History

feat: add headless `t3 connect` setup for SSH hosts (#3749)

Theo Browne committed 3 months ago

Commit 7a820ab1 parent 53e3c9835 files · +3017 −144Browse files

Showing the changes to apps/server/src (14 of 35 files). Show all

Unified · Split

14 files
  1. apps/server/src/bin.test.ts +3 −3
  2. apps/server/src/cli/connect.test.ts +42 −0
  3. apps/server/src/cli/connect.ts +245 −56
  4. apps/server/src/cloud/CliTokenManager.test.ts +264 −0
  5. apps/server/src/cloud/CliTokenManager.ts +283 −68
  6. apps/server/src/cloud/bootService.test.ts +529 −0
  7. apps/server/src/cloud/bootService.ts +452 −0
  8. apps/server/src/cloud/cliAuthHtml.test.ts +13 −0
  9. apps/server/src/cloud/cliAuthHtml.ts +140 −0
  10. apps/server/src/cloud/http.test.ts +1 −0
  11. apps/server/src/cloud/publicConfig.test.ts +35 −0
  12. apps/server/src/cloud/publicConfig.ts +40 −1
  13. apps/server/src/persistence/Migrations.ts +4 −8
  14. apps/server/src/server.ts +4 −1
apps/server/src/bin.test.ts+3−3
@@ -236,7 +236,7 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { · 235 unmodified lines
236236 }),
237237 );
238238
239− it.effect("logs in to headless connect without enabling access", () =>
239+ it.effect("accepts the --headless login override without enabling access", () =>
240240 Effect.gen(function* () {
241241 const baseDir = NodeFS.mkdtempSync(
242242 NodePath.join(NodeOS.tmpdir(), "t3-cli-cloud-login-test-"),
@@ -254,7 +254,7 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { · 11 unmodified lines
254254 );
255255
256256 const login = yield* captureStdout(
257− runConnectCli(["connect", "login", "--base-dir", baseDir]),
257+ runConnectCli(["connect", "login", "--base-dir", baseDir, "--headless"]),
258258 );
259259 const status = yield* captureStdout(
260260 runConnectCli(["connect", "status", "--base-dir", baseDir, "--json"]),
@@ -265,7 +265,7 @@ it.layer(NodeServices.layer)("bin cli parsing", (it) => { · 4 unmodified lines
265265 readonly authenticated: boolean;
266266 };
267267
268− assert.equal(login.output, "Signed in to T3 Connect.");
268+ assert.equal(login.output, "✓ Signed in");
269269 assert.isFalse(decoded.desired);
270270 assert.isTrue(decoded.authenticated);
271271 }),
apps/server/src/cli/connect.test.ts+42−0
@@ -1,19 +1,45 @@
11 import * as RelayClient from "@t3tools/shared/relayClient";
22 import { assert, it } from "@effect/vitest";
33 import * as Cause from "effect/Cause";
4+import * as ConfigProvider from "effect/ConfigProvider";
45 import * as Console from "effect/Console";
56 import * as Effect from "effect/Effect";
67 import * as Exit from "effect/Exit";
78 import * as Logger from "effect/Logger";
89 import * as Option from "effect/Option";
910 import * as References from "effect/References";
11+import * as Terminal from "effect/Terminal";
1012
1113 import {
1214 acquireRelayClientForLink,
15+ formatHeadlessAuthorizationPrompt,
16+ formatRelayClientReady,
17+ headlessSessionConfig,
1318 isPublishAgentActivityEnabledValue,
19+ recoverBootServiceOffer,
1420 reportCloudDisconnectResults,
1521 } from "./connect.ts";
1622
23+it("explains how to complete headless authorization", () => {
24+ assert.equal(
25+ formatHeadlessAuthorizationPrompt("https://example.test/connect"),
26+ [
27+ "Headless authorization",
28+ "Open this URL on a device with a browser:",
29+ " https://example.test/connect",
30+ "",
31+ "After signing in, return here and enter the code shown in your browser.",
32+ ].join("\n"),
33+ );
34+});
35+
36+it("formats relay readiness without printing its installation path", () => {
37+ assert.equal(formatRelayClientReady("2026.5.2"), "✓ Relay client ready · cloudflared 2026.5.2");
38+});
39+
40+const readHeadlessSessionConfig = (env: Record<string, string>) =>
41+ headlessSessionConfig.pipe(Effect.provide(ConfigProvider.layer(ConfigProvider.fromEnv({ env }))));
42+
1743 const managedExecutable = {
1844 status: "available",
1945 executablePath: "/tmp/cloudflared",
@@ -21,6 +47,22 @@ const managedExecutable = { · 1 unmodified line
2147 version: RelayClient.CLOUDFLARED_VERSION,
2248 } as const;
2349
50+it.effect("detects headless operation from individual SSH config values", () =>
51+ Effect.gen(function* () {
52+ assert.isFalse(yield* readHeadlessSessionConfig({}));
53+ assert.isFalse(yield* readHeadlessSessionConfig({ CI: "true" }));
54+ assert.isTrue(yield* readHeadlessSessionConfig({ SSH_CONNECTION: "client server" }));
55+ assert.isTrue(yield* readHeadlessSessionConfig({ SSH_TTY: "/dev/pts/1" }));
56+ }),
57+);
58+
59+it.effect("treats cancelling optional background setup as a successful skip", () =>
60+ Effect.gen(function* () {
61+ const result = yield* recoverBootServiceOffer(Effect.fail(new Terminal.QuitError({})));
62+ assert.isFalse(result);
63+ }),
64+);
65+
2466 it.effect("does not install the relay client when the user declines the managed download", () =>
2567 Effect.gen(function* () {
2668 let installCalls = 0;
apps/server/src/cli/connect.ts+245−56
@@ -6,9 +6,12 @@ import { · 5 unmodified lines
66 } from "@t3tools/contracts";
77 import { RelayOkResponse } from "@t3tools/contracts/relay";
88 import * as RelayClient from "@t3tools/shared/relayClient";
9+import * as Terminal from "effect/Terminal";
910 import { withRelayClientTracing } from "@t3tools/shared/relayTracing";
1011 import * as Cause from "effect/Cause";
12+import * as Config from "effect/Config";
1113 import * as Console from "effect/Console";
14+import * as Crypto from "effect/Crypto";
1215 import * as Duration from "effect/Duration";
1316 import * as Effect from "effect/Effect";
1417 import * as Exit from "effect/Exit";
@@ -16,6 +19,7 @@ import * as FileSystem from "effect/FileSystem"; · 1 unmodified line
1619 import * as Layer from "effect/Layer";
1720 import * as Option from "effect/Option";
1821 import * as References from "effect/References";
22+import * as Schema from "effect/Schema";
1923 import { Command, Flag, GlobalFlag, Prompt } from "effect/unstable/cli";
2024 import {
2125 FetchHttpClient,
@@ -25,8 +29,10 @@ import { · 3 unmodified lines
2529 } from "effect/unstable/http";
2630 import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient";
2731
32+import packageJson from "../../package.json" with { type: "json" };
2833 import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts";
2934 import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
35+import * as BootService from "../cloud/bootService.ts";
3036 import * as CliState from "../cloud/CliState.ts";
3137 import * as CliTokenManager from "../cloud/CliTokenManager.ts";
3238 import {
@@ -38,6 +44,8 @@ import { relayUrlConfig } from "../cloud/publicConfig.ts"; · 5 unmodified lines
3844 import { headlessRelayClientTracingLayer } from "../cloud/relayTracing.ts";
3945 import * as ServerConfig from "../config.ts";
4046 import * as ServerEnvironment from "../environment/ServerEnvironment.ts";
47+import * as ExternalLauncher from "../process/externalLauncher.ts";
48+import * as ProcessRunner from "../processRunner.ts";
4149 import { readPersistedServerRuntimeState } from "../serverRuntimeState.ts";
4250 import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts";
4351
@@ -46,6 +54,81 @@ const jsonFlag = Flag.boolean("json").pipe( · 2 unmodified lines
4654 Flag.withDefault(false),
4755 );
4856
57+const isCloudCliTokenManagerError = Schema.is(CliTokenManager.CloudCliTokenManagerError);
58+
59+const headlessFlag = Flag.boolean("headless").pipe(
60+ Flag.withDescription("Authorize without a local browser using out-of-band OAuth."),
61+ Flag.withDefault(false),
62+);
63+
64+/**
65+ * Inside an SSH session there is no local browser to complete the loopback
66+ * OAuth callback, so out-of-band OAuth is the only flow that can work.
67+ */
68+export const headlessSessionConfig = Config.all({
69+ sshConnection: Config.string("SSH_CONNECTION").pipe(Config.option),
70+ sshTty: Config.string("SSH_TTY").pipe(Config.option),
71+}).pipe(
72+ Config.map(({ sshConnection, sshTty }) => Option.isSome(sshConnection) || Option.isSome(sshTty)),
73+);
74+
75+const promptForOutOfBandOAuthCode = Effect.fn("cloud.cli.prompt_for_out_of_band_oauth_code")(
76+ function* ({ authorizeUrl, validate }: CliTokenManager.OutOfBandOAuthPromptInput) {
77+ yield* Console.log(formatHeadlessAuthorizationPrompt(authorizeUrl));
78+ return yield* Prompt.run(Prompt.text({ message: "Authorization code", validate }));
79+ },
80+);
81+
82+export function formatHeadlessAuthorizationPrompt(authorizeUrl: string): string {
83+ return [
84+ "Headless authorization",
85+ "Open this URL on a device with a browser:",
86+ ` ${authorizeUrl}`,
87+ "",
88+ "After signing in, return here and enter the code shown in your browser.",
89+ ].join("\n");
90+}
91+
92+/** Returns the connected account identity, if the flow could determine one. */
93+const authorizeCli = Effect.fn("cloud.cli.authorize")(function* (options: {
94+ readonly headless: boolean;
95+}) {
96+ const tokens = yield* CliTokenManager.CloudCliTokenManager;
97+ const useOutOfBandOAuth = options.headless || (yield* headlessSessionConfig);
98+ if (!useOutOfBandOAuth) {
99+ const authorization = yield* tokens.get;
100+ if (authorization._tag === "Authorized") {
101+ return authorization.token.identity ?? null;
102+ }
103+ yield* Console.log("\nHeadless mode enabled. A new authorization link is ready below.");
104+ }
105+ // A stored credential whose refresh fails (revoked, expired grant) must
106+ // fall through to a fresh out-of-band authorization, not dead-end the command.
107+ const existing = yield* tokens.getExisting.pipe(
108+ Effect.catchTag("CloudCliCredentialRefreshError", () =>
109+ Console.log(
110+ "The stored T3 Connect credential could not be refreshed; signing in again.",
111+ ).pipe(Effect.as(Option.none())),
112+ ),
113+ );
114+ if (Option.isSome(existing)) {
115+ return existing.value.identity ?? null;
116+ }
117+ const { token, identity } = yield* CliTokenManager.outOfBandOAuthLogin(
118+ promptForOutOfBandOAuthCode,
119+ ).pipe(
120+ Effect.mapError((cause) =>
121+ // Ctrl-C / EOF at the prompt is a QuitError; let it propagate so the CLI
122+ // cancels quietly instead of dumping an authorization error.
123+ Terminal.isQuitError(cause) || isCloudCliTokenManagerError(cause)
124+ ? cause
125+ : new CliTokenManager.CloudCliAuthorizationError({ cause }),
126+ ),
127+ );
128+ yield* tokens.store(token);
129+ return identity;
130+});
131+
49132 function bytesToString(value: Uint8Array): string {
50133 return new TextDecoder().decode(value);
51134 }
@@ -313,6 +396,21 @@ const disconnectCloud = Effect.fn("cloud.cli.disconnect")(function* (options: { · 261 unmodified lines
313396 if (options.clearAuthorization) {
314397 const tokens = yield* CliTokenManager.CloudCliTokenManager;
315398 yield* tokens.clear;
399+
400+ // uninstall itself no-ops when nothing is installed (and on non-Linux),
401+ // so no status pre-check that could mask a real removal failure.
402+ const bootService = yield* BootService.BootService;
403+ yield* bootService.uninstall.pipe(
404+ Effect.tap((removed) =>
405+ removed ? Console.log("Removed the T3 Code background service.") : Effect.void,
406+ ),
407+ Effect.catchTag("BootServiceUnsupportedError", () => Effect.succeed(false)),
408+ Effect.catch((error) =>
409+ Console.warn(`Could not remove the background service: ${error.message}`).pipe(
410+ Effect.as(false),
411+ ),
412+ ),
413+ );
316414 }
317415
318416 yield* reportCloudDisconnectResults({
@@ -326,7 +424,7 @@ const disconnectCloud = Effect.fn("cloud.cli.disconnect")(function* (options: { · 7 unmodified lines
326424 }
327425 });
328426
329−const runCloudCommand = <A, E>(
427+const runCloudCommand = Effect.fn("cloud.cli.run_cloud_command")(function* <A, E>(
330428 flags: { readonly baseDir: Option.Option<string> },
331429 run: Effect.Effect<
332430 A,
@@ -335,6 +433,8 @@ const runCloudCommand = <A, E>( · 2 unmodified lines
335433 | CliTokenManager.CloudCliTokenManager
336434 | RelayClient.RelayClient
337435 | EnvironmentAuth.EnvironmentAuth
436+ | BootService.BootService
437+ | Crypto.Crypto
338438 | FileSystem.FileSystem
339439 | HttpClient.HttpClient
340440 | Prompt.Environment
@@ -344,37 +444,79 @@ const runCloudCommand = <A, E>( · 3 unmodified lines
344444 options?: {
345445 readonly quietLogs?: boolean;
346446 },
347−) =>
348− Effect.gen(function* () {
349− const logLevel = yield* GlobalFlag.LogLevel;
350− const config = yield* resolveCliAuthConfig(flags, logLevel);
351− const minimumLogLevel = options?.quietLogs ? "Error" : config.logLevel;
352− const runtimeLayer = Layer.mergeAll(
353− ServerSecretStore.layer,
354− CliTokenManager.layer.pipe(Layer.provide(ServerSecretStore.layer)),
355− RelayClient.layerCloudflared({ baseDir: config.baseDir }),
356− EnvironmentAuth.runtimeLayer,
357− ServerEnvironment.layer,
358− headlessRelayClientTracingLayer,
359− ).pipe(
360− Layer.provideMerge(FetchHttpClient.layer),
361− Layer.provideMerge(ServerConfig.layer(config)),
362− Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)),
447+) {
448+ const logLevel = yield* GlobalFlag.LogLevel;
449+ const config = yield* resolveCliAuthConfig(flags, logLevel);
450+ const minimumLogLevel = options?.quietLogs ? "Error" : config.logLevel;
451+ const runtimeLayer = Layer.mergeAll(
452+ ServerSecretStore.layer,
453+ CliTokenManager.layer.pipe(
454+ Layer.provide(ServerSecretStore.layer),
455+ Layer.provide(ExternalLauncher.layer),
456+ ),
457+ RelayClient.layerCloudflared({ baseDir: config.baseDir }),
458+ EnvironmentAuth.runtimeLayer,
459+ ServerEnvironment.layer,
460+ BootService.layer({
461+ baseDir: config.baseDir,
462+ logsDir: config.logsDir,
463+ cliVersion: packageJson.version,
464+ }).pipe(Layer.provide(ProcessRunner.layer)),
465+ headlessRelayClientTracingLayer,
466+ ).pipe(
467+ Layer.provideMerge(FetchHttpClient.layer),
468+ Layer.provideMerge(ServerConfig.layer(config)),
469+ Layer.provide(Layer.succeed(References.MinimumLogLevel, minimumLogLevel)),
470+ );
471+ return yield* run.pipe(Effect.provide(runtimeLayer));
472+});
473+
474+const connectedAs = (identity: string | null): string => (identity ? ` as ${identity}` : "");
475+
476+export function formatRelayClientReady(version: string): string {
477+ return `✓ Relay client ready · cloudflared ${version}`;
478+}
479+
480+const linkEnvironmentForConnect = Effect.fn("cloud.cli.link_environment")(function* (options: {
481+ readonly headless: boolean;
482+ readonly publishOnly?: boolean;
483+}) {
484+ const publishOnly = options.publishOnly ?? false;
485+ if (!publishOnly) {
486+ const relayClient = yield* RelayClient.RelayClient;
487+ const installed = yield* acquireRelayClientForLink(
488+ relayClient,
489+ confirmRelayClientInstall,
490+ reportRelayClientInstallProgress,
363491 );
364− return yield* run.pipe(Effect.provide(runtimeLayer));
365− });
492+ if (Option.isNone(installed)) {
493+ yield* Console.log("T3 Connect setup cancelled. The relay client was not installed.");
494+ return null;
495+ }
496+ yield* Console.log(formatRelayClientReady(installed.value.version));
497+ }
498+
499+ const identity = yield* authorizeCli(options);
500+ yield* CliState.setCliDesiredCloudLink(true, publishOnly ? "publish_only" : "managed");
501+ if (publishOnly) {
502+ const secrets = yield* ServerSecretStore.ServerSecretStore;
503+ yield* secrets.set(PUBLISH_AGENT_ACTIVITY_SECRET, stringToBytes("true"));
504+ }
505+ return { identity } as const;
506+});
366507
367508 const connectLoginCommand = Command.make("login", {
368509 ...projectLocationFlags,
510+ headless: headlessFlag,
369511 }).pipe(
370512 Command.withDescription("Authorize the T3 Connect CLI without enabling remote access."),
371513 Command.withHandler((flags) =>
372514 runCloudCommand(
373515 flags,
374516 Effect.gen(function* () {
375− const tokens = yield* CliTokenManager.CloudCliTokenManager;
376− yield* tokens.get;
377− yield* Console.log("Signed in to T3 Connect.");
517+ yield* Console.log("T3 Connect\n");
518+ const identity = yield* authorizeCli(flags);
519+ yield* Console.log(`✓ Signed in${connectedAs(identity)}`);
378520 }),
379521 ),
380522 ),
@@ -382,6 +524,7 @@ const connectLoginCommand = Command.make("login", { · 1 unmodified line
382524
383525 const connectLinkCommand = Command.make("link", {
384526 ...projectLocationFlags,
527+ headless: headlessFlag,
385528 publishOnly: Flag.boolean("publish-only").pipe(
386529 Flag.withDescription(
387530 "Link to publish agent activity only — no managed tunnel. Reach this environment out of band (e.g. Tailscale).",
@@ -394,41 +537,15 @@ const connectLinkCommand = Command.make("link", { · 6 unmodified lines
394537 runCloudCommand(
395538 flags,
396539 Effect.gen(function* () {
397− // A publish-only link needs no Cloudflare tunnel, so skip installing the
398− // relay client entirely.
399− if (!flags.publishOnly) {
400− const relayClient = yield* RelayClient.RelayClient;
401− const installed = yield* acquireRelayClientForLink(
402− relayClient,
403− confirmRelayClientInstall,
404− reportRelayClientInstallProgress,
405− );
406− if (Option.isNone(installed)) {
407− yield* Console.log("T3 Connect setup cancelled. The relay client was not installed.");
408− return;
409− }
540+ yield* Console.log("T3 Connect\n");
541+ const linked = yield* linkEnvironmentForConnect(flags);
542+ if (linked) {
410543 yield* Console.log(
411− `Using relay client ${installed.value.version} from ${installed.value.executablePath}.`,
544+ flags.publishOnly
545+ ? `✓ Authorized${connectedAs(linked.identity)}\n\nNext\n Start T3 to publish agent activity (no managed tunnel).`
546+ : `✓ Authorized${connectedAs(linked.identity)}\n\nNext\n Start the server with \`t3 serve\` to make this machine reachable.`,
412547 );
413548 }
414−
415− const tokens = yield* CliTokenManager.CloudCliTokenManager;
416− yield* tokens.get;
417− yield* CliState.setCliDesiredCloudLink(
418− true,
419− flags.publishOnly ? "publish_only" : "managed",
420− );
421− if (flags.publishOnly) {
422− // A publish-only link exists solely to publish; without the publish
423− // flag the link would be inert and the success message a lie.
424− const secrets = yield* ServerSecretStore.ServerSecretStore;
425− yield* secrets.set(PUBLISH_AGENT_ACTIVITY_SECRET, stringToBytes("true"));
426− }
427− yield* Console.log(
428− flags.publishOnly
429− ? "This environment will publish agent activity to your mobile clients the next time T3 starts (no managed tunnel)."
430− : "This T3 environment will be available through T3 Connect the next time T3 starts.",
431− );
432549 }),
433550 ),
434551 ),
@@ -566,8 +683,80 @@ const connectLogoutCommand = Command.make("logout", { · 131 unmodified lines
566683 ),
567684 );
568685
569−export const connectCommand = Command.make("connect").pipe(
570− Command.withDescription("Manage headless T3 Connect access."),
686+const offerBootService = Effect.gen(function* () {
687+ const bootService = yield* BootService.BootService;
688+ const { supported, installed, current } = yield* bootService.status;
689+ if (!supported) {
690+ // Don't prompt for something that can only fail; background setup is
691+ // Linux/systemd-only for now.
692+ return false;
693+ }
694+ if (installed && current) {
695+ yield* Console.log("T3 Code is already set up to run in the background on this machine.");
696+ return true;
697+ }
698+ const wanted = yield* Prompt.run(
699+ Prompt.confirm({
700+ message: installed
701+ ? "The installed T3 Code background service is from an older setup. Update it now?"
702+ : "Run T3 Code in the background whenever this machine boots? " +
703+ "It stays reachable through T3 Connect even after you log out.",
704+ initial: true,
705+ }),
706+ );
707+ if (!wanted) {
708+ return false;
709+ }
710+ const plan = yield* bootService.install;
711+ yield* Console.log(`Background service installed. Logs: ${plan.logPath}`);
712+ return true;
713+});
714+
715+export const recoverBootServiceOffer = <R>(
716+ offer: Effect.Effect<boolean, BootService.BootServiceError | Terminal.QuitError, R>,
717+) =>
718+ offer.pipe(
719+ Effect.catchTags({
720+ QuitError: () => Effect.succeed(false),
721+ BootServiceUnsupportedError: (error) =>
722+ Console.log(`Skipping background setup: ${error.message}`).pipe(Effect.as(false)),
723+ BootServiceCommandError: (error) =>
724+ Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)),
725+ BootServiceInstallError: (error) =>
726+ Console.warn(`Background setup did not finish: ${error.message}`).pipe(Effect.as(false)),
727+ }),
728+ );
729+
730+export const connectCommand = Command.make("connect", {
731+ ...projectLocationFlags,
732+ headless: headlessFlag,
733+}).pipe(
734+ Command.withDescription("Set up T3 Connect for this machine."),
735+ Command.withHandler((flags) =>
736+ runCloudCommand(
737+ flags,
738+ Effect.gen(function* () {
739+ yield* Console.log("T3 Connect\n");
740+ const linked = yield* linkEnvironmentForConnect(flags);
741+ if (!linked) {
742+ return;
743+ }
744+ // Show which account was linked so an unexpected identity (an
745+ // authorization code for a different account) is visible before the
746+ // machine is brought online.
747+ yield* Console.log(`✓ Connected${connectedAs(linked.identity)}`);
748+
749+ // Connect itself already succeeded; a boot-service failure must not
750+ // fail the command, just tell the user what happened and move on.
751+ const background = yield* recoverBootServiceOffer(offerBootService);
752+ yield* Console.log(
753+ background
754+ ? "\n✓ Background service ready\n\nT3 Code will stay reachable after you log out."
755+ : "\nNext\n Start the server with `t3 serve` to make this machine reachable.",
756+ );
757+ }),
758+ ),
759+ ),
571760 Command.withSubcommands([
572761 connectLoginCommand,
573762 connectLinkCommand,
apps/server/src/cloud/CliTokenManager.test.ts+264−0
@@ -0,0 +1,264 @@
1+import { readConnectAuthorizeRequest } from "@t3tools/shared/connectAuth";
2+import * as NodeServices from "@effect/platform-node/NodeServices";
3+import { assert, it } from "@effect/vitest";
4+import * as ConfigProvider from "effect/ConfigProvider";
5+import * as Crypto from "effect/Crypto";
6+import * as Deferred from "effect/Deferred";
7+import * as Effect from "effect/Effect";
8+import * as Encoding from "effect/Encoding";
9+import * as Layer from "effect/Layer";
10+import * as Option from "effect/Option";
11+import * as Queue from "effect/Queue";
12+import * as Schema from "effect/Schema";
13+import * as Terminal from "effect/Terminal";
14+import * as HttpClient from "effect/unstable/http/HttpClient";
15+import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse";
16+
17+import * as CliTokenManager from "./CliTokenManager.ts";
18+import type { OutOfBandOAuthPromptInput } from "./CliTokenManager.ts";
19+
20+// pk_test_<base64 of "clerk.example.test$">
21+const TEST_ENV = {
22+ T3CODE_CLERK_PUBLISHABLE_KEY: "pk_test_Y2xlcmsuZXhhbXBsZS50ZXN0JA==",
23+ T3CODE_CLERK_CLI_OAUTH_CLIENT_ID: "oauth_client_test",
24+ T3CODE_HOSTED_APP_URL: "https://hosted.example.test",
25+};
26+
27+interface RecordedTokenRequest {
28+ readonly url: string;
29+ readonly params: URLSearchParams;
30+}
31+
32+// A JWT whose payload claims { email: "theo@example.test" } (signature is not
33+// verified — the CLI only reads the claim to display the connected account).
34+const TestIdTokenHeaderJson = Schema.fromJsonString(Schema.Struct({ alg: Schema.Literal("none") }));
35+const TestIdTokenPayloadJson = Schema.fromJsonString(Schema.Struct({ email: Schema.String }));
36+const encodeTestIdTokenHeader = Schema.encodeSync(TestIdTokenHeaderJson);
37+const encodeTestIdTokenPayload = Schema.encodeSync(TestIdTokenPayloadJson);
38+const idTokenWithEmail = (() => {
39+ const header = Encoding.encodeBase64Url(encodeTestIdTokenHeader({ alg: "none" }));
40+ const payload = Encoding.encodeBase64Url(
41+ encodeTestIdTokenPayload({ email: "theo@example.test" }),
42+ );
43+ return `${header}.${payload}.`;
44+})();
45+
46+const TestTokenResponseJson = Schema.fromJsonString(
47+ Schema.Struct({
48+ access_token: Schema.String,
49+ refresh_token: Schema.String,
50+ id_token: Schema.String,
51+ expires_in: Schema.Number,
52+ token_type: Schema.String,
53+ }),
54+);
55+const encodeTestTokenResponse = Schema.encodeSync(TestTokenResponseJson);
56+
57+const makeTokenEndpointLayer = (
58+ requests: Array<RecordedTokenRequest>,
59+ options?: { readonly idToken?: string },
60+) =>
61+ Layer.succeed(
62+ HttpClient.HttpClient,
63+ HttpClient.make((request) =>
64+ Effect.sync(() => {
65+ const body =
66+ request.body._tag === "Uint8Array" ? new TextDecoder().decode(request.body.body) : "";
67+ requests.push({ url: request.url, params: new URLSearchParams(body) });
68+ return HttpClientResponse.fromWeb(
69+ request,
70+ new Response(
71+ encodeTestTokenResponse({
72+ access_token: "access-token-1",
73+ refresh_token: "refresh-token-1",
74+ id_token: options?.idToken ?? idTokenWithEmail,
75+ expires_in: 3600,
76+ token_type: "bearer",
77+ }),
78+ { status: 200, headers: { "content-type": "application/json" } },
79+ ),
80+ );
81+ }),
82+ ),
83+ );
84+
85+const provideTestEnv = Effect.provide(
86+ ConfigProvider.layer(ConfigProvider.fromEnv({ env: TEST_ENV })),
87+);
88+
89+const isAuthorizationError = Schema.is(CliTokenManager.CloudCliAuthorizationError);
90+
91+class PromptRejectedError extends Schema.TaggedErrorClass<PromptRejectedError>()(
92+ "PromptRejectedError",
93+ { message: Schema.String },
94+) {}
95+
96+it("formats loopback authorization with a headless-host fallback", () => {
97+ assert.equal(
98+ CliTokenManager.formatLoopbackAuthorizationPrompt("https://clerk.example.test/authorize"),
99+ [
100+ "Open this URL to authorize T3 Connect:",
101+ " https://clerk.example.test/authorize",
102+ "",
103+ "Press \u001b[1mEnter\u001b[22m to open it in your browser.",
104+ "No browser on this device? Press \u001b[1mH\u001b[22m to switch to headless mode.",
105+ ].join("\n"),
106+ );
107+});
108+
109+const makeTestTerminal = (queue: Queue.Queue<Terminal.UserInput>) =>
110+ Terminal.make({
111+ columns: Effect.succeed(80),
112+ rows: Effect.succeed(24),
113+ readInput: Effect.succeed(Queue.asDequeue(queue)),
114+ readLine: Effect.never,
115+ display: () => Effect.void,
116+ });
117+
118+const userInput = (name: string): Terminal.UserInput => ({
119+ input: Option.some(name),
120+ key: { name, ctrl: false, meta: false, shift: name !== name.toLowerCase() },
121+});
122+
123+it.effect("opens the browser on Enter and switches the active flow on H", () =>
124+ Effect.gen(function* () {
125+ const queue = yield* Queue.make<Terminal.UserInput>();
126+ yield* Queue.offerAll(queue, [userInput("enter"), userInput("H")]);
127+ const opened: Array<string> = [];
128+
129+ const result = yield* CliTokenManager.waitForLoopbackAuthorization({
130+ authorizationUrl: "https://clerk.example.test/authorize",
131+ callback: Effect.never,
132+ terminal: makeTestTerminal(queue),
133+ launchBrowser: (url) =>
134+ Effect.sync(() => {
135+ opened.push(url);
136+ }),
137+ });
138+
139+ assert.deepEqual(opened, ["https://clerk.example.test/authorize"]);
140+ assert.deepEqual(result, { _tag: "HeadlessRequested" });
141+ }),
142+);
143+
144+it.effect("finishes normally when the browser callback wins", () =>
145+ Effect.gen(function* () {
146+ const queue = yield* Queue.make<Terminal.UserInput>();
147+ const callback = yield* Deferred.make<string>();
148+ yield* Deferred.succeed(callback, "clerk-code-123");
149+
150+ const result = yield* CliTokenManager.waitForLoopbackAuthorization({
151+ authorizationUrl: "https://clerk.example.test/authorize",
152+ callback: Deferred.await(callback),
153+ terminal: makeTestTerminal(queue),
154+ launchBrowser: () => Effect.die("browser launch should not run"),
155+ });
156+
157+ assert.deepEqual(result, { _tag: "AuthorizationCode", code: "clerk-code-123" });
158+ }),
159+);
160+
161+it.layer(NodeServices.layer)("CliTokenManager.outOfBandOAuthLogin", (it) => {
162+ it.effect("prints a hosted authorize URL and exchanges the out-of-band code with PKCE", () =>
163+ Effect.gen(function* () {
164+ const requests: Array<RecordedTokenRequest> = [];
165+ let seenAuthorizeUrl = "";
166+
167+ const { token, identity } = yield* CliTokenManager.outOfBandOAuthLogin(
168+ ({ authorizeUrl, validate }: OutOfBandOAuthPromptInput) =>
169+ Effect.gen(function* () {
170+ seenAuthorizeUrl = authorizeUrl;
171+ const request = readConnectAuthorizeRequest(new URL(authorizeUrl));
172+ assert.isNotNull(request);
173+ return yield* validate(`clerk-code-123.${request!.state}`).pipe(
174+ Effect.mapError((message) => new PromptRejectedError({ message })),
175+ );
176+ }),
177+ ).pipe(Effect.provide(makeTokenEndpointLayer(requests)), provideTestEnv);
178+
179+ const authorizeUrl = new URL(seenAuthorizeUrl);
180+ assert.equal(authorizeUrl.origin, "https://hosted.example.test");
181+ assert.equal(authorizeUrl.pathname, "/connect");
182+ const request = readConnectAuthorizeRequest(authorizeUrl);
183+ assert.isNotNull(request);
184+ assert.match(request!.state, /^[A-Za-z0-9_-]{22}$/);
185+
186+ assert.equal(token.accessToken, "access-token-1");
187+ assert.equal(token.refreshToken, "refresh-token-1");
188+ assert.equal(token.identity, "theo@example.test");
189+ // The id_token's email claim is surfaced so connect can show the account.
190+ assert.equal(identity, "theo@example.test");
191+
192+ assert.lengthOf(requests, 1);
193+ const exchange = requests[0]!;
194+ assert.equal(exchange.url, "https://clerk.example.test/oauth/token");
195+ assert.equal(exchange.params.get("grant_type"), "authorization_code");
196+ assert.equal(exchange.params.get("code"), "clerk-code-123");
197+ assert.equal(
198+ exchange.params.get("redirect_uri"),
199+ "https://hosted.example.test/connect/callback",
200+ );
201+ assert.equal(exchange.params.get("client_id"), "oauth_client_test");
202+ // The verifier must hash to the challenge advertised in the authorize URL.
203+ const verifier = exchange.params.get("code_verifier");
204+ assert.isNotNull(verifier);
205+ const crypto = yield* Crypto.Crypto;
206+ const digest = yield* crypto.digest("SHA-256", new TextEncoder().encode(verifier!));
207+ assert.equal(Encoding.encodeBase64Url(digest), request!.challenge);
208+ }),
209+ );
210+
211+ it.effect("rejects out-of-band codes whose state does not match the request", () =>
212+ Effect.gen(function* () {
213+ const requests: Array<RecordedTokenRequest> = [];
214+
215+ const validationErrors: Array<string> = [];
216+ const result = yield* CliTokenManager.outOfBandOAuthLogin(
217+ ({ validate }: OutOfBandOAuthPromptInput) =>
218+ validate("clerk-code-123.wrong-state").pipe(
219+ Effect.tapError((message) => Effect.sync(() => validationErrors.push(message))),
220+ Effect.mapError((message) => new PromptRejectedError({ message })),
221+ ),
222+ ).pipe(Effect.provide(makeTokenEndpointLayer(requests)), provideTestEnv, Effect.flip);
223+
224+ assert.lengthOf(requests, 0);
225+ assert.lengthOf(validationErrors, 1);
226+ assert.include(validationErrors[0], "different connect request");
227+ assert.instanceOf(result, PromptRejectedError);
228+ }),
229+ );
230+
231+ it.effect("ignores an id_token whose claims are not valid JSON", () =>
232+ Effect.gen(function* () {
233+ const requests: Array<RecordedTokenRequest> = [];
234+ const malformedIdToken = `header.${Encoding.encodeBase64Url("not-json")}.signature`;
235+
236+ const { identity } = yield* CliTokenManager.outOfBandOAuthLogin(
237+ ({ authorizeUrl }: OutOfBandOAuthPromptInput) => {
238+ const request = readConnectAuthorizeRequest(new URL(authorizeUrl));
239+ assert.isNotNull(request);
240+ return Effect.succeed(`clerk-code-123.${request!.state}`);
241+ },
242+ ).pipe(
243+ Effect.provide(makeTokenEndpointLayer(requests, { idToken: malformedIdToken })),
244+ provideTestEnv,
245+ );
246+
247+ assert.isNull(identity);
248+ assert.lengthOf(requests, 1);
249+ }),
250+ );
251+
252+ it.effect("fails without touching the token endpoint when the prompt returns garbage", () =>
253+ Effect.gen(function* () {
254+ const requests: Array<RecordedTokenRequest> = [];
255+
256+ const result = yield* CliTokenManager.outOfBandOAuthLogin(() =>
257+ Effect.succeed("not-a-connect-code"),
258+ ).pipe(Effect.provide(makeTokenEndpointLayer(requests)), provideTestEnv, Effect.flip);
259+
260+ assert.lengthOf(requests, 0);
261+ assert.isTrue(isAuthorizationError(result));
262+ }),
263+ );
264+});
apps/server/src/cloud/CliTokenManager.ts+283−68
@@ -3,6 +3,7 @@ import * as NodeHttp from "node:http"; · 2 unmodified lines
33
44 import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer";
55 import * as Clock from "effect/Clock";
6+import * as Cause from "effect/Cause";
67 import * as Console from "effect/Console";
78 import * as Context from "effect/Context";
89 import * as Crypto from "effect/Crypto";
@@ -12,8 +13,10 @@ import * as Effect from "effect/Effect"; · 3 unmodified lines
1213 import * as Encoding from "effect/Encoding";
1314 import * as Layer from "effect/Layer";
1415 import * as Option from "effect/Option";
16+import * as Queue from "effect/Queue";
1517 import * as Schema from "effect/Schema";
1618 import * as Semaphore from "effect/Semaphore";
19+import * as Terminal from "effect/Terminal";
1720 import * as HttpClient from "effect/unstable/http/HttpClient";
1821 import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest";
1922 import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse";
@@ -21,19 +24,105 @@ import * as HttpRouter from "effect/unstable/http/HttpRouter"; · 1 unmodified line
2124 import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest";
2225 import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";
2326
27+import {
28+ buildConnectAuthorizeRequestUrl,
29+ buildConnectClerkAuthorizeUrl,
30+ checkConnectAuthCode,
31+ connectCallbackUrl,
32+} from "@t3tools/shared/connectAuth";
33+
2434 import * as ServerSecretStore from "../auth/ServerSecretStore.ts";
25−import { cloudCliOAuthConfig, type CloudCliOAuthConfig } from "./publicConfig.ts";
35+import * as ExternalLauncher from "../process/externalLauncher.ts";
36+import {
37+ cloudCliOAuthConfig,
38+ hostedAppUrlConfig,
39+ type CloudCliOAuthConfig,
40+} from "./publicConfig.ts";
41+import { renderLoopbackAuthorizationCompleteHtml } from "./cliAuthHtml.ts";
2642
2743 const CLOUD_CLI_OAUTH_TOKEN_SECRET = "cloud-cli-oauth-token";
2844 const CLOUD_CLI_OAUTH_CALLBACK_TIMEOUT = Duration.minutes(10);
2945 const CLOUD_CLI_OAUTH_REFRESH_EARLY_MS = Duration.toMillis(Duration.minutes(5));
46+const boldTerminalText = (value: string): string => `\u001b[1m${value}\u001b[22m`;
47+
48+export function formatLoopbackAuthorizationPrompt(authorizationUrl: string): string {
49+ return [
50+ "Open this URL to authorize T3 Connect:",
51+ ` ${authorizationUrl}`,
52+ "",
53+ `Press ${boldTerminalText("Enter")} to open it in your browser.`,
54+ `No browser on this device? Press ${boldTerminalText("H")} to switch to headless mode.`,
55+ ].join("\n");
56+}
57+
58+export type LoopbackAuthorizationResult =
59+ | { readonly _tag: "AuthorizationCode"; readonly code: string }
60+ | { readonly _tag: "HeadlessRequested" };
61+
62+const readLoopbackAuthorizationAction = Effect.fn(
63+ "cloud.cli_token.read_loopback_authorization_action",
64+)(function* (input: Queue.Dequeue<Terminal.UserInput, Cause.Done>) {
65+ while (true) {
66+ const event = yield* Queue.take(input).pipe(Effect.mapError(() => new Terminal.QuitError({})));
67+ const keyName = event.key.name.toLowerCase();
68+ if (!event.key.ctrl && !event.key.meta && keyName === "h") {
69+ return "headless" as const;
70+ }
71+ if (keyName === "enter" || keyName === "return") {
72+ return "open-browser" as const;
73+ }
74+ }
75+});
76+
77+export const waitForLoopbackAuthorization = Effect.fn(
78+ "cloud.cli_token.wait_for_loopback_authorization",
79+)(function* <E, R>(input: {
80+ readonly authorizationUrl: string;
81+ readonly callback: Effect.Effect<string, E, R>;
82+ readonly terminal: Terminal.Terminal;
83+ readonly launchBrowser: (
84+ url: string,
85+ ) => Effect.Effect<void, ExternalLauncher.ExternalLauncherError>;
86+}) {
87+ return yield* Effect.scoped(
88+ Effect.gen(function* () {
89+ const terminalInput = yield* input.terminal.readInput;
90+ while (true) {
91+ const result = yield* Effect.raceFirst(
92+ input.callback.pipe(
93+ Effect.map(
94+ (code): LoopbackAuthorizationResult => ({ _tag: "AuthorizationCode", code }),
95+ ),
96+ ),
97+ readLoopbackAuthorizationAction(terminalInput),
98+ );
99+ if (typeof result !== "string") {
100+ return result;
101+ }
102+ if (result === "headless") {
103+ return { _tag: "HeadlessRequested" } as const;
104+ }
105+ yield* input
106+ .launchBrowser(input.authorizationUrl)
107+ .pipe(
108+ Effect.catch(() =>
109+ Console.warn(
110+ `Could not open a browser on this device. Open the URL above manually, or press ${boldTerminalText("H")} to switch to headless mode.`,
111+ ),
112+ ),
113+ );
114+ }
115+ }),
116+ );
117+});
30118
31119 const PersistedToken = Schema.Struct({
32120 accessToken: Schema.String,
33121 refreshToken: Schema.String,
34122 expiresAtEpochMs: Schema.Number,
123+ identity: Schema.optional(Schema.String),
35124 });
36−type PersistedToken = typeof PersistedToken.Type;
125+export type PersistedToken = typeof PersistedToken.Type;
37126
38127 const PersistedTokenJson = Schema.fromJsonString(PersistedToken);
39128 const decodePersistedToken = Schema.decodeUnknownEffect(PersistedTokenJson);
@@ -42,10 +131,39 @@ const encodePersistedToken = Schema.encodeEffect(PersistedTokenJson); · 2 unmodified lines
42131 const OAuthTokenResponse = Schema.Struct({
43132 access_token: Schema.String,
44133 refresh_token: Schema.optional(Schema.String),
134+ id_token: Schema.optional(Schema.String),
45135 expires_in: Schema.Number,
46136 token_type: Schema.String,
47137 });
48138
139+const OidcIdentityClaimsJson = Schema.fromJsonString(
140+ Schema.Struct({
141+ email: Schema.optional(Schema.String),
142+ preferred_username: Schema.optional(Schema.String),
143+ sub: Schema.optional(Schema.String),
144+ }),
145+);
146+const decodeOidcIdentityClaimsJson = Schema.decodeUnknownOption(OidcIdentityClaimsJson);
147+
148+/**
149+ * Best-effort read of the `email` (or fallback) claim from an OIDC id_token.
150+ * Only used to show the operator which account they linked, so a malformed
151+ * token degrades to "no identity" rather than an error.
152+ */
153+function idTokenIdentity(idToken: string | undefined): string | null {
154+ if (!idToken) return null;
155+ const payload = idToken.split(".")[1];
156+ if (!payload) return null;
157+ const decoded = Encoding.decodeBase64UrlString(payload);
158+ if (decoded._tag !== "Success") return null;
159+ const claims = decodeOidcIdentityClaimsJson(decoded.success);
160+ if (Option.isNone(claims)) return null;
161+ for (const value of [claims.value.email, claims.value.preferred_username, claims.value.sub]) {
162+ if (typeof value === "string" && value.length > 0) return value;
163+ }
164+ return null;
165+}
166+
49167 export class CloudCliCredentialRemovalError extends Schema.TaggedErrorClass<CloudCliCredentialRemovalError>()(
50168 "CloudCliCredentialRemovalError",
51169 { cause: Schema.Defect() },
@@ -103,18 +221,18 @@ export type CloudCliTokenManagerError = typeof CloudCliTokenManagerError.Type; · 51 unmodified lines
103221 export class CloudCliTokenManager extends Context.Service<
104222 CloudCliTokenManager,
105223 {
106− readonly get: Effect.Effect<PersistedToken, CloudCliTokenManagerError>;
224+ readonly get: Effect.Effect<
225+ | { readonly _tag: "Authorized"; readonly token: PersistedToken }
226+ | { readonly _tag: "HeadlessRequested" },
227+ CloudCliTokenManagerError | Terminal.QuitError
228+ >;
107229 readonly getExisting: Effect.Effect<Option.Option<PersistedToken>, CloudCliTokenManagerError>;
108230 readonly hasCredential: Effect.Effect<boolean, CloudCliTokenManagerError>;
231+ readonly store: (token: PersistedToken) => Effect.Effect<void, CloudCliTokenManagerError>;
109232 readonly clear: Effect.Effect<void, CloudCliTokenManagerError>;
110233 }
111234 >()("t3/cloud/CliTokenManager/CloudCliTokenManager") {}
112235
113−const wrapError =
114− <WrappedError extends CloudCliTokenManagerError>(makeError: (cause: unknown) => WrappedError) =>
115− <A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, WrappedError, R> =>
116− effect.pipe(Effect.mapError(makeError));
117−
118236 function stringToBytes(value: string): Uint8Array {
119237 return new TextEncoder().encode(value);
120238 }
@@ -123,10 +241,101 @@ function bytesToString(value: Uint8Array): string { · 2 unmodified lines
123241 return new TextDecoder().decode(value);
124242 }
125243
244+const exchangeToken = Effect.fn("cloud.cli_token.exchange")(function* (
245+ metadata: Pick<CloudCliOAuthConfig, "tokenEndpoint">,
246+ params: Record<string, string>,
247+) {
248+ const httpClient = (yield* HttpClient.HttpClient).pipe(HttpClient.filterStatusOk);
249+ const response = yield* HttpClientRequest.post(metadata.tokenEndpoint).pipe(
250+ HttpClientRequest.bodyUrlParams(params),
251+ httpClient.execute,
252+ Effect.flatMap(HttpClientResponse.schemaBodyJson(OAuthTokenResponse)),
253+ );
254+ const now = yield* Clock.currentTimeMillis;
255+ const identity = idTokenIdentity(response.id_token);
256+ return {
257+ token: {
258+ accessToken: response.access_token,
259+ refreshToken: response.refresh_token ?? params.refresh_token ?? "",
260+ expiresAtEpochMs: now + response.expires_in * 1_000,
261+ ...(identity === null ? {} : { identity }),
262+ } satisfies PersistedToken,
263+ identity,
264+ };
265+});
266+
267+const makePkceRequest = Effect.gen(function* () {
268+ const crypto = yield* Crypto.Crypto;
269+ const verifier = Encoding.encodeBase64Url(yield* crypto.randomBytes(32));
270+ const challenge = Encoding.encodeBase64Url(
271+ yield* crypto.digest("SHA-256", new TextEncoder().encode(verifier)),
272+ );
273+ const state = Encoding.encodeBase64Url(yield* crypto.randomBytes(16));
274+ return { verifier, challenge, state };
275+});
276+
277+export interface OutOfBandOAuthPromptInput {
278+ readonly authorizeUrl: string;
279+ readonly validate: (value: string) => Effect.Effect<string, string>;
280+}
281+
282+/**
283+ * Out-of-band OAuth for machines without a local browser (SSH). The user
284+ * opens the hosted /connect URL elsewhere, signs in, and enters the displayed
285+ * code in this terminal. The PKCE verifier never leaves this process, so the
286+ * authorization code is useless to an observer, and the state bundled into
287+ * the blob preserves the loopback flow's CSRF check.
288+ */
289+export const outOfBandOAuthLogin = Effect.fn("cloud.cli_token.out_of_band_oauth_login")(function* <
290+ E,
291+ R,
292+>(promptForCode: (input: OutOfBandOAuthPromptInput) => Effect.Effect<string, E, R>) {
293+ const metadata = yield* cloudCliOAuthConfig;
294+ const hostedAppUrl = yield* hostedAppUrlConfig;
295+ const { verifier, challenge, state } = yield* makePkceRequest;
296+
297+ const authorizationCode = yield* promptForCode({
298+ authorizeUrl: buildConnectAuthorizeRequestUrl({ hostedAppUrl, state, challenge }),
299+ validate: (value) => {
300+ const checked = checkConnectAuthCode(value, state);
301+ return typeof checked === "string" ? Effect.fail(checked) : Effect.succeed(value);
302+ },
303+ }).pipe(
304+ // Clerk authorization codes expire on this horizon anyway; matching the
305+ // loopback flow's timeout turns an abandoned prompt into a clear error.
306+ Effect.timeout(CLOUD_CLI_OAUTH_CALLBACK_TIMEOUT),
307+ Effect.catchTag("TimeoutError", (cause) =>
308+ Effect.fail(new CloudCliAuthorizationTimeoutError({ cause })),
309+ ),
310+ );
311+ // promptForCode is caller-supplied, so re-check the returned value rather
312+ // than trusting that the prompt ran validate.
313+ const authCode = checkConnectAuthCode(authorizationCode, state);
314+ if (typeof authCode === "string") {
315+ return yield* new CloudCliAuthorizationError({ cause: authCode });
316+ }
317+
318+ return yield* exchangeToken(metadata, {
319+ grant_type: "authorization_code",
320+ code: authCode.code,
321+ redirect_uri: connectCallbackUrl(hostedAppUrl),
322+ client_id: metadata.clientId,
323+ code_verifier: verifier,
324+ });
325+});
326+
126327 export const make = Effect.gen(function* () {
328+ // Capture exactly the services the login/refresh flows need at build time
329+ // (matching the behavior before the out-of-band flow captured the instances), not
330+ // the whole ambient context.
127331 const crypto = yield* Crypto.Crypto;
128− const httpClient = (yield* HttpClient.HttpClient).pipe(HttpClient.filterStatusOk);
332+ const httpClient = yield* HttpClient.HttpClient;
333+ const services = Context.make(Crypto.Crypto, crypto).pipe(
334+ Context.add(HttpClient.HttpClient, httpClient),
335+ );
129336 const secrets = yield* ServerSecretStore.ServerSecretStore;
337+ const terminal = yield* Terminal.Terminal;
338+ const externalLauncher = yield* ExternalLauncher.ExternalLauncher;
130339 const semaphore = yield* Semaphore.make(1);
131340 const persist = Effect.fn("cloud.cli_token.persist")(function* (token: PersistedToken) {
132341 const encoded = yield* encodePersistedToken(token);
@@ -136,7 +345,7 @@ export const make = Effect.gen(function* () { · 3 unmodified lines
136345
137346 const clear = secrets
138347 .remove(CLOUD_CLI_OAUTH_TOKEN_SECRET)
139− .pipe(wrapError((cause) => new CloudCliCredentialRemovalError({ cause })));
348+ .pipe(Effect.mapError((cause) => new CloudCliCredentialRemovalError({ cause })));
140349
141350 const read = Effect.fn("cloud.cli_token.read")(function* () {
142351 const encoded = yield* secrets.get(CLOUD_CLI_OAUTH_TOKEN_SECRET);
@@ -144,39 +353,21 @@ export const make = Effect.gen(function* () { · 1 unmodified line
144353 return Option.some(yield* decodePersistedToken(bytesToString(encoded.value)));
145354 });
146355
147− const exchangeToken = Effect.fn("cloud.cli_token.exchange")(function* (
148− metadata: CloudCliOAuthConfig,
149− params: Record<string, string>,
150− ) {
151− const response = yield* HttpClientRequest.post(metadata.tokenEndpoint).pipe(
152− HttpClientRequest.bodyUrlParams(params),
153− httpClient.execute,
154− Effect.flatMap(HttpClientResponse.schemaBodyJson(OAuthTokenResponse)),
155− );
156− const now = yield* Clock.currentTimeMillis;
157− return {
158− accessToken: response.access_token,
159− refreshToken: response.refresh_token ?? params.refresh_token ?? "",
160− expiresAtEpochMs: now + response.expires_in * 1_000,
161− } satisfies PersistedToken;
162− });
163−
164356 const refresh = Effect.fn("cloud.cli_token.refresh")(function* (token: PersistedToken) {
165357 const metadata = yield* cloudCliOAuthConfig;
166− return yield* exchangeToken(metadata, {
358+ const { token: refreshed } = yield* exchangeToken(metadata, {
167359 grant_type: "refresh_token",
168360 refresh_token: token.refreshToken,
169361 client_id: metadata.clientId,
170362 });
363+ return refreshed.identity === undefined && token.identity !== undefined
364+ ? { ...refreshed, identity: token.identity }
365+ : refreshed;
171366 });
172367
173368 const login = Effect.fn("cloud.cli_token.login")(function* () {
174369 const metadata = yield* cloudCliOAuthConfig;
175− const verifier = Encoding.encodeBase64Url(yield* crypto.randomBytes(32));
176− const challenge = Encoding.encodeBase64Url(
177− yield* crypto.digest("SHA-256", new TextEncoder().encode(verifier)),
178− );
179− const state = yield* crypto.randomUUIDv4;
370+ const { verifier, challenge, state } = yield* makePkceRequest;
180371 const callback = yield* Deferred.make<string>();
181372 const callbackRoute = HttpRouter.add(
182373 "GET",
@@ -191,14 +382,7 @@ export const make = Effect.gen(function* () { · 8 unmodified lines
191382 });
192383 }
193384 yield* Deferred.succeed(callback, code);
194− return yield* HttpServerResponse.html`
195−<html>
196− <body style="font-family: sans-serif; text-align: center; margin-top: 50px;">
197− <h1>T3 Connect authorization complete</h1>
198− <p>You can close this window and return to your terminal.</p>
199− </body>
200−</html>
201−`;
385+ return HttpServerResponse.html(renderLoopbackAuthorizationCompleteHtml());
202386 }),
203387 );
204388 yield* HttpRouter.serve(callbackRoute, {
@@ -214,32 +398,37 @@ export const make = Effect.gen(function* () { · 9 unmodified lines
214398 ),
215399 Layer.build,
216400 );
217− const authorizationUrl = new URL(metadata.authorizationEndpoint);
218− authorizationUrl.searchParams.set("client_id", metadata.clientId);
219− authorizationUrl.searchParams.set("redirect_uri", metadata.redirectUri);
220− authorizationUrl.searchParams.set("response_type", "code");
221− authorizationUrl.searchParams.set("scope", metadata.scopes.join(" "));
222− authorizationUrl.searchParams.set("state", state);
223− authorizationUrl.searchParams.set("code_challenge", challenge);
224− authorizationUrl.searchParams.set("code_challenge_method", "S256");
225− yield* Console.log(`Open this URL to authorize T3 Connect:\n${authorizationUrl.toString()}\n`);
226− const code = yield* Deferred.await(callback).pipe(
227− Effect.timeout(CLOUD_CLI_OAUTH_CALLBACK_TIMEOUT),
228− Effect.catchTag("TimeoutError", (cause) =>
229− Effect.fail(
230− new CloudCliAuthorizationTimeoutError({
231− cause,
232− }),
401+ const authorizationUrl = buildConnectClerkAuthorizeUrl({
402+ authorizationEndpoint: metadata.authorizationEndpoint,
403+ clientId: metadata.clientId,
404+ redirectUri: metadata.redirectUri,
405+ scopes: metadata.scopes,
406+ state,
407+ challenge,
408+ });
409+ yield* Console.log(formatLoopbackAuthorizationPrompt(authorizationUrl));
410+ const authorization = yield* waitForLoopbackAuthorization({
411+ authorizationUrl,
412+ callback: Deferred.await(callback).pipe(
413+ Effect.timeout(CLOUD_CLI_OAUTH_CALLBACK_TIMEOUT),
414+ Effect.catchTag("TimeoutError", (cause) =>
415+ Effect.fail(new CloudCliAuthorizationTimeoutError({ cause })),
233416 ),
234417 ),
235− );
236− return yield* exchangeToken(metadata, {
418+ terminal,
419+ launchBrowser: externalLauncher.launchBrowser,
420+ });
421+ if (authorization._tag === "HeadlessRequested") {
422+ return authorization;
423+ }
424+ const { token } = yield* exchangeToken(metadata, {
237425 grant_type: "authorization_code",
238− code,
426+ code: authorization.code,
239427 redirect_uri: metadata.redirectUri,
240428 client_id: metadata.clientId,
241429 code_verifier: verifier,
242430 });
431+ return { _tag: "Authorized", token } as const;
243432 });
244433
245434 const getExistingNoLock = Effect.fn("cloud.cli_token.get_existing_no_lock")(function* () {
@@ -253,24 +442,50 @@ export const make = Effect.gen(function* () { · 7 unmodified lines
253442 });
254443
255444 const getExisting = semaphore.withPermits(1)(
256− getExistingNoLock().pipe(wrapError((cause) => new CloudCliCredentialRefreshError({ cause }))),
445+ getExistingNoLock().pipe(
446+ Effect.mapError((cause) => new CloudCliCredentialRefreshError({ cause })),
447+ Effect.provide(services),
448+ ),
257449 );
258450 const hasCredential = semaphore.withPermits(1)(
259451 read().pipe(
260452 Effect.map(Option.isSome),
261− wrapError((cause) => new CloudCliCredentialReadError({ cause })),
453+ Effect.mapError((cause) => new CloudCliCredentialReadError({ cause })),
262454 ),
263455 );
264456 const get = semaphore.withPermits(1)(
265457 Effect.gen(function* () {
266− const token = yield* getExistingNoLock();
267− return Option.isSome(token)
268− ? token.value
269− : yield* Effect.scoped(login()).pipe(Effect.flatMap(persist));
270− }).pipe(wrapError((cause) => new CloudCliAuthorizationError({ cause }))),
458+ // A stored credential that can't be read or refreshed (corrupt, revoked,
459+ // expired grant) must fall through to a fresh login rather than dead-end
460+ // the command — authorizeCli applies the same fallback to out-of-band
461+ // authorization.
462+ const token = yield* getExistingNoLock().pipe(
463+ Effect.orElseSucceed(() => Option.none<PersistedToken>()),
464+ );
465+ if (Option.isSome(token)) {
466+ return { _tag: "Authorized", token: token.value } as const;
467+ }
468+ const authorization = yield* Effect.scoped(login());
469+ return authorization._tag === "Authorized"
470+ ? ({ _tag: "Authorized", token: yield* persist(authorization.token) } as const)
471+ : authorization;
472+ }).pipe(
473+ Effect.mapError((cause) =>
474+ Terminal.isQuitError(cause) ? cause : new CloudCliAuthorizationError({ cause }),
475+ ),
476+ Effect.provide(services),
477+ ),
271478 );
479+ const store = Effect.fn("cloud.cli_token.store")(function* (token: PersistedToken) {
480+ yield* semaphore.withPermits(1)(
481+ persist(token).pipe(
482+ Effect.asVoid,
483+ Effect.mapError((cause) => new CloudCliAuthorizationError({ cause })),
484+ ),
485+ );
486+ });
272487
273− return CloudCliTokenManager.of({ get, getExisting, hasCredential, clear });
488+ return CloudCliTokenManager.of({ get, getExisting, hasCredential, store, clear });
274489 });
275490
276491 export const layer = Layer.effect(CloudCliTokenManager, make);
apps/server/src/cloud/bootService.test.ts+529−0
@@ -0,0 +1,529 @@
1+import * as NodeServices from "@effect/platform-node/NodeServices";
2+import { assert, it } from "@effect/vitest";
3+import * as ConfigProvider from "effect/ConfigProvider";
4+import * as Effect from "effect/Effect";
5+import * as FileSystem from "effect/FileSystem";
6+import * as Layer from "effect/Layer";
7+import * as Path from "effect/Path";
8+import * as Schema from "effect/Schema";
9+import * as ChildProcessSpawner from "effect/unstable/process/ChildProcessSpawner";
10+
11+import {
12+ HostProcessArguments,
13+ HostProcessExecutablePath,
14+ HostProcessPlatform,
15+} from "@t3tools/shared/hostProcess";
16+
17+import * as ProcessRunner from "../processRunner.ts";
18+import * as BootService from "./bootService.ts";
19+
20+const isUnsupportedError = Schema.is(BootService.BootServiceUnsupportedError);
21+const isCommandError = Schema.is(BootService.BootServiceCommandError);
22+
23+interface RecordedCommand {
24+ readonly command: string;
25+ readonly args: ReadonlyArray<string>;
26+}
27+
28+const makeRecordingRunnerLayer = (
29+ commands: Array<RecordedCommand>,
30+ options?: {
31+ readonly failCommand?: string;
32+ readonly failWhen?: (command: string, args: ReadonlyArray<string>) => boolean;
33+ },
34+) =>
35+ Layer.succeed(
36+ ProcessRunner.ProcessRunner,
37+ ProcessRunner.ProcessRunner.of({
38+ run: (input) =>
39+ Effect.sync(() => {
40+ assert.isUndefined(input.env);
41+ commands.push({ command: input.command, args: input.args });
42+ const failed =
43+ input.command === options?.failCommand ||
44+ options?.failWhen?.(input.command, input.args) === true;
45+ return {
46+ stdout: "",
47+ stderr: failed ? `${input.command} exploded` : "",
48+ code: ChildProcessSpawner.ExitCode(failed ? 1 : 0),
49+ timedOut: false,
50+ stdoutTruncated: false,
51+ stderrTruncated: false,
52+ };
53+ }),
54+ }),
55+ );
56+
57+const makeHost = (entry: string): BootService.BootServiceHost => ({
58+ execPath: "/usr/local/bin/node",
59+ cliEntryPath: entry,
60+});
61+
62+const provideHostRefs = (home: string, platform: NodeJS.Platform = "linux") =>
63+ Effect.provide(
64+ Layer.mergeAll(
65+ Layer.succeed(HostProcessPlatform, platform),
66+ ConfigProvider.layer(ConfigProvider.fromEnv({ env: { HOME: home } })),
67+ ),
68+ );
69+
70+const makeTestContext = Effect.fn("test.makeTestContext")(function* () {
71+ const fs = yield* FileSystem.FileSystem;
72+ const path = yield* Path.Path;
73+ const root = yield* fs.makeTempDirectoryScoped({ prefix: "t3-boot-service-test-" });
74+ // A real file for the stable-entry cases so status can confirm the entry
75+ // point exists.
76+ const stableEntry = path.join(root, "bin.mjs");
77+ yield* fs.writeFileString(stableEntry, "#!/usr/bin/env node\n");
78+ return {
79+ fs,
80+ path,
81+ dirs: {
82+ home: root,
83+ baseDir: path.join(root, ".t3"),
84+ logsDir: path.join(root, ".t3", "userdata", "logs"),
85+ stableEntry,
86+ },
87+ };
88+});
89+
90+it("renders a systemd unit with absolute paths and append-mode logging", () => {
91+ const unit = BootService.renderBootServiceUnit({
92+ nodePath: "/usr/local/bin/node",
93+ t3EntryPath: "/home/theo/.t3/runtime/versions/0.0.27/node_modules/t3/dist/bin.mjs",
94+ baseDir: "/home/theo/.t3",
95+ logPath: "/home/theo/.t3/userdata/logs/boot-service.log",
96+ unitPath: "/home/theo/.config/systemd/user/t3code.service",
97+ });
98+
99+ assert.equal(
100+ unit,
101+ [
102+ "[Unit]",
103+ "Description=T3 Code server (T3 Connect)",
104+ "StartLimitIntervalSec=300",
105+ "StartLimitBurst=5",
106+ "",
107+ "[Service]",
108+ "Type=simple",
109+ "WorkingDirectory=%h",
110+ "Environment=T3CODE_HOME=/home/theo/.t3",
111+ "ExecStart=/usr/local/bin/node /home/theo/.t3/runtime/versions/0.0.27/node_modules/t3/dist/bin.mjs serve",
112+ "Restart=always",
113+ "RestartSec=5",
114+ "StandardOutput=append:/home/theo/.t3/userdata/logs/boot-service.log",
115+ "StandardError=append:/home/theo/.t3/userdata/logs/boot-service.log",
116+ "",
117+ "[Install]",
118+ "WantedBy=default.target",
119+ "",
120+ ].join("\n"),
121+ );
122+});
123+
124+it("quotes systemd values containing spaces and escapes percent specifiers", () => {
125+ assert.equal(BootService.quoteSystemdValue("/plain/path"), "/plain/path");
126+ assert.equal(BootService.quoteSystemdValue("/home/me/T3 Data"), '"/home/me/T3 Data"');
127+ assert.equal(BootService.quoteSystemdValue("/opt/100%cpu"), "/opt/100%%cpu");
128+
129+ const unit = BootService.renderBootServiceUnit({
130+ nodePath: "/home/me/my tools/node",
131+ t3EntryPath: "/home/me/T3 Data/bin.mjs",
132+ baseDir: "/home/me/T3 Data",
133+ logPath: "/home/me/100%logs/boot.log",
134+ unitPath: "/home/me/.config/systemd/user/t3code.service",
135+ });
136+ assert.include(unit, 'ExecStart="/home/me/my tools/node" "/home/me/T3 Data/bin.mjs" serve');
137+ assert.include(unit, 'Environment=T3CODE_HOME="/home/me/T3 Data"');
138+ // append: paths take the rest of the line literally (spaces are fine,
139+ // quoting is not), but % still goes through specifier expansion.
140+ assert.include(unit, "StandardOutput=append:/home/me/100%%logs/boot.log");
141+ assert.include(unit, "StandardError=append:/home/me/100%%logs/boot.log");
142+});
143+
144+it("flags package-manager cache entry points as ephemeral", () => {
145+ assert.isTrue(
146+ BootService.isEphemeralCacheEntry("/home/theo/.npm/_npx/abc123/node_modules/t3/dist/bin.mjs"),
147+ );
148+ assert.isTrue(
149+ BootService.isEphemeralCacheEntry("C:\\Users\\theo\\AppData\\npm-cache\\_npx\\abc\\bin.mjs"),
150+ );
151+ assert.isTrue(
152+ BootService.isEphemeralCacheEntry(
153+ "/home/theo/.cache/pnpm/dlx/abc/node_modules/t3/dist/bin.mjs",
154+ ),
155+ );
156+ assert.isTrue(
157+ BootService.isEphemeralCacheEntry("/home/theo/.bun/install/cache/t3@0.0.27/dist/bin.mjs"),
158+ );
159+ assert.isFalse(BootService.isEphemeralCacheEntry("/usr/local/lib/node_modules/t3/dist/bin.mjs"));
160+ assert.isFalse(
161+ BootService.isEphemeralCacheEntry(
162+ "/home/theo/dev/pnpm/dlx-tools/t3/node_modules/t3/dist/bin.mjs",
163+ ),
164+ );
165+ assert.isFalse(
166+ BootService.isEphemeralCacheEntry(
167+ "/home/theo/.t3/runtime/versions/0.0.27/node_modules/t3/dist/bin.mjs",
168+ ),
169+ );
170+});
171+
172+it.layer(NodeServices.layer)("BootService", (it) => {
173+ it.effect("installs the unit, enables the service, and enables linger", () =>
174+ Effect.gen(function* () {
175+ const { dirs, fs, path } = yield* makeTestContext();
176+ const commands: Array<RecordedCommand> = [];
177+ const service = yield* BootService.make({
178+ baseDir: dirs.baseDir,
179+ logsDir: dirs.logsDir,
180+ cliVersion: "0.0.27",
181+ host: makeHost(dirs.stableEntry),
182+ }).pipe(Effect.provide(makeRecordingRunnerLayer(commands)), provideHostRefs(dirs.home));
183+
184+ const plan = yield* service.install;
185+
186+ // A stable entry point is reused directly — no npm install.
187+ assert.equal(plan.t3EntryPath, dirs.stableEntry);
188+ assert.deepEqual(
189+ commands.map((entry) => [entry.command, ...entry.args].join(" ")),
190+ [
191+ "systemctl --user daemon-reload",
192+ "systemctl --user enable t3code.service",
193+ // restart (not enable --now) so repairing a stale unit replaces a
194+ // running process instead of leaving the old one until reboot.
195+ "systemctl --user restart t3code.service",
196+ "loginctl enable-linger",
197+ ],
198+ );
199+
200+ const unitPath = path.join(dirs.home, ".config", "systemd", "user", "t3code.service");
201+ const unit = yield* fs.readFileString(unitPath);
202+ assert.include(unit, `ExecStart=/usr/local/bin/node ${dirs.stableEntry} serve`);
203+ assert.include(unit, `Environment=T3CODE_HOME=${dirs.baseDir}`);
204+
205+ const status = yield* service.status;
206+ assert.isTrue(status.supported);
207+ assert.isTrue(status.installed);
208+ assert.isTrue(status.current);
209+
210+ const removed = yield* service.uninstall;
211+ assert.isTrue(removed);
212+ assert.isFalse(yield* fs.exists(unitPath));
213+ const statusAfter = yield* service.status;
214+ assert.isFalse(statusAfter.installed);
215+ const removedAgain = yield* service.uninstall;
216+ assert.isFalse(removedAgain);
217+ }),
218+ );
219+
220+ it.effect("pins a runtime via npm install when running from the npx cache", () =>
221+ Effect.gen(function* () {
222+ const { dirs, fs, path } = yield* makeTestContext();
223+ const commands: Array<RecordedCommand> = [];
224+ const service = yield* BootService.make({
225+ baseDir: dirs.baseDir,
226+ logsDir: dirs.logsDir,
227+ cliVersion: "0.0.27",
228+ host: makeHost("/home/theo/.npm/_npx/abc/node_modules/t3/dist/bin.mjs"),
229+ }).pipe(Effect.provide(makeRecordingRunnerLayer(commands)), provideHostRefs(dirs.home));
230+
231+ const plan = yield* service.install;
232+
233+ const runtimeDir = path.join(dirs.baseDir, "runtime", "versions", "0.0.27");
234+ assert.equal(
235+ plan.t3EntryPath,
236+ path.join(runtimeDir, "node_modules", "t3", "dist", "bin.mjs"),
237+ );
238+ assert.deepEqual(commands[0], {
239+ command: "npm",
240+ args: ["install", "--prefix", runtimeDir, "--no-fund", "--no-audit", "t3@0.0.27"],
241+ });
242+ // Success is recorded via a sentinel so interrupted installs re-run.
243+ assert.isTrue(yield* fs.exists(path.join(runtimeDir, ".install-complete")));
244+ }),
245+ );
246+
247+ it.effect("reinstalls a pinned runtime when its entry point is missing", () =>
248+ Effect.gen(function* () {
249+ const { dirs, fs, path } = yield* makeTestContext();
250+ const commands: Array<RecordedCommand> = [];
251+ const service = yield* BootService.make({
252+ baseDir: dirs.baseDir,
253+ logsDir: dirs.logsDir,
254+ cliVersion: "0.0.27",
255+ host: makeHost("/home/theo/.npm/_npx/abc/node_modules/t3/dist/bin.mjs"),
256+ }).pipe(Effect.provide(makeRecordingRunnerLayer(commands)), provideHostRefs(dirs.home));
257+
258+ const plan = yield* service.install;
259+ yield* fs.makeDirectory(path.dirname(plan.t3EntryPath), { recursive: true });
260+ yield* fs.writeFileString(plan.t3EntryPath, "#!/usr/bin/env node\n");
261+ yield* fs.remove(plan.t3EntryPath);
262+ commands.length = 0;
263+
264+ yield* service.install;
265+
266+ assert.isTrue(commands.some(({ command }) => command === "npm"));
267+ }),
268+ );
269+
270+ it.effect("reads executable metadata from host process references", () =>
271+ Effect.gen(function* () {
272+ const { dirs } = yield* makeTestContext();
273+ const commands: Array<RecordedCommand> = [];
274+ const service = yield* BootService.make({
275+ baseDir: dirs.baseDir,
276+ logsDir: dirs.logsDir,
277+ cliVersion: "0.0.27",
278+ }).pipe(
279+ Effect.provide(makeRecordingRunnerLayer(commands)),
280+ provideHostRefs(dirs.home),
281+ Effect.provideService(HostProcessExecutablePath, "/opt/node/bin/node"),
282+ Effect.provideService(HostProcessArguments, ["/opt/node/bin/node", dirs.stableEntry]),
283+ );
284+
285+ const plan = yield* service.install;
286+ assert.equal(plan.nodePath, "/opt/node/bin/node");
287+ assert.equal(plan.t3EntryPath, dirs.stableEntry);
288+ }),
289+ );
290+
291+ it.effect("cleans up and fails when the pinned runtime install fails", () =>
292+ Effect.gen(function* () {
293+ const { dirs, fs, path } = yield* makeTestContext();
294+ const commands: Array<RecordedCommand> = [];
295+ const service = yield* BootService.make({
296+ baseDir: dirs.baseDir,
297+ logsDir: dirs.logsDir,
298+ cliVersion: "0.0.27",
299+ host: makeHost("/home/theo/.npm/_npx/abc/node_modules/t3/dist/bin.mjs"),
300+ }).pipe(
301+ Effect.provide(makeRecordingRunnerLayer(commands, { failCommand: "npm" })),
302+ provideHostRefs(dirs.home),
303+ );
304+
305+ const error = yield* service.install.pipe(Effect.flip);
306+ assert.isTrue(isCommandError(error));
307+ const runtimeDir = path.join(dirs.baseDir, "runtime", "versions", "0.0.27");
308+ // The half-installed tree must not be reused by the next attempt.
309+ assert.isFalse(yield* fs.exists(runtimeDir));
310+ assert.isFalse(yield* fs.exists(path.join(runtimeDir, ".install-complete")));
311+ }),
312+ );
313+
314+ it.effect("reports an installed-but-stale unit so connect can offer a repair", () =>
315+ Effect.gen(function* () {
316+ const { dirs, fs, path } = yield* makeTestContext();
317+ const commands: Array<RecordedCommand> = [];
318+ const service = yield* BootService.make({
319+ baseDir: dirs.baseDir,
320+ logsDir: dirs.logsDir,
321+ cliVersion: "0.0.27",
322+ host: makeHost(dirs.stableEntry),
323+ }).pipe(Effect.provide(makeRecordingRunnerLayer(commands)), provideHostRefs(dirs.home));
324+
325+ const unitDir = path.join(dirs.home, ".config", "systemd", "user");
326+ yield* fs.makeDirectory(unitDir, { recursive: true });
327+ yield* fs.writeFileString(
328+ path.join(unitDir, "t3code.service"),
329+ "[Service]\nExecStart=/old/node /old/t3 serve\n",
330+ );
331+
332+ const status = yield* service.status;
333+ assert.isTrue(status.supported);
334+ assert.isTrue(status.installed);
335+ assert.isFalse(status.current);
336+ }),
337+ );
338+
339+ it.effect("reports a current unit as stale when its entry point is gone", () =>
340+ Effect.gen(function* () {
341+ const { dirs, fs } = yield* makeTestContext();
342+ const commands: Array<RecordedCommand> = [];
343+ const service = yield* BootService.make({
344+ baseDir: dirs.baseDir,
345+ logsDir: dirs.logsDir,
346+ cliVersion: "0.0.27",
347+ host: makeHost(dirs.stableEntry),
348+ }).pipe(Effect.provide(makeRecordingRunnerLayer(commands)), provideHostRefs(dirs.home));
349+
350+ yield* service.install;
351+ assert.isTrue((yield* service.status).current);
352+
353+ // The pinned runtime (or global bin) was deleted to reclaim space; the
354+ // unit still matches byte-for-byte but would crashloop at boot.
355+ yield* fs.remove(dirs.stableEntry);
356+ const status = yield* service.status;
357+ assert.isTrue(status.installed);
358+ assert.isFalse(status.current);
359+ }),
360+ );
361+
362+ it.effect("fails on non-Linux platforms without touching the filesystem", () =>
363+ Effect.gen(function* () {
364+ const { dirs, fs, path } = yield* makeTestContext();
365+ const commands: Array<RecordedCommand> = [];
366+ const service = yield* BootService.make({
367+ baseDir: dirs.baseDir,
368+ logsDir: dirs.logsDir,
369+ cliVersion: "0.0.27",
370+ host: makeHost("/usr/local/lib/node_modules/t3/dist/bin.mjs"),
371+ }).pipe(
372+ Effect.provide(makeRecordingRunnerLayer(commands)),
373+ provideHostRefs(dirs.home, "darwin"),
374+ );
375+
376+ const error = yield* service.install.pipe(Effect.flip);
377+ assert.isTrue(isUnsupportedError(error));
378+ assert.lengthOf(commands, 0);
379+ assert.isFalse(
380+ yield* fs.exists(path.join(dirs.home, ".config", "systemd", "user", "t3code.service")),
381+ );
382+
383+ const status = yield* service.status;
384+ assert.isFalse(status.supported);
385+ assert.isFalse(status.installed);
386+ }),
387+ );
388+
389+ it.effect("removes the unit file when an activation step fails", () =>
390+ Effect.gen(function* () {
391+ const { dirs, fs, path } = yield* makeTestContext();
392+ const commands: Array<RecordedCommand> = [];
393+ const service = yield* BootService.make({
394+ baseDir: dirs.baseDir,
395+ logsDir: dirs.logsDir,
396+ cliVersion: "0.0.27",
397+ host: makeHost("/usr/local/lib/node_modules/t3/dist/bin.mjs"),
398+ }).pipe(
399+ Effect.provide(makeRecordingRunnerLayer(commands, { failCommand: "loginctl" })),
400+ provideHostRefs(dirs.home),
401+ );
402+
403+ const error = yield* service.install.pipe(Effect.flip);
404+ assert.isTrue(isCommandError(error));
405+ // A leftover unit would make the next connect report "already set up"
406+ // even though linger never happened.
407+ assert.isFalse(
408+ yield* fs.exists(path.join(dirs.home, ".config", "systemd", "user", "t3code.service")),
409+ );
410+ const status = yield* service.status;
411+ assert.isFalse(status.installed);
412+ assert.isTrue(
413+ commands.some(
414+ ({ command, args }) =>
415+ command === "systemctl" && args.join(" ") === "--user disable --now t3code.service",
416+ ),
417+ );
418+ }),
419+ );
420+
421+ it.effect("restores the previous unit when a repair cannot activate", () =>
422+ Effect.gen(function* () {
423+ const { dirs, fs, path } = yield* makeTestContext();
424+ const initialCommands: Array<RecordedCommand> = [];
425+ const initialService = yield* BootService.make({
426+ baseDir: dirs.baseDir,
427+ logsDir: dirs.logsDir,
428+ cliVersion: "0.0.27",
429+ host: makeHost(dirs.stableEntry),
430+ }).pipe(
431+ Effect.provide(makeRecordingRunnerLayer(initialCommands)),
432+ provideHostRefs(dirs.home),
433+ );
434+ yield* initialService.install;
435+
436+ const unitPath = path.join(dirs.home, ".config", "systemd", "user", "t3code.service");
437+ const previousUnit = yield* fs.readFileString(unitPath);
438+ const replacementEntry = path.join(dirs.home, "replacement-bin.mjs");
439+ yield* fs.writeFileString(replacementEntry, "#!/usr/bin/env node\n");
440+ const repairCommands: Array<RecordedCommand> = [];
441+ const repairService = yield* BootService.make({
442+ baseDir: dirs.baseDir,
443+ logsDir: dirs.logsDir,
444+ cliVersion: "0.0.28",
445+ host: makeHost(replacementEntry),
446+ }).pipe(
447+ Effect.provide(makeRecordingRunnerLayer(repairCommands, { failCommand: "loginctl" })),
448+ provideHostRefs(dirs.home),
449+ );
450+
451+ const error = yield* repairService.install.pipe(Effect.flip);
452+
453+ assert.isTrue(isCommandError(error));
454+ assert.equal(yield* fs.readFileString(unitPath), previousUnit);
455+ assert.isTrue(
456+ repairCommands.some(
457+ ({ command, args }) =>
458+ command === "systemctl" && args.join(" ") === "--user restart t3code.service",
459+ ),
460+ );
461+ }),
462+ );
463+
464+ it.effect("keeps the unit when stopping it during uninstall fails", () =>
465+ Effect.gen(function* () {
466+ const { dirs, fs, path } = yield* makeTestContext();
467+ const installCommands: Array<RecordedCommand> = [];
468+ const installedService = yield* BootService.make({
469+ baseDir: dirs.baseDir,
470+ logsDir: dirs.logsDir,
471+ cliVersion: "0.0.27",
472+ host: makeHost(dirs.stableEntry),
473+ }).pipe(
474+ Effect.provide(makeRecordingRunnerLayer(installCommands)),
475+ provideHostRefs(dirs.home),
476+ );
477+ yield* installedService.install;
478+
479+ const uninstallCommands: Array<RecordedCommand> = [];
480+ const failingService = yield* BootService.make({
481+ baseDir: dirs.baseDir,
482+ logsDir: dirs.logsDir,
483+ cliVersion: "0.0.27",
484+ host: makeHost(dirs.stableEntry),
485+ }).pipe(
486+ Effect.provide(
487+ makeRecordingRunnerLayer(uninstallCommands, {
488+ failWhen: (command, args) =>
489+ command === "systemctl" && args.includes("disable") && args.includes("--now"),
490+ }),
491+ ),
492+ provideHostRefs(dirs.home),
493+ );
494+
495+ const error = yield* failingService.uninstall.pipe(Effect.flip);
496+
497+ assert.isTrue(isCommandError(error));
498+ assert.isTrue(
499+ yield* fs.exists(path.join(dirs.home, ".config", "systemd", "user", "t3code.service")),
500+ );
501+ }),
502+ );
503+
504+ it.effect("appends failed steps to the boot-service log", () =>
505+ Effect.gen(function* () {
506+ const { dirs, fs, path } = yield* makeTestContext();
507+ const commands: Array<RecordedCommand> = [];
508+ const service = yield* BootService.make({
509+ baseDir: dirs.baseDir,
510+ logsDir: dirs.logsDir,
511+ cliVersion: "0.0.27",
512+ host: makeHost("/usr/local/lib/node_modules/t3/dist/bin.mjs"),
513+ }).pipe(
514+ Effect.provide(makeRecordingRunnerLayer(commands, { failCommand: "systemctl" })),
515+ provideHostRefs(dirs.home),
516+ );
517+
518+ const error = yield* service.install.pipe(Effect.flip);
519+ assert.isTrue(isCommandError(error));
520+ if (!isCommandError(error)) return;
521+ assert.equal(error.exitCode, 1);
522+ assert.equal(error.stderrLength, "systemctl exploded".length);
523+
524+ const logPath = path.join(dirs.logsDir, "boot-service.log");
525+ assert.isTrue(yield* fs.exists(logPath));
526+ assert.include(yield* fs.readFileString(logPath), "exit code 1");
527+ }),
528+ );
529+});
apps/server/src/cloud/bootService.ts+452−0
@@ -0,0 +1,452 @@
1+import * as Context from "effect/Context";
2+import * as Config from "effect/Config";
3+import * as DateTime from "effect/DateTime";
4+import * as Duration from "effect/Duration";
5+import * as Effect from "effect/Effect";
6+import * as FileSystem from "effect/FileSystem";
7+import * as Layer from "effect/Layer";
8+import * as Option from "effect/Option";
9+import * as Path from "effect/Path";
10+import * as Schema from "effect/Schema";
11+
12+import {
13+ HostProcessArguments,
14+ HostProcessExecutablePath,
15+ HostProcessPlatform,
16+} from "@t3tools/shared/hostProcess";
17+
18+import * as ProcessRunner from "../processRunner.ts";
19+
20+/**
21+ * Installs T3 Code as a per-user boot service so a connected machine stays
22+ * reachable through T3 Connect after the SSH session ends. Linux-only for
23+ * now: systemd user unit + loginctl enable-linger. The service runs a pinned
24+ * runtime installed under <baseDir>/runtime — never `npx t3`, whose cache is
25+ * ephemeral and whose registry fetch at boot would make startup depend on
26+ * the network.
27+ */
28+
29+const BOOT_SERVICE_NAME = "t3code";
30+const BOOT_RUNTIME_DIR = "runtime";
31+
32+const BOOT_SERVICE_UNIT_FILE = `${BOOT_SERVICE_NAME}.service`;
33+const PINNED_RUNTIME_INSTALL_TIMEOUT = Duration.minutes(10);
34+
35+const EPHEMERAL_CACHE_SEGMENTS = [
36+ "/_npx/", // npx
37+ "\\_npx\\",
38+ "/pnpm/dlx/", // pnpm dlx (~/.cache/pnpm/dlx and $PNPM_HOME/.pnpm/dlx)
39+ "/.pnpm/dlx/",
40+ "/.bun/install/cache/", // bunx
41+];
42+
43+/**
44+ * `npx t3` (and pnpm dlx / bunx) run out of ephemeral package-manager
45+ * caches that can be evicted at any time — a boot service must never point
46+ * there. Global installs, repo checkouts, and the pinned runtime below are
47+ * all stable.
48+ */
49+export function isEphemeralCacheEntry(entryPath: string): boolean {
50+ return EPHEMERAL_CACHE_SEGMENTS.some((segment) => entryPath.includes(segment));
51+}
52+
53+/**
54+ * systemd expands `%` specifiers in most directive values, including the
55+ * `append:` file paths, which take the rest of the line literally and must
56+ * NOT be quoted.
57+ */
58+export function escapeSystemdSpecifiers(value: string): string {
59+ return value.replaceAll("%", "%%");
60+}
61+
62+/**
63+ * systemd word-splits ExecStart and Environment values and expands `%`
64+ * specifiers, so paths with spaces or percents must be quoted and escaped.
65+ */
66+export function quoteSystemdValue(value: string): string {
67+ const escaped = escapeSystemdSpecifiers(value);
68+ return /[\s"'\\]/.test(escaped)
69+ ? `"${escaped.replaceAll("\\", "\\\\").replaceAll('"', '\\"')}"`
70+ : escaped;
71+}
72+
73+export interface BootServicePlan {
74+ /** Absolute path of the node binary running this CLI. */
75+ readonly nodePath: string;
76+ /** Absolute path of the pinned t3 entry point the unit will run. */
77+ readonly t3EntryPath: string;
78+ readonly baseDir: string;
79+ readonly logPath: string;
80+ readonly unitPath: string;
81+}
82+
83+/**
84+ * Pure so it is testable byte-for-byte. systemd user units run with a
85+ * minimal environment: every path must be absolute, and the service must
86+ * not rely on PATH, nvm shims, or shell profiles. Failures land in
87+ * `logPath` because `systemctl --user` failures are otherwise invisible.
88+ */
89+export function renderBootServiceUnit(plan: BootServicePlan): string {
90+ // No After=network-online.target: it does not exist in the systemd *user*
91+ // manager, so ordering on it is silently ignored. The server retries its
92+ // relay connection, and Restart=always covers early-boot failures.
93+ return [
94+ "[Unit]",
95+ "Description=T3 Code server (T3 Connect)",
96+ // Give up after 5 crashes in 5 minutes so a persistently broken install
97+ // (deleted runtime, broken workspace) stops instead of restarting every
98+ // 5s forever and growing the unrotated append log without bound.
99+ "StartLimitIntervalSec=300",
100+ "StartLimitBurst=5",
101+ "",
102+ "[Service]",
103+ "Type=simple",
104+ "WorkingDirectory=%h",
105+ `Environment=T3CODE_HOME=${quoteSystemdValue(plan.baseDir)}`,
106+ `ExecStart=${quoteSystemdValue(plan.nodePath)} ${quoteSystemdValue(plan.t3EntryPath)} serve`,
107+ "Restart=always",
108+ "RestartSec=5",
109+ `StandardOutput=append:${escapeSystemdSpecifiers(plan.logPath)}`,
110+ `StandardError=append:${escapeSystemdSpecifiers(plan.logPath)}`,
111+ "",
112+ "[Install]",
113+ "WantedBy=default.target",
114+ "",
115+ ].join("\n");
116+}
117+
118+export class BootServiceUnsupportedError extends Schema.TaggedErrorClass<BootServiceUnsupportedError>()(
119+ "BootServiceUnsupportedError",
120+ { platform: Schema.String },
121+) {
122+ override get message(): string {
123+ return `Background setup currently supports Linux with systemd; this machine reports '${this.platform}'.`;
124+ }
125+}
126+
127+export class BootServiceCommandError extends Schema.TaggedErrorClass<BootServiceCommandError>()(
128+ "BootServiceCommandError",
129+ {
130+ step: Schema.String,
131+ exitCode: Schema.optional(Schema.Number),
132+ stdoutLength: Schema.optional(Schema.Number),
133+ stderrLength: Schema.optional(Schema.Number),
134+ cause: Schema.optional(Schema.Defect()),
135+ },
136+) {
137+ override get message(): string {
138+ return this.exitCode === undefined
139+ ? `Background setup failed while ${this.step}.`
140+ : `Background setup failed while ${this.step} (exit code ${this.exitCode}).`;
141+ }
142+}
143+
144+export class BootServiceInstallError extends Schema.TaggedErrorClass<BootServiceInstallError>()(
145+ "BootServiceInstallError",
146+ { cause: Schema.Defect() },
147+) {
148+ override get message(): string {
149+ return "Could not set up the T3 Code background service.";
150+ }
151+}
152+
153+export type BootServiceError =
154+ | BootServiceUnsupportedError
155+ | BootServiceCommandError
156+ | BootServiceInstallError;
157+
158+export interface BootServiceStatus {
159+ readonly supported: boolean;
160+ readonly installed: boolean;
161+ /** False when the installed unit no longer matches what install would write. */
162+ readonly current: boolean;
163+ readonly unitPath: string;
164+ readonly logPath: string;
165+}
166+
167+export class BootService extends Context.Service<
168+ BootService,
169+ {
170+ /** Installs the pinned runtime + unit, enables linger, starts the service. */
171+ readonly install: Effect.Effect<BootServicePlan, BootServiceError>;
172+ /**
173+ * Stops and removes the unit; leaves the pinned runtime for reuse.
174+ * Returns whether a unit was actually removed.
175+ */
176+ readonly uninstall: Effect.Effect<boolean, BootServiceError>;
177+ readonly status: Effect.Effect<BootServiceStatus, BootServiceError>;
178+ }
179+>()("t3/cloud/bootService") {}
180+
181+export interface BootServiceHost {
182+ readonly execPath: string;
183+ readonly cliEntryPath: string;
184+}
185+
186+export const make = Effect.fn("cloud.boot_service.make")(function* (input: {
187+ readonly baseDir: string;
188+ readonly logsDir: string;
189+ readonly cliVersion: string;
190+ readonly host?: BootServiceHost;
191+}) {
192+ const hostExecPath = yield* HostProcessExecutablePath;
193+ const hostArguments = yield* HostProcessArguments;
194+ const host = input.host ?? {
195+ execPath: hostExecPath,
196+ // When running the packed CLI this is dist/bin.mjs; when stable (global
197+ // install, repo checkout) the boot service runs this same artifact.
198+ cliEntryPath: hostArguments[1] ?? "",
199+ };
200+ const platform = yield* HostProcessPlatform;
201+ const homeDir = yield* Config.string("HOME").pipe(Config.withDefault(""));
202+ const fs = yield* FileSystem.FileSystem;
203+ const path = yield* Path.Path;
204+ const runner = yield* ProcessRunner.ProcessRunner;
205+
206+ const unitDir = path.join(homeDir, ".config", "systemd", "user");
207+ const unitPath = path.join(unitDir, BOOT_SERVICE_UNIT_FILE);
208+ const logPath = path.join(input.logsDir, "boot-service.log");
209+ const runtimeVersionDir = path.join(
210+ input.baseDir,
211+ BOOT_RUNTIME_DIR,
212+ "versions",
213+ input.cliVersion,
214+ );
215+ const runtimeEntryPath = path.join(runtimeVersionDir, "node_modules", "t3", "dist", "bin.mjs");
216+ const runtimeSentinelPath = path.join(runtimeVersionDir, ".install-complete");
217+
218+ const requireSystemdLinux = Effect.gen(function* () {
219+ if (platform !== "linux" || homeDir === "") {
220+ return yield* new BootServiceUnsupportedError({ platform });
221+ }
222+ });
223+
224+ const runStep = Effect.fn("cloud.boot_service.run_step")(function* (
225+ step: string,
226+ command: string,
227+ args: ReadonlyArray<string>,
228+ options?: { readonly timeout?: Duration.Input },
229+ ) {
230+ return yield* runner.run({ command, args, timeout: options?.timeout }).pipe(
231+ Effect.mapError((cause) => new BootServiceCommandError({ step, cause })),
232+ Effect.filterOrFail(
233+ (result) => result.code === 0,
234+ (result) =>
235+ new BootServiceCommandError({
236+ step,
237+ exitCode: Number(result.code),
238+ stdoutLength: result.stdout.length,
239+ stderrLength: result.stderr.length,
240+ }),
241+ ),
242+ Effect.tapError((error) =>
243+ DateTime.now.pipe(
244+ Effect.flatMap((now) =>
245+ fs.writeFileString(logPath, `${DateTime.formatIso(now)} ${error.message}\n`, {
246+ flag: "a",
247+ }),
248+ ),
249+ Effect.ignore,
250+ ),
251+ ),
252+ );
253+ });
254+
255+ /**
256+ * Ensures plannedEntryPath exists before the unit points at it. A stable
257+ * install (global bin, repo checkout) is used as-is; an ephemeral cache
258+ * entry is replaced by `npm install --prefix`-ing the exact running
259+ * version into <baseDir>/runtime/versions/<v>. A real install (not a copy
260+ * of bin.mjs) because t3 ships native deps like node-pty.
261+ */
262+ const ensurePinnedRuntime = Effect.gen(function* () {
263+ if (!isEphemeralCacheEntry(host.cliEntryPath)) {
264+ return;
265+ }
266+ // The sentinel is written only after npm exits 0. Checking the entry
267+ // file alone is not enough: npm extracts files before running native
268+ // builds (node-pty), so a killed install leaves a plausible-looking but
269+ // broken tree behind.
270+ const alreadyPinned = yield* Effect.all([
271+ fs.exists(runtimeSentinelPath),
272+ fs.exists(runtimeEntryPath),
273+ ]).pipe(
274+ Effect.map(([sentinelExists, entryExists]) => sentinelExists && entryExists),
275+ Effect.mapError((cause) => new BootServiceInstallError({ cause })),
276+ );
277+ if (alreadyPinned) {
278+ return;
279+ }
280+ yield* fs.remove(runtimeVersionDir, { recursive: true, force: true }).pipe(
281+ Effect.andThen(fs.makeDirectory(runtimeVersionDir, { recursive: true })),
282+ Effect.mapError((cause) => new BootServiceInstallError({ cause })),
283+ );
284+ yield* runStep(
285+ "installing the pinned t3 runtime (this can take a few minutes)",
286+ "npm",
287+ [
288+ "install",
289+ "--prefix",
290+ runtimeVersionDir,
291+ "--no-fund",
292+ "--no-audit",
293+ `t3@${input.cliVersion}`,
294+ ],
295+ // Native deps (node-pty) can compile from source on slow boxes; the
296+ // ProcessRunner default of 60s would kill a healthy install.
297+ { timeout: PINNED_RUNTIME_INSTALL_TIMEOUT },
298+ ).pipe(
299+ Effect.tapError(() =>
300+ fs.remove(runtimeVersionDir, { recursive: true, force: true }).pipe(Effect.ignore),
301+ ),
302+ );
303+ yield* fs
304+ .writeFileString(runtimeSentinelPath, `${input.cliVersion}\n`)
305+ .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause })));
306+ });
307+
308+ // Where the unit will point: derivable without touching the network, so
309+ // status can compare units purely; install materializes it first.
310+ const plannedEntryPath = isEphemeralCacheEntry(host.cliEntryPath)
311+ ? runtimeEntryPath
312+ : host.cliEntryPath;
313+ const plan: BootServicePlan = {
314+ nodePath: host.execPath,
315+ t3EntryPath: plannedEntryPath,
316+ baseDir: input.baseDir,
317+ logPath,
318+ unitPath,
319+ };
320+
321+ const install: BootService["Service"]["install"] = Effect.gen(function* () {
322+ yield* requireSystemdLinux;
323+ yield* fs
324+ .makeDirectory(input.logsDir, { recursive: true })
325+ .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause })));
326+
327+ yield* ensurePinnedRuntime;
328+
329+ const previousUnit = yield* fs.exists(unitPath).pipe(
330+ Effect.flatMap((exists) =>
331+ exists
332+ ? fs.readFileString(unitPath).pipe(Effect.map(Option.some))
333+ : Effect.succeed(Option.none<string>()),
334+ ),
335+ Effect.mapError((cause) => new BootServiceInstallError({ cause })),
336+ );
337+
338+ yield* fs.makeDirectory(unitDir, { recursive: true }).pipe(
339+ Effect.andThen(fs.writeFileString(unitPath, renderBootServiceUnit(plan))),
340+ Effect.mapError((cause) => new BootServiceInstallError({ cause })),
341+ );
342+
343+ // If any activation step fails, remove the unit again: a leftover file
344+ // would make the next `t3 connect` report the service as already set up
345+ // even though it was never enabled or lingered.
346+ yield* Effect.gen(function* () {
347+ yield* runStep("reloading systemd user units", "systemctl", ["--user", "daemon-reload"]);
348+ yield* runStep("enabling the service", "systemctl", [
349+ "--user",
350+ "enable",
351+ BOOT_SERVICE_UNIT_FILE,
352+ ]);
353+ // restart rather than enable --now: --now does not replace an already
354+ // running process, so repairing a stale unit would leave the old
355+ // server running until reboot. restart also starts a stopped service.
356+ yield* runStep("starting the service", "systemctl", [
357+ "--user",
358+ "restart",
359+ BOOT_SERVICE_UNIT_FILE,
360+ ]);
361+ // Linger keeps the user manager (and this service) running without an
362+ // open session — the whole point on a box reached over SSH. No
363+ // username argument: loginctl defaults to the calling user, which is
364+ // always right, while $USER can be stale (su without -l) or unset.
365+ yield* runStep("enabling lingering for this user", "loginctl", ["enable-linger"]);
366+ }).pipe(Effect.tapError(() => rollbackFailedInstall(previousUnit)));
367+
368+ return plan;
369+ }).pipe(Effect.withSpan("cloud.boot_service.install"));
370+
371+ // If activation fails partway (e.g. enable succeeds but restart/linger
372+ // fails), leave nothing behind: disable removes the enable symlink, remove
373+ // deletes the file, daemon-reload clears the stale definition — otherwise a
374+ // dangling wants/ symlink logs "Failed to load unit" at every boot and the
375+ // next connect misreports the state.
376+ const rollbackFailedInstall = Effect.fn("cloud.boot_service.rollback_failed_install")(function* (
377+ previousUnit: Option.Option<string>,
378+ ) {
379+ if (Option.isSome(previousUnit)) {
380+ yield* fs.writeFileString(unitPath, previousUnit.value).pipe(Effect.ignore);
381+ } else {
382+ yield* runStep("cleaning up the service", "systemctl", [
383+ "--user",
384+ "disable",
385+ "--now",
386+ BOOT_SERVICE_UNIT_FILE,
387+ ]).pipe(Effect.ignore);
388+ yield* fs.remove(unitPath).pipe(Effect.ignore);
389+ }
390+ yield* runStep("reloading systemd user units", "systemctl", ["--user", "daemon-reload"]).pipe(
391+ Effect.ignore,
392+ );
393+ if (Option.isSome(previousUnit)) {
394+ yield* runStep("restoring the previous service", "systemctl", [
395+ "--user",
396+ "restart",
397+ BOOT_SERVICE_UNIT_FILE,
398+ ]).pipe(Effect.ignore);
399+ }
400+ });
401+
402+ const uninstall: BootService["Service"]["uninstall"] = Effect.gen(function* () {
403+ yield* requireSystemdLinux;
404+ const exists = yield* fs
405+ .exists(unitPath)
406+ .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause })));
407+ if (!exists) {
408+ return false;
409+ }
410+ yield* runStep("stopping the service", "systemctl", [
411+ "--user",
412+ "disable",
413+ "--now",
414+ BOOT_SERVICE_UNIT_FILE,
415+ ]);
416+ yield* fs
417+ .remove(unitPath)
418+ .pipe(Effect.mapError((cause) => new BootServiceInstallError({ cause })));
419+ yield* runStep("reloading systemd user units", "systemctl", ["--user", "daemon-reload"]);
420+ return true;
421+ }).pipe(Effect.withSpan("cloud.boot_service.uninstall"));
422+
423+ const status: BootService["Service"]["status"] = Effect.gen(function* () {
424+ if (platform !== "linux" || homeDir === "") {
425+ return { supported: false, installed: false, current: false, unitPath, logPath };
426+ }
427+ const unitExists = yield* fs.exists(unitPath);
428+ if (!unitExists) {
429+ return { supported: true, installed: false, current: false, unitPath, logPath };
430+ }
431+ const unit = yield* fs.readFileString(unitPath);
432+ // A unit is current only if it matches what install would write now (an
433+ // older CLI wrote a different runtime/node path) AND the entry point it
434+ // references still exists (a pinned runtime under ~/.t3 can be deleted to
435+ // reclaim space). Either mismatch makes connect offer a repair.
436+ const entryExists = yield* fs.exists(plannedEntryPath);
437+ const current = unit === renderBootServiceUnit(plan) && entryExists;
438+ return { supported: true, installed: true, current, unitPath, logPath };
439+ }).pipe(
440+ Effect.mapError((cause) => new BootServiceInstallError({ cause })),
441+ Effect.withSpan("cloud.boot_service.status"),
442+ );
443+
444+ return BootService.of({ install, uninstall, status });
445+});
446+
447+export const layer = (input: {
448+ readonly baseDir: string;
449+ readonly logsDir: string;
450+ readonly cliVersion: string;
451+ readonly host?: BootServiceHost;
452+}) => Layer.effect(BootService, make(input));
apps/server/src/cloud/cliAuthHtml.test.ts+13−0
@@ -0,0 +1,13 @@
1+import { expect, it } from "@effect/vitest";
2+
3+import { renderLoopbackAuthorizationCompleteHtml } from "./cliAuthHtml.ts";
4+
5+it("renders the branded loopback authorization completion page", () => {
6+ const html = renderLoopbackAuthorizationCompleteHtml();
7+
8+ expect(html).toContain("T3 Code");
9+ expect(html).not.toContain("Secure terminal handoff");
10+ expect(html).toContain("You're connected");
11+ expect(html).toContain("return to the terminal");
12+ expect(html).toContain('name="viewport"');
13+});
apps/server/src/cloud/cliAuthHtml.ts+140−0
@@ -0,0 +1,140 @@
1+export function renderLoopbackAuthorizationCompleteHtml(): string {
2+ return `<!doctype html>
3+<html lang="en">
4+ <head>
5+ <meta charset="utf-8" />
6+ <meta name="viewport" content="width=device-width, initial-scale=1" />
7+ <meta name="color-scheme" content="light dark" />
8+ <title>T3 Connect authorization complete</title>
9+ <style>
10+ :root {
11+ color-scheme: light dark;
12+ font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
13+ background: #f6f7f9;
14+ color: #17191f;
15+ }
16+ * { box-sizing: border-box; }
17+ body {
18+ min-height: 100vh;
19+ margin: 0;
20+ display: grid;
21+ place-items: center;
22+ padding: 32px 16px;
23+ background:
24+ radial-gradient(48rem 22rem at 50% -8rem, rgba(47, 119, 235, 0.15), transparent),
25+ #f6f7f9;
26+ }
27+ main {
28+ width: min(100%, 576px);
29+ overflow: hidden;
30+ border: 1px solid rgba(23, 25, 31, 0.1);
31+ border-radius: 20px;
32+ background: rgba(255, 255, 255, 0.94);
33+ box-shadow: 0 24px 64px rgba(16, 24, 40, 0.16);
34+ }
35+ .stage {
36+ position: relative;
37+ height: 96px;
38+ overflow: hidden;
39+ padding: 22px 24px;
40+ color: white;
41+ background: linear-gradient(145deg, #5ab8fa 0%, #347ff8 46%, #1939bd 100%);
42+ }
43+ .stage::before {
44+ content: "";
45+ position: absolute;
46+ inset: 0;
47+ opacity: 0.42;
48+ background-image:
49+ linear-gradient(rgba(234, 246, 255, 0.25) 1px, transparent 1px),
50+ linear-gradient(90deg, rgba(234, 246, 255, 0.25) 1px, transparent 1px),
51+ linear-gradient(rgba(234, 246, 255, 0.12) 1px, transparent 1px),
52+ linear-gradient(90deg, rgba(234, 246, 255, 0.12) 1px, transparent 1px);
53+ background-size: 32px 32px, 32px 32px, 8px 8px, 8px 8px;
54+ }
55+ .stage::after {
56+ content: "";
57+ position: absolute;
58+ inset: 0;
59+ background:
60+ radial-gradient(circle at 78% 20%, rgba(210, 255, 255, 0.36), transparent 34%),
61+ linear-gradient(to bottom, transparent 24%, rgba(8, 28, 89, 0.38));
62+ }
63+ .stage-content {
64+ position: relative;
65+ z-index: 1;
66+ }
67+ .brand {
68+ margin: 0;
69+ font-size: 10px;
70+ font-weight: 700;
71+ letter-spacing: 0.2em;
72+ text-transform: uppercase;
73+ }
74+ .brand { color: rgba(255, 255, 255, 0.82); }
75+ .content { padding: 30px 32px 34px; }
76+ .status {
77+ width: 42px;
78+ height: 42px;
79+ display: grid;
80+ place-items: center;
81+ margin-top: -52px;
82+ margin-bottom: 22px;
83+ border: 4px solid white;
84+ border-radius: 50%;
85+ background: #2065df;
86+ color: white;
87+ font-size: 22px;
88+ font-weight: 700;
89+ box-shadow: 0 8px 22px rgba(25, 72, 177, 0.3);
90+ }
91+ .eyebrow {
92+ margin: 0 0 8px;
93+ color: #2866cc;
94+ font-size: 10px;
95+ font-weight: 700;
96+ letter-spacing: 0.18em;
97+ text-transform: uppercase;
98+ }
99+ h1 { margin: 0; font-size: clamp(26px, 5vw, 34px); line-height: 1.12; letter-spacing: -0.035em; }
100+ .description { margin: 12px 0 0; color: #646975; font-size: 15px; line-height: 1.6; }
101+ .next {
102+ margin-top: 24px;
103+ padding: 14px 16px;
104+ border: 1px solid rgba(23, 25, 31, 0.1);
105+ border-radius: 12px;
106+ background: #f7f8fa;
107+ color: #454a55;
108+ font-size: 13px;
109+ }
110+ .next strong { color: #17191f; }
111+ @media (prefers-color-scheme: dark) {
112+ :root { background: #101115; color: #f1f3f7; }
113+ body { background: radial-gradient(48rem 22rem at 50% -8rem, rgba(55, 102, 210, 0.2), transparent), #101115; }
114+ main { border-color: rgba(255, 255, 255, 0.1); background: rgba(25, 27, 33, 0.96); }
115+ .status { border-color: #191b21; }
116+ .eyebrow { color: #77a8ff; }
117+ .description { color: #a8adb8; }
118+ .next { border-color: rgba(255, 255, 255, 0.1); background: #20232a; color: #b9bec8; }
119+ .next strong { color: #f1f3f7; }
120+ }
121+ </style>
122+ </head>
123+ <body>
124+ <main>
125+ <header class="stage">
126+ <div class="stage-content">
127+ <p class="brand">T3 Code</p>
128+ </div>
129+ </header>
130+ <section class="content">
131+ <div class="status" aria-hidden="true">✓</div>
132+ <p class="eyebrow">Browser authorization complete</p>
133+ <h1>You're connected</h1>
134+ <p class="description">The authorization code was delivered securely to your waiting terminal.</p>
135+ <div class="next"><strong>Next:</strong> return to the terminal to finish T3 Connect setup. You can close this window.</div>
136+ </section>
137+ </main>
138+ </body>
139+</html>`;
140+}
apps/server/src/cloud/http.test.ts+1−0
@@ -200,6 +200,7 @@ describe("reconcileDesiredCloudLink", () => { · 199 unmodified lines
200200 get: unusedSecretStoreOperation(),
201201 getExisting: Effect.succeed(Option.none()),
202202 hasCredential: unusedSecretStoreOperation(),
203+ store: () => unusedSecretStoreOperation(),
203204 clear: unusedSecretStoreOperation(),
204205 }),
205206 ),
apps/server/src/cloud/publicConfig.test.ts+35−0
@@ -4,6 +4,7 @@ import * as Effect from "effect/Effect"; · 3 unmodified lines
44 import * as Result from "effect/Result";
55
66 import {
7+ hostedAppUrlConfig,
78 makeCloudCliOAuthConfig,
89 makeRelayUrlConfig,
910 resolveRelayClientTracingConfig,
@@ -47,6 +48,40 @@ it.effect("rejects an injected relay URL with a non-origin path", () => · 37 unmodified lines
4748 makeRelayUrlConfig("https://embedded.example.test/path").pipe(provideEnv({}), Effect.flip),
4849 );
4950
51+it.effect("normalizes the hosted app URL to an absolute origin", () =>
52+ Effect.gen(function* () {
53+ assert.equal(
54+ yield* hostedAppUrlConfig.pipe(
55+ provideEnv({ T3CODE_HOSTED_APP_URL: "https://nightly.app.t3.codes" }),
56+ ),
57+ "https://nightly.app.t3.codes",
58+ );
59+ assert.equal(
60+ yield* hostedAppUrlConfig.pipe(
61+ provideEnv({ T3CODE_HOSTED_APP_URL: "http://localhost:5733" }),
62+ ),
63+ "http://localhost:5733",
64+ );
65+ }),
66+);
67+
68+it.effect("rejects malformed or insecure hosted app URLs", () =>
69+ Effect.gen(function* () {
70+ for (const value of [
71+ "app.t3.codes",
72+ "http://app.t3.codes",
73+ "https://app.t3.codes/nested",
74+ "https://app.t3.codes?alias=true",
75+ ]) {
76+ const result = yield* hostedAppUrlConfig.pipe(
77+ provideEnv({ T3CODE_HOSTED_APP_URL: value }),
78+ Effect.result,
79+ );
80+ assert.isTrue(Result.isFailure(result), value);
81+ }
82+ }),
83+);
84+
5085 it.effect("derives direct Clerk OAuth endpoints from statically injected public config", () =>
5186 Effect.gen(function* () {
5287 const config = yield* makeCloudCliOAuthConfig({
apps/server/src/cloud/publicConfig.ts+40−1
@@ -1,3 +1,4 @@
1+import { CONNECT_OAUTH_SCOPES, DEFAULT_HOSTED_APP_URL } from "@t3tools/shared/connectAuth";
12 import { clerkFrontendApiUrlFromPublishableKey } from "@t3tools/shared/relayAuth";
23 import { normalizeSecureRelayUrl } from "@t3tools/shared/relayUrl";
34 import * as Config from "effect/Config";
@@ -15,7 +16,7 @@ declare const __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_DATASET__: string | undefi · 11 unmodified lines
1516 declare const __T3CODE_BUILD_RELAY_CLIENT_OTLP_TRACES_TOKEN__: string | undefined;
1617
1718 const CLOUD_CLI_OAUTH_REDIRECT_URI = "http://127.0.0.1:34338/callback";
18−const CLOUD_CLI_OAUTH_SCOPES = ["openid", "profile", "email"] as const;
19+const CLOUD_CLI_OAUTH_SCOPES = CONNECT_OAUTH_SCOPES;
1920
2021 function validateRelayUrl(value: string) {
2122 const relayUrl = normalizeSecureRelayUrl(value);
@@ -100,6 +101,44 @@ export function makeRelayUrlConfig(fallback = buildTimeRelayUrl) { · 78 unmodified lines
100101
101102 export const relayUrlConfig = makeRelayUrlConfig();
102103
104+/**
105+ * Hosted app origin used for out-of-band OAuth on headless
106+ * machines. Overridable so staging/nightly builds can point their CLIs at a
107+ * matching hosted deployment.
108+ */
109+export const hostedAppUrlConfig = makePublicValueConfig(
110+ "T3CODE_HOSTED_APP_URL",
111+ DEFAULT_HOSTED_APP_URL,
112+).pipe(Config.mapOrFail(validateHostedAppUrl));
113+
114+function validateHostedAppUrl(value: string) {
115+ try {
116+ const url = new URL(value);
117+ const isLoopbackHttp =
118+ url.protocol === "http:" &&
119+ (url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]");
120+ if (
121+ (url.protocol !== "https:" && !isLoopbackHttp) ||
122+ url.pathname !== "/" ||
123+ url.search !== "" ||
124+ url.hash !== ""
125+ ) {
126+ throw new Error("invalid hosted app origin");
127+ }
128+ return Effect.succeed(url.origin);
129+ } catch {
130+ return Effect.fail(
131+ new Config.ConfigError(
132+ new Schema.SchemaError(
133+ new SchemaIssue.InvalidValue(Option.some(value), {
134+ message: "Hosted app URL must be an absolute HTTPS origin (or HTTP loopback origin).",
135+ }),
136+ ),
137+ ),
138+ );
139+ }
140+}
141+
103142 function makePublicValueConfig(name: string, fallback: string) {
104143 const runtimeConfig = Config.nonEmptyString(name);
105144 return (fallback ? runtimeConfig.pipe(Config.withDefault(fallback)) : runtimeConfig).pipe(
apps/server/src/persistence/Migrations.ts+4−8
@@ -123,15 +123,11 @@ export interface RunMigrationsOptions { · 122 unmodified lines
123123 export const runMigrations = Effect.fn("runMigrations")(function* ({
124124 toMigrationInclusive,
125125 }: RunMigrationsOptions = {}) {
126− yield* Effect.log(
127− toMigrationInclusive === undefined
128− ? "Running all migrations..."
129− : `Running migrations 1 through ${toMigrationInclusive}...`,
130− );
131126 const executedMigrations = yield* run({ loader: makeMigrationLoader(toMigrationInclusive) });
132− yield* Effect.log("Migrations ran successfully").pipe(
133− Effect.annotateLogs({ migrations: executedMigrations.map(([id, name]) => `${id}_${name}`) }),
134− );
127+ const migrations = executedMigrations.map(([id, name]) => `${id}_${name}`);
128+ yield* migrations.length === 0
129+ ? Effect.logDebug("Database schema is current")
130+ : Effect.log("Migrations ran successfully").pipe(Effect.annotateLogs({ migrations }));
135131 return executedMigrations;
136132 });
137133
apps/server/src/server.ts+4−1
@@ -322,7 +322,10 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( · 321 unmodified lines
322322 Layer.provideMerge(ServerSecretStore.layer),
323323 Layer.provideMerge(
324324 Layer.mergeAll(
325− CloudCliTokenManager.layer.pipe(Layer.provide(ServerSecretStore.layer)),
325+ CloudCliTokenManager.layer.pipe(
326+ Layer.provide(ServerSecretStore.layer),
327+ Layer.provide(ExternalLauncher.layer),
328+ ),
326329 CloudManagedEndpointRuntimeLive,
327330 ),
328331 ),