From 38a0dcc37d6df802e27414a2e306a5849ee10e3d Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Fri, 24 Jul 2026 12:46:47 -0700 Subject: [PATCH 01/10] feat(desktop): add client-only backend mode Let the desktop app run as a pure client for remote or independently managed backends, instead of always owning a local backend process. - Add a persisted `managed` / `client-only` backend mode with a launch-only `--backend-mode` override, exposed over IPC. - In client-only mode, bootstrap skips the backend pool entirely and serves the renderer from packaged static assets (or the dev proxy), opening the window on renderer readiness rather than backend readiness, and skipping backend shutdown on quit. - `ElectronProtocol` takes an explicit `source: "proxy" | "static"`. Static serving adds path-safe file resolution, SPA fallback, MIME types, CSP and HEAD support. - Web routing treats client-only like hosted static, and local environment bootstraps are empty, so a client-only desktop is never stranded on a dead primary-backend state. Co-Authored-By: Claude --- apps/desktop/src/app/DesktopApp.ts | 85 ++++++++- .../src/app/DesktopBackendMode.test.ts | 70 ++++++++ apps/desktop/src/app/DesktopBackendMode.ts | 128 ++++++++++++++ .../src/app/DesktopEnvironment.test.ts | 4 + apps/desktop/src/app/DesktopEnvironment.ts | 5 + .../src/backend/DesktopBackendManager.ts | 8 +- .../src/backend/DesktopBackendPool.test.ts | 1 + .../src/backend/DesktopServerExposure.test.ts | 1 + .../src/electron/ElectronProtocol.test.ts | 125 ++++++++++++- apps/desktop/src/electron/ElectronProtocol.ts | 165 +++++++++++++++++- apps/desktop/src/ipc/DesktopIpcHandlers.ts | 3 + apps/desktop/src/ipc/channels.ts | 2 + apps/desktop/src/ipc/methods/backendMode.ts | 60 +++++++ apps/desktop/src/ipc/methods/window.test.ts | 43 ++++- apps/desktop/src/ipc/methods/window.ts | 5 + apps/desktop/src/main.ts | 2 + apps/desktop/src/preload.ts | 12 ++ .../src/settings/DesktopAppSettings.test.ts | 14 ++ .../src/settings/DesktopAppSettings.ts | 29 +++ .../src/updates/DesktopUpdates.test.ts | 1 + .../src/window/DesktopApplicationMenu.test.ts | 1 + apps/desktop/src/window/DesktopWindow.test.ts | 21 +++ apps/desktop/src/window/DesktopWindow.ts | 12 +- .../settings/ConnectionsSettings.tsx | 94 +++++++++- apps/web/src/connection/platform.ts | 7 +- .../environments/primary/bootstrap.test.ts | 18 ++ apps/web/src/environments/primary/index.ts | 2 + apps/web/src/environments/primary/target.ts | 9 + apps/web/src/routes/__root.tsx | 7 +- packages/contracts/src/ipc.ts | 16 ++ 30 files changed, 918 insertions(+), 32 deletions(-) create mode 100644 apps/desktop/src/app/DesktopBackendMode.test.ts create mode 100644 apps/desktop/src/app/DesktopBackendMode.ts create mode 100644 apps/desktop/src/ipc/methods/backendMode.ts diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index fd86c5f05d2..1f98c7ec699 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -1,6 +1,8 @@ import * as Cause from "effect/Cause"; import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; +import * as Path from "effect/Path"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; @@ -11,6 +13,7 @@ import * as ElectronDialog from "../electron/ElectronDialog.ts"; import * as ElectronProtocol from "../electron/ElectronProtocol.ts"; import { installDesktopIpcHandlers } from "../ipc/DesktopIpcHandlers.ts"; import * as DesktopAppIdentity from "./DesktopAppIdentity.ts"; +import * as DesktopBackendMode from "./DesktopBackendMode.ts"; import * as DesktopClerk from "./DesktopClerk.ts"; import * as DesktopApplicationMenu from "../window/DesktopApplicationMenu.ts"; import * as DesktopWindow from "../window/DesktopWindow.ts"; @@ -57,6 +60,17 @@ export class DesktopDevelopmentBackendPortRequiredError extends Schema.TaggedErr } } +export class DesktopRendererAssetsUnavailableError extends Schema.TaggedErrorClass()( + "DesktopRendererAssetsUnavailableError", + { + candidates: Schema.Array(Schema.String), + }, +) { + override get message(): string { + return `The packaged desktop renderer was not found. Checked: ${this.candidates.join(", ")}.`; + } +} + const { logInfo: logBootstrapInfo, logWarning: logBootstrapWarning } = DesktopObservability.makeComponentLogger("desktop-bootstrap"); @@ -136,21 +150,68 @@ const handleFatalStartupError = Effect.fn("desktop.startup.handleFatalStartupErr const fatalStartupCause = (stage: string, cause: Cause.Cause) => handleFatalStartupError(stage, Cause.pretty(cause)).pipe(Effect.andThen(Effect.failCause(cause))); +const resolvePackagedClientRoot = Effect.fn("desktop.bootstrap.resolvePackagedClientRoot")( + function* (candidates: readonly string[]) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + for (const candidate of candidates) { + if ( + yield* fileSystem + .exists(path.join(candidate, "index.html")) + .pipe(Effect.orElseSucceed(() => false)) + ) { + return candidate; + } + } + return yield* new DesktopRendererAssetsUnavailableError({ candidates: [...candidates] }); + }, +); + const bootstrap = Effect.gen(function* () { - const pool = yield* DesktopBackendPool.DesktopBackendPool; - const primaryBackend = yield* pool.primary; + const launchMode = yield* DesktopBackendMode.DesktopBackendMode; const state = yield* DesktopState.DesktopState; const environment = yield* DesktopEnvironment.DesktopEnvironment; const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; - const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; - const wslBackend = yield* DesktopWslBackend.DesktopWslBackend; const desktopWindow = yield* DesktopWindow.DesktopWindow; + const electronProtocol = yield* ElectronProtocol.ElectronProtocol; + const backendMode = (yield* launchMode.get).effectiveMode; yield* logBootstrapInfo("bootstrap start"); + if (backendMode === "client-only") { + if (environment.isDevelopment) { + yield* electronProtocol.registerDesktopProtocol({ + scheme: ElectronProtocol.getDesktopScheme(true), + source: "proxy", + targetOrigin: Option.getOrThrow(environment.devServerUrl), + clerkFrontendApiHostname: DesktopClerk.desktopClerkFrontendApiHostname, + }); + } else { + const staticRoot = yield* resolvePackagedClientRoot(environment.packagedClientRootCandidates); + yield* electronProtocol.registerDesktopProtocol({ + scheme: ElectronProtocol.getDesktopScheme(false), + source: "static", + staticRoot, + clerkFrontendApiHostname: DesktopClerk.desktopClerkFrontendApiHostname, + }); + yield* logBootstrapInfo("bootstrap resolved packaged renderer", { staticRoot }); + } + + yield* installDesktopIpcHandlers(); + yield* logBootstrapInfo("bootstrap ipc handlers registered"); + if (!(yield* Ref.get(state.quitting))) { + yield* desktopWindow.handleRendererReady; + } + return; + } + if (environment.isDevelopment && Option.isNone(environment.configuredBackendPort)) { return yield* new DesktopDevelopmentBackendPortRequiredError(); } + const pool = yield* DesktopBackendPool.DesktopBackendPool; + const primaryBackend = yield* pool.primary; + const serverExposure = yield* DesktopServerExposure.DesktopServerExposure; + const wslBackend = yield* DesktopWslBackend.DesktopWslBackend; const backendPortSelection = yield* resolveDesktopBackendPort(environment.configuredBackendPort); const backendPort = backendPortSelection.port; yield* logBootstrapInfo( @@ -171,14 +232,13 @@ const bootstrap = Effect.gen(function* () { } const serverExposureState = yield* serverExposure.configureFromSettings({ port: backendPort }); const backendConfig = yield* serverExposure.backendConfig; - const electronProtocol = yield* ElectronProtocol.ElectronProtocol; const rendererTarget = environment.isDevelopment ? Option.getOrThrow(environment.devServerUrl) : backendConfig.httpBaseUrl; yield* electronProtocol.registerDesktopProtocol({ scheme: ElectronProtocol.getDesktopScheme(environment.isDevelopment), + source: "proxy", targetOrigin: rendererTarget, - backendOrigin: backendConfig.httpBaseUrl, clerkFrontendApiHostname: DesktopClerk.desktopClerkFrontendApiHostname, }); yield* logBootstrapInfo("bootstrap resolved backend endpoint", { @@ -223,6 +283,7 @@ const startup = Effect.gen(function* () { const clerk = yield* DesktopClerk.DesktopClerk; const shellEnvironment = yield* DesktopShellEnvironment.DesktopShellEnvironment; const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; + const backendMode = yield* DesktopBackendMode.DesktopBackendMode; const updates = yield* DesktopUpdates.DesktopUpdates; const environment = yield* DesktopEnvironment.DesktopEnvironment; @@ -230,7 +291,13 @@ const startup = Effect.gen(function* () { const userDataPath = yield* appIdentity.resolveUserDataPath; yield* electronApp.setPath("userData", userDataPath); yield* logStartupInfo("runtime logging configured", { logDir: environment.logDir }); - yield* desktopSettings.load; + const settings = yield* desktopSettings.load; + const launchMode = yield* backendMode.latch(settings.backendMode); + yield* logStartupInfo("desktop backend mode selected", { + effectiveMode: launchMode.effectiveMode, + configuredMode: launchMode.configuredMode, + ...(launchMode.cliOverride === null ? {} : { cliOverride: launchMode.cliOverride }), + }); if (environment.platform === "linux") { yield* electronApp.appendCommandLineSwitch("class", environment.linuxWmClass); @@ -261,6 +328,10 @@ const scopedProgram = Effect.scoped( yield* Effect.addFinalizer(() => Effect.gen(function* () { + const backendMode = yield* DesktopBackendMode.DesktopBackendMode; + if ((yield* backendMode.get).effectiveMode === "client-only") { + return; + } const pool = yield* DesktopBackendPool.DesktopBackendPool; // Stop every backend in the pool, not just the primary. The // electronApp.quit() path can race ahead of the layer-scope diff --git a/apps/desktop/src/app/DesktopBackendMode.test.ts b/apps/desktop/src/app/DesktopBackendMode.test.ts new file mode 100644 index 00000000000..de9394da9f9 --- /dev/null +++ b/apps/desktop/src/app/DesktopBackendMode.test.ts @@ -0,0 +1,70 @@ +import { assert, describe, it } from "@effect/vitest"; + +import * as DesktopBackendMode from "./DesktopBackendMode.ts"; + +describe("DesktopBackendMode", () => { + const captureThrown = (run: () => unknown): unknown => { + try { + run(); + } catch (error) { + return error; + } + throw new Error("Expected the operation to throw."); + }; + + it("uses the persisted mode when no CLI override is present", () => { + assert.deepEqual(DesktopBackendMode.resolveDesktopBackendModeState([], "client-only"), { + effectiveMode: "client-only", + configuredMode: "client-only", + cliOverride: null, + }); + }); + + it("gives the CLI override precedence without changing the configured mode", () => { + assert.deepEqual( + DesktopBackendMode.resolveDesktopBackendModeState( + ["electron", "main.cjs", "--backend-mode=client-only"], + "managed", + ), + { + effectiveMode: "client-only", + configuredMode: "managed", + cliOverride: "client-only", + }, + ); + }); + + it("accepts a separate flag value", () => { + assert.equal( + DesktopBackendMode.parseDesktopBackendModeOverride(["electron", "--backend-mode", "managed"]), + "managed", + ); + }); + + it.each([ + ["--backend-mode=other", "invalid-value"], + ["--backend-mode=", "missing-value"], + ["--backend-mode", "missing-value"], + ])("rejects invalid launch argument %s", (argument, reason) => { + const error = captureThrown(() => + DesktopBackendMode.parseDesktopBackendModeOverride(["electron", argument]), + ); + assert.isTrue(DesktopBackendMode.isDesktopBackendModeArgumentError(error)); + if (DesktopBackendMode.isDesktopBackendModeArgumentError(error)) { + assert.equal(error.reason, reason); + } + }); + + it("rejects repeated overrides", () => { + const error = captureThrown(() => + DesktopBackendMode.parseDesktopBackendModeOverride([ + "--backend-mode=managed", + "--backend-mode=client-only", + ]), + ); + assert.isTrue(DesktopBackendMode.isDesktopBackendModeArgumentError(error)); + if (DesktopBackendMode.isDesktopBackendModeArgumentError(error)) { + assert.equal(error.reason, "repeated"); + } + }); +}); diff --git a/apps/desktop/src/app/DesktopBackendMode.ts b/apps/desktop/src/app/DesktopBackendMode.ts new file mode 100644 index 00000000000..81819b41005 --- /dev/null +++ b/apps/desktop/src/app/DesktopBackendMode.ts @@ -0,0 +1,128 @@ +import { + type DesktopBackendMode as DesktopBackendModeValue, + type DesktopBackendModeState, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; + +const BACKEND_MODE_FLAG = "--backend-mode"; + +export class DesktopBackendModeArgumentError extends Schema.TaggedErrorClass()( + "DesktopBackendModeArgumentError", + { + value: Schema.NullOr(Schema.String), + reason: Schema.Literals(["missing-value", "invalid-value", "repeated"]), + }, +) { + override get message(): string { + if (this.reason === "missing-value") { + return `${BACKEND_MODE_FLAG} requires either "managed" or "client-only".`; + } + if (this.reason === "repeated") { + return `${BACKEND_MODE_FLAG} may only be specified once.`; + } + return `Invalid ${BACKEND_MODE_FLAG} value ${JSON.stringify(this.value)}. Expected "managed" or "client-only".`; + } +} + +export const isDesktopBackendModeArgumentError = Schema.is(DesktopBackendModeArgumentError); + +function parseBackendMode(value: string | undefined): DesktopBackendModeValue { + if (value === undefined || value.length === 0) { + throw new DesktopBackendModeArgumentError({ + value: value ?? null, + reason: "missing-value", + }); + } + if (value === "managed" || value === "client-only") { + return value; + } + throw new DesktopBackendModeArgumentError({ + value, + reason: "invalid-value", + }); +} + +export function parseDesktopBackendModeOverride( + argv: readonly string[], +): DesktopBackendModeValue | null { + let override: DesktopBackendModeValue | null = null; + + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === undefined) continue; + + let value: string | undefined; + if (argument === BACKEND_MODE_FLAG) { + value = argv[index + 1]; + index += 1; + } else if (argument.startsWith(`${BACKEND_MODE_FLAG}=`)) { + value = argument.slice(BACKEND_MODE_FLAG.length + 1); + } else { + continue; + } + + if (override !== null) { + throw new DesktopBackendModeArgumentError({ + value: value ?? null, + reason: "repeated", + }); + } + override = parseBackendMode(value); + } + + return override; +} + +export function resolveDesktopBackendModeState( + argv: readonly string[], + configuredMode: DesktopBackendModeValue, +): DesktopBackendModeState { + const cliOverride = parseDesktopBackendModeOverride(argv); + return { + effectiveMode: cliOverride ?? configuredMode, + configuredMode, + cliOverride, + }; +} + +export class DesktopBackendMode extends Context.Service< + DesktopBackendMode, + { + readonly latch: ( + configuredMode: DesktopBackendModeValue, + ) => Effect.Effect; + readonly get: Effect.Effect; + } +>()("@t3tools/desktop/app/DesktopBackendMode") {} + +export const make = Effect.fn("desktop.backendMode.make")(function* (argv: readonly string[]) { + const stateRef = yield* Ref.make({ + effectiveMode: "managed", + configuredMode: "managed", + cliOverride: null, + }); + + return DesktopBackendMode.of({ + latch: (configuredMode) => + Effect.try({ + try: () => resolveDesktopBackendModeState(argv, configuredMode), + catch: (cause) => + isDesktopBackendModeArgumentError(cause) + ? cause + : new DesktopBackendModeArgumentError({ + value: null, + reason: "invalid-value", + }), + }).pipe(Effect.tap((state) => Ref.set(stateRef, state))), + get: Ref.get(stateRef), + }); +}); + +export const layer = Layer.effect(DesktopBackendMode, make(process.argv)); + +export const layerTest = (argv: readonly string[] = []) => + Layer.effect(DesktopBackendMode, make(argv)); diff --git a/apps/desktop/src/app/DesktopEnvironment.test.ts b/apps/desktop/src/app/DesktopEnvironment.test.ts index 15d23f8e152..b1eeacfff81 100644 --- a/apps/desktop/src/app/DesktopEnvironment.test.ts +++ b/apps/desktop/src/app/DesktopEnvironment.test.ts @@ -67,6 +67,10 @@ describe("DesktopEnvironment", () => { assert.equal(environment.appRoot, "/repo"); assert.equal(environment.backendEntryPath, "/repo/apps/server/dist/bin.mjs"); assert.equal(environment.backendCwd, "/repo"); + assert.deepEqual(environment.packagedClientRootCandidates, [ + "/Applications/T3 Code.app/Contents/Resources/app.asar/apps/server/dist/client", + "/Applications/T3 Code.app/Contents/Resources/app.asar.unpacked/apps/server/dist/client", + ]); assert.equal(environment.appUserModelId, "com.t3tools.t3code.dev"); assert.equal(environment.linuxWmClass, "t3code-dev"); assert.deepEqual( diff --git a/apps/desktop/src/app/DesktopEnvironment.ts b/apps/desktop/src/app/DesktopEnvironment.ts index c991f5b39d6..cb7afe59986 100644 --- a/apps/desktop/src/app/DesktopEnvironment.ts +++ b/apps/desktop/src/app/DesktopEnvironment.ts @@ -53,6 +53,7 @@ export class DesktopEnvironment extends Context.Service< readonly appRoot: string; readonly backendEntryPath: string; readonly backendCwd: string; + readonly packagedClientRootCandidates: readonly string[]; readonly preloadPath: string; readonly appUpdateYmlPath: string; readonly devServerUrl: Option.Option; @@ -188,6 +189,10 @@ const make = Effect.fn("desktop.environment.make")(function* ( appRoot, backendEntryPath: path.join(appRoot, "apps/server/dist/bin.mjs"), backendCwd: input.isPackaged ? homeDirectory : appRoot, + packagedClientRootCandidates: [ + path.join(input.appPath, "apps/server/dist/client"), + path.join(resourcesPath, "app.asar.unpacked/apps/server/dist/client"), + ], preloadPath: path.join(input.dirname, "preload.cjs"), appUpdateYmlPath: input.isPackaged ? path.join(resourcesPath, "app-update.yml") diff --git a/apps/desktop/src/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts index e3a4de661ac..497d00a5a54 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.ts @@ -403,6 +403,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const httpClient = yield* HttpClient.HttpClient; const state = yield* Ref.make(initialState); + const startRequestedRef = yield* Ref.make(false); const mutex = yield* Semaphore.make(1); const { logWarning: logInstanceWarning, logError: logInstanceError } = @@ -442,6 +443,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( const start: Effect.Effect = Effect.suspend(() => mutex.withPermits(1)( Effect.gen(function* () { + yield* Ref.set(startRequestedRef, true); const current = yield* Ref.get(state); if (Option.isSome(current.active)) { return; @@ -805,7 +807,11 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( Effect.map(Option.getOrElse(() => false)), ); - yield* Effect.addFinalizer(() => stop()); + yield* Effect.addFinalizer(() => + Ref.get(startRequestedRef).pipe( + Effect.flatMap((startRequested) => (startRequested ? stop() : Effect.void)), + ), + ); return { id: spec.id, diff --git a/apps/desktop/src/backend/DesktopBackendPool.test.ts b/apps/desktop/src/backend/DesktopBackendPool.test.ts index fa0811d5df7..21a27602015 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.test.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.test.ts @@ -74,6 +74,7 @@ function makePoolLayer( activate: Effect.die("unexpected window activate"), createMainIfBackendReady: Effect.die("unexpected window create"), showConnectingSplash: Effect.void, + handleRendererReady: Effect.void, handleBackendReady: () => Effect.void, handleBackendNotReady: Effect.void, flushMainWindowBounds: Effect.void, diff --git a/apps/desktop/src/backend/DesktopServerExposure.test.ts b/apps/desktop/src/backend/DesktopServerExposure.test.ts index dcfee93778d..3d7b0e6a2f1 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.test.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.test.ts @@ -251,6 +251,7 @@ describe("DesktopServerExposure", () => { get: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS), load: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS), setMainWindowBounds: () => Effect.die("unexpected main window bounds update"), + setBackendMode: () => Effect.die("unexpected backend mode update"), setServerExposureMode: () => Effect.fail(settingsFailure), setTailscaleServe: () => Effect.fail(settingsFailure), setUpdateChannel: () => Effect.die("unexpected update channel change"), diff --git a/apps/desktop/src/electron/ElectronProtocol.test.ts b/apps/desktop/src/electron/ElectronProtocol.test.ts index 92e30000427..2d35913e355 100644 --- a/apps/desktop/src/electron/ElectronProtocol.test.ts +++ b/apps/desktop/src/electron/ElectronProtocol.test.ts @@ -36,8 +36,8 @@ describe("ElectronProtocol", () => { const protocol = yield* ElectronProtocol.ElectronProtocol; yield* protocol.registerDesktopProtocol({ scheme: "t3code-dev", + source: "proxy", targetOrigin: new URL("http://127.0.0.1:3773/"), - backendOrigin: new URL("http://127.0.0.1:3774/"), clerkFrontendApiHostname: "clerk.t3.codes", }); assert.isDefined(handler); @@ -100,8 +100,8 @@ describe("ElectronProtocol", () => { const protocol = yield* ElectronProtocol.ElectronProtocol; yield* protocol.registerDesktopProtocol({ scheme: "t3code", + source: "proxy", targetOrigin: new URL("http://127.0.0.1:3773/"), - backendOrigin: new URL("http://127.0.0.1:3773/"), clerkFrontendApiHostname: undefined, }); return yield* Effect.promise(() => handler!(new Request("t3code://other/"))); @@ -128,8 +128,8 @@ describe("ElectronProtocol", () => { const protocol = yield* ElectronProtocol.ElectronProtocol; yield* protocol.registerDesktopProtocol({ scheme: "t3code-dev", + source: "proxy", targetOrigin: new URL("http://127.0.0.1:5733/"), - backendOrigin: new URL("http://127.0.0.1:3773/"), clerkFrontendApiHostname: undefined, }); return yield* Effect.promise(() => handler!(new Request("t3code-dev://app/"))); @@ -152,8 +152,8 @@ describe("ElectronProtocol", () => { const error = yield* Effect.scoped( protocol.registerDesktopProtocol({ scheme: "t3code-dev", + source: "proxy", targetOrigin: new URL("http://127.0.0.1:3773/"), - backendOrigin: new URL("http://127.0.0.1:3774/"), clerkFrontendApiHostname: undefined, }), ).pipe(Effect.flip); @@ -177,8 +177,8 @@ describe("ElectronProtocol", () => { Effect.scoped( protocol.registerDesktopProtocol({ scheme: "t3code", + source: "proxy", targetOrigin: new URL("http://127.0.0.1:3773/"), - backendOrigin: new URL("http://127.0.0.1:3773/"), clerkFrontendApiHostname: undefined, }), ), @@ -198,8 +198,8 @@ describe("ElectronProtocol", () => { it("keeps executable sources host-restricted while allowing runtime network resources", () => { const policy = ElectronProtocol.makeDesktopContentSecurityPolicy({ scheme: "t3code", + source: "proxy", targetOrigin: new URL("http://127.0.0.1:3773/"), - backendOrigin: new URL("http://127.0.0.1:3773/"), clerkFrontendApiHostname: "clerk.t3.codes", }); const directives = Object.fromEntries( @@ -226,4 +226,117 @@ describe("ElectronProtocol", () => { ]); assert.deepEqual(directives["font-src"], ["'self'", "t3code:", "data:"]); }); + + it.effect("serves packaged assets, SPA routes, HEAD requests, and CSP", () => + Effect.gen(function* () { + let handler: ((request: Request) => Promise) | undefined; + handleMock.mockImplementation((_scheme, nextHandler) => { + handler = nextHandler; + }); + netFetchMock.mockImplementation(async (url: string) => { + if (url.endsWith("/index.html")) { + return new Response("
T3 Code
", { status: 200 }); + } + if (url.endsWith("/assets/app-123.js")) { + return new Response("export {};", { status: 200 }); + } + return new Response(null, { status: 404 }); + }); + + yield* Effect.scoped( + Effect.gen(function* () { + const protocol = yield* ElectronProtocol.ElectronProtocol; + yield* protocol.registerDesktopProtocol({ + scheme: "t3code", + source: "static", + staticRoot: "/opt/t3/apps/server/dist/client", + clerkFrontendApiHostname: undefined, + }); + assert.isDefined(handler); + + const root = yield* Effect.promise(() => + handler!( + new Request("t3code://app/", { + headers: { accept: "text/html" }, + }), + ), + ); + assert.equal(root.status, 200); + assert.equal(root.headers.get("content-type"), "text/html; charset=utf-8"); + assert.include(root.headers.get("content-security-policy") ?? "", "default-src 'self'"); + assert.equal(yield* Effect.promise(() => root.text()), "
T3 Code
"); + + const asset = yield* Effect.promise(() => + handler!(new Request("t3code://app/assets/app-123.js")), + ); + assert.equal(asset.headers.get("content-type"), "text/javascript; charset=utf-8"); + assert.equal(yield* Effect.promise(() => asset.text()), "export {};"); + + const route = yield* Effect.promise(() => + handler!( + new Request("t3code://app/settings/connections", { + headers: { accept: "text/html" }, + }), + ), + ); + assert.equal(route.status, 200); + assert.equal(yield* Effect.promise(() => route.text()), "
T3 Code
"); + + const head = yield* Effect.promise(() => + handler!( + new Request("t3code://app/assets/app-123.js", { + method: "HEAD", + }), + ), + ); + assert.equal(head.status, 200); + assert.equal(yield* Effect.promise(() => head.text()), ""); + }), + ); + }).pipe(Effect.provide(ElectronProtocol.layer)), + ); + + it.effect("does not fall back missing assets and rejects unsafe static requests", () => + Effect.gen(function* () { + let handler: ((request: Request) => Promise) | undefined; + handleMock.mockImplementation((_scheme, nextHandler) => { + handler = nextHandler; + }); + netFetchMock.mockResolvedValue(new Response(null, { status: 404 })); + + yield* Effect.scoped( + Effect.gen(function* () { + const protocol = yield* ElectronProtocol.ElectronProtocol; + yield* protocol.registerDesktopProtocol({ + scheme: "t3code", + source: "static", + staticRoot: "/opt/t3/apps/server/dist/client", + clerkFrontendApiHostname: undefined, + }); + assert.isDefined(handler); + + const missingAsset = yield* Effect.promise(() => + handler!( + new Request("t3code://app/assets/missing.js", { + headers: { accept: "text/html" }, + }), + ), + ); + assert.equal(missingAsset.status, 404); + assert.equal(netFetchMock.mock.calls.length, 1); + + const traversal = yield* Effect.promise(() => + handler!(new Request("t3code://app/%2e%2e%2fsecret.txt")), + ); + assert.equal(traversal.status, 403); + + const unsupported = yield* Effect.promise(() => + handler!(new Request("t3code://app/", { method: "POST" })), + ); + assert.equal(unsupported.status, 405); + assert.equal(unsupported.headers.get("allow"), "GET, HEAD"); + }), + ); + }).pipe(Effect.provide(ElectronProtocol.layer)), + ); }); diff --git a/apps/desktop/src/electron/ElectronProtocol.ts b/apps/desktop/src/electron/ElectronProtocol.ts index 03c7ef64fd7..d65e563585d 100644 --- a/apps/desktop/src/electron/ElectronProtocol.ts +++ b/apps/desktop/src/electron/ElectronProtocol.ts @@ -1,3 +1,4 @@ +// @effect-diagnostics nodeBuiltinImport:off - Electron static protocol handlers require synchronous platform path validation. import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -5,6 +6,8 @@ import * as NodeTimersPromises from "node:timers/promises"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; import * as Electron from "electron"; @@ -48,13 +51,23 @@ export class ElectronProtocolUnregistrationError extends Schema.TaggedErrorClass } } -export interface DesktopProtocolRegistrationInput { +interface DesktopProtocolRegistrationBase { readonly scheme: string; - readonly targetOrigin: URL; - readonly backendOrigin: URL; readonly clerkFrontendApiHostname: string | undefined; } +export type DesktopProtocolRegistrationInput = DesktopProtocolRegistrationBase & + ( + | { + readonly source: "proxy"; + readonly targetOrigin: URL; + } + | { + readonly source: "static"; + readonly staticRoot: string; + } + ); + export class ElectronProtocol extends Context.Service< ElectronProtocol, { @@ -104,6 +117,143 @@ function withContentSecurityPolicy(response: Response, policy: string): Response }); } +const STATIC_CONTENT_TYPES: Readonly> = { + ".css": "text/css; charset=utf-8", + ".gif": "image/gif", + ".html": "text/html; charset=utf-8", + ".ico": "image/x-icon", + ".jpeg": "image/jpeg", + ".jpg": "image/jpeg", + ".js": "text/javascript; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".map": "application/json; charset=utf-8", + ".mjs": "text/javascript; charset=utf-8", + ".png": "image/png", + ".svg": "image/svg+xml", + ".webp": "image/webp", + ".woff": "font/woff", + ".woff2": "font/woff2", +}; + +type StaticPathResolution = + | { readonly _tag: "Invalid"; readonly status: 400 | 403 } + | { readonly _tag: "Resolved"; readonly path: string; readonly relativePath: string }; + +export function resolveDesktopStaticPath( + staticRoot: string, + encodedPathname: string, +): StaticPathResolution { + let decodedPathname: string; + try { + decodedPathname = decodeURIComponent(encodedPathname); + } catch { + return { _tag: "Invalid", status: 400 }; + } + + if ( + decodedPathname.includes("\0") || + decodedPathname.includes("\\") || + /^[a-zA-Z]:/u.test(decodedPathname.replace(/^\/+/u, "")) + ) { + return { _tag: "Invalid", status: 403 }; + } + + const segments = decodedPathname.split("/").filter((segment) => segment.length > 0); + if (segments.some((segment) => segment === "." || segment === "..")) { + return { _tag: "Invalid", status: 403 }; + } + + const relativePath = segments.length === 0 ? "index.html" : segments.join("/"); + const normalizedRoot = NodePath.resolve(staticRoot); + const resolvedPath = NodePath.resolve(normalizedRoot, relativePath); + const relativeToRoot = NodePath.relative(normalizedRoot, resolvedPath); + if ( + relativeToRoot === ".." || + relativeToRoot.startsWith(`..${NodePath.sep}`) || + NodePath.isAbsolute(relativeToRoot) + ) { + return { _tag: "Invalid", status: 403 }; + } + + return { + _tag: "Resolved", + path: resolvedPath, + relativePath, + }; +} + +function shouldUseSpaFallback(request: Request, relativePath: string): boolean { + if (NodePath.extname(relativePath) !== "") { + return false; + } + const accept = request.headers.get("accept") ?? ""; + const mode = request.headers.get("sec-fetch-mode") ?? ""; + return mode === "navigate" || accept.includes("text/html"); +} + +async function fetchStaticFile(path: string): Promise { + try { + return await Electron.net.fetch(NodeURL.pathToFileURL(path).href, { method: "GET" }); + } catch { + return new Response(null, { status: 404 }); + } +} + +function withStaticResponseHeaders(response: Response, path: string, headOnly: boolean): Response { + const headers = new Headers(response.headers); + const contentType = STATIC_CONTENT_TYPES[NodePath.extname(path).toLowerCase()]; + if (contentType !== undefined) { + headers.set("Content-Type", contentType); + } + return new Response(headOnly ? null : response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); +} + +export async function serveDesktopStaticRequest( + request: Request, + staticRoot: string, + contentSecurityPolicy: string, +): Promise { + const requestUrl = new URL(request.url); + if (requestUrl.host !== DESKTOP_HOST) { + return withContentSecurityPolicy(new Response(null, { status: 404 }), contentSecurityPolicy); + } + if (request.method !== "GET" && request.method !== "HEAD") { + return withContentSecurityPolicy( + new Response(null, { + status: 405, + headers: { Allow: "GET, HEAD" }, + }), + contentSecurityPolicy, + ); + } + + const resolution = resolveDesktopStaticPath(staticRoot, requestUrl.pathname); + if (resolution._tag === "Invalid") { + return withContentSecurityPolicy( + new Response(null, { status: resolution.status }), + contentSecurityPolicy, + ); + } + + let response = await fetchStaticFile(resolution.path); + let responsePath = resolution.path; + if (response.status === 404 && shouldUseSpaFallback(request, resolution.relativePath)) { + // Resolve the shell through the same normalization as asset paths so a + // relative or non-normalized staticRoot still falls back correctly. + responsePath = NodePath.resolve(staticRoot, "index.html"); + response = await fetchStaticFile(responsePath); + } + + return withContentSecurityPolicy( + withStaticResponseHeaders(response, responsePath, request.method === "HEAD"), + contentSecurityPolicy, + ); +} + async function proxyRequest( request: Request, targetOrigin: URL, @@ -181,9 +331,12 @@ export const make = Effect.gen(function* () { yield* Effect.acquireRelease( Effect.try({ try: () => { - Electron.protocol.handle(input.scheme, (request) => - proxyRequest(request, input.targetOrigin, contentSecurityPolicy), - ); + Electron.protocol.handle(input.scheme, (request) => { + if (input.source === "static") { + return serveDesktopStaticRequest(request, input.staticRoot, contentSecurityPolicy); + } + return proxyRequest(request, input.targetOrigin, contentSecurityPolicy); + }); }, catch: (cause) => new ElectronProtocolRegistrationError({ scheme: input.scheme, cause }), }).pipe(Effect.andThen(Ref.set(registered, true))), diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index e478d0c6eff..d72a2f6226a 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -2,6 +2,7 @@ import * as Effect from "effect/Effect"; import * as DesktopIpc from "./DesktopIpc.ts"; import { getClientSettings, setClientSettings } from "./methods/clientSettings.ts"; +import { getBackendModeState, setBackendMode } from "./methods/backendMode.ts"; import { clearConnectionCatalog, getConnectionCatalog, @@ -49,9 +50,11 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* PreviewIpc.installPreviewEventForwarding(); yield* ipc.handleSync(getAppBranding); + yield* ipc.handleSync(getBackendModeState); yield* ipc.handleSync(getWindowFullscreenState); yield* ipc.handleSync(getLocalEnvironmentBootstraps); yield* ipc.handle(getLocalEnvironmentBearerToken); + yield* ipc.handle(setBackendMode); yield* ipc.handle(getClientSettings); yield* ipc.handle(setClientSettings); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 95c725130e5..9557c9cd524 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -13,6 +13,8 @@ export const UPDATE_DOWNLOAD_CHANNEL = "desktop:update-download"; export const UPDATE_INSTALL_CHANNEL = "desktop:update-install"; export const UPDATE_CHECK_CHANNEL = "desktop:update-check"; export const GET_APP_BRANDING_CHANNEL = "desktop:get-app-branding"; +export const GET_BACKEND_MODE_STATE_CHANNEL = "desktop:get-backend-mode-state"; +export const SET_BACKEND_MODE_CHANNEL = "desktop:set-backend-mode"; export const GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL = "desktop:get-local-environment-bootstraps"; export const GET_LOCAL_ENVIRONMENT_BEARER_TOKEN_CHANNEL = "desktop:get-local-environment-bearer-token"; diff --git a/apps/desktop/src/ipc/methods/backendMode.ts b/apps/desktop/src/ipc/methods/backendMode.ts new file mode 100644 index 00000000000..d6c9b7acd52 --- /dev/null +++ b/apps/desktop/src/ipc/methods/backendMode.ts @@ -0,0 +1,60 @@ +import { + DesktopBackendModeSchema, + DesktopBackendModeStateSchema, + type DesktopBackendModeState, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; + +import * as DesktopBackendMode from "../../app/DesktopBackendMode.ts"; +import * as DesktopLifecycle from "../../app/DesktopLifecycle.ts"; +import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; +import * as DesktopIpc from "../DesktopIpc.ts"; +import * as IpcChannels from "../channels.ts"; + +const readBackendModeState: Effect.Effect< + DesktopBackendModeState, + never, + DesktopBackendMode.DesktopBackendMode | DesktopAppSettings.DesktopAppSettings +> = Effect.gen(function* () { + const launchMode = yield* DesktopBackendMode.DesktopBackendMode; + const settings = yield* DesktopAppSettings.DesktopAppSettings; + const launchState = yield* launchMode.get; + const configuredMode = (yield* settings.get).backendMode; + return { + ...launchState, + configuredMode, + }; +}); + +export const getBackendModeState = DesktopIpc.makeSyncIpcMethod({ + channel: IpcChannels.GET_BACKEND_MODE_STATE_CHANNEL, + result: DesktopBackendModeStateSchema, + handler: Effect.fn("desktop.ipc.backendMode.get")(function* () { + return yield* readBackendModeState; + }), +}); + +export const setBackendMode = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.SET_BACKEND_MODE_CHANNEL, + payload: DesktopBackendModeSchema, + result: DesktopBackendModeStateSchema, + handler: Effect.fn("desktop.ipc.backendMode.set")(function* (mode) { + const launchMode = yield* DesktopBackendMode.DesktopBackendMode; + const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; + const settings = yield* DesktopAppSettings.DesktopAppSettings; + const change = yield* settings.setBackendMode(mode); + const launchState = yield* launchMode.get; + const state = { + ...launchState, + configuredMode: change.settings.backendMode, + }; + if ( + change.changed && + launchState.cliOverride === null && + launchState.effectiveMode !== change.settings.backendMode + ) { + yield* lifecycle.relaunch(`backendMode=${mode}`); + } + return state; + }), +}); diff --git a/apps/desktop/src/ipc/methods/window.test.ts b/apps/desktop/src/ipc/methods/window.test.ts index 13e6e8d3956..3d0d23003ef 100644 --- a/apps/desktop/src/ipc/methods/window.test.ts +++ b/apps/desktop/src/ipc/methods/window.test.ts @@ -7,6 +7,7 @@ import type * as Electron from "electron"; import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts"; import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts"; +import * as DesktopBackendMode from "../../app/DesktopBackendMode.ts"; import * as ElectronWindow from "../../electron/ElectronWindow.ts"; import { getLocalEnvironmentBootstraps, getWindowFullscreenState } from "./window.ts"; @@ -64,7 +65,14 @@ describe("getLocalEnvironmentBootstraps", () => { bootstrapToken: "bootstrap-token", }, ]); - }).pipe(Effect.provide(DesktopBackendPool.layerTest([defaultWslInstance]))), + }).pipe( + Effect.provide( + Layer.mergeAll( + DesktopBackendPool.layerTest([defaultWslInstance]), + DesktopBackendMode.layerTest(), + ), + ), + ), ); it.effect("publishes a pending bootstrap only while a transient retry is scheduled", () => { @@ -99,7 +107,14 @@ describe("getLocalEnvironmentBootstraps", () => { wsBaseUrl: null, }, ]); - }).pipe(Effect.provide(DesktopBackendPool.layerTest([retryingInstance]))); + }).pipe( + Effect.provide( + Layer.mergeAll( + DesktopBackendPool.layerTest([retryingInstance]), + DesktopBackendMode.layerTest(), + ), + ), + ); }); it.effect("omits a bounded transient bootstrap after retries stop", () => { @@ -127,8 +142,30 @@ describe("getLocalEnvironmentBootstraps", () => { return Effect.gen(function* () { const result = yield* getLocalEnvironmentBootstraps.handler(); assert.deepEqual(result, []); - }).pipe(Effect.provide(DesktopBackendPool.layerTest([stoppedInstance]))); + }).pipe( + Effect.provide( + Layer.mergeAll( + DesktopBackendPool.layerTest([stoppedInstance]), + DesktopBackendMode.layerTest(), + ), + ), + ); }); + + it.effect("returns no local bootstraps in client-only mode", () => + Effect.gen(function* () { + const mode = yield* DesktopBackendMode.DesktopBackendMode; + yield* mode.latch("client-only"); + assert.deepEqual(yield* getLocalEnvironmentBootstraps.handler(), []); + }).pipe( + Effect.provide( + Layer.mergeAll( + DesktopBackendPool.layerTest([defaultWslInstance]), + DesktopBackendMode.layerTest(), + ), + ), + ), + ); }); describe("getWindowFullscreenState", () => { diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index a4e98aaabad..784c5f6223c 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -12,6 +12,7 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts"; +import * as DesktopBackendMode from "../../app/DesktopBackendMode.ts"; import * as DesktopLocalEnvironmentAuth from "../../backend/DesktopLocalEnvironmentAuth.ts"; import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts"; import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; @@ -69,6 +70,10 @@ export const getLocalEnvironmentBootstraps = DesktopIpc.makeSyncIpcMethod({ channel: IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL, result: Schema.Array(DesktopEnvironmentBootstrapSchema), handler: Effect.fn("desktop.ipc.window.getLocalEnvironmentBootstraps")(function* () { + const launchMode = yield* DesktopBackendMode.DesktopBackendMode; + if ((yield* launchMode.get).effectiveMode === "client-only") { + return []; + } const pool = yield* DesktopBackendPool.DesktopBackendPool; const instances = yield* pool.list; const bootstraps: DesktopEnvironmentBootstrap[] = []; diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 9795f04e8ae..282a69c4c0e 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -31,6 +31,7 @@ import * as ElectronTheme from "./electron/ElectronTheme.ts"; import * as ElectronUpdater from "./electron/ElectronUpdater.ts"; import * as ElectronWindow from "./electron/ElectronWindow.ts"; import * as DesktopApp from "./app/DesktopApp.ts"; +import * as DesktopBackendMode from "./app/DesktopBackendMode.ts"; import * as DesktopAppIdentity from "./app/DesktopAppIdentity.ts"; import * as DesktopConnectionCatalogStore from "./app/DesktopConnectionCatalogStore.ts"; import * as DesktopClerk from "./app/DesktopClerk.ts"; @@ -125,6 +126,7 @@ const electronLayer = Layer.mergeAll( const desktopFoundationLayer = Layer.mergeAll( DesktopState.layer, DesktopShutdown.layer, + DesktopBackendMode.layer, DesktopAppSettings.layer, DesktopClientSettings.layer, DesktopConnectionCatalogStore.layer.pipe(Layer.provideMerge(DesktopSavedEnvironments.layer)), diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 228114fd1d1..fe89c1c80d2 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -35,6 +35,18 @@ contextBridge.exposeInMainWorld("desktopBridge", { } return result as ReturnType; }, + getBackendModeState: () => { + const result = ipcRenderer.sendSync(IpcChannels.GET_BACKEND_MODE_STATE_CHANNEL); + if (typeof result !== "object" || result === null) { + return { + effectiveMode: "managed", + configuredMode: "managed", + cliOverride: null, + }; + } + return result as ReturnType; + }, + setBackendMode: (mode) => ipcRenderer.invoke(IpcChannels.SET_BACKEND_MODE_CHANNEL, mode), getLocalEnvironmentBootstraps: () => { const result = ipcRenderer.sendSync(IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL); if (!Array.isArray(result)) { diff --git a/apps/desktop/src/settings/DesktopAppSettings.test.ts b/apps/desktop/src/settings/DesktopAppSettings.test.ts index 3878b0e36ad..f7835e6069d 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.test.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.test.ts @@ -11,6 +11,7 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopAppSettings from "./DesktopAppSettings.ts"; const DesktopSettingsPatch = Schema.Struct({ + backendMode: Schema.optionalKey(Schema.Literals(["managed", "client-only"])), mainWindowBounds: Schema.optionalKey( Schema.NullOr( Schema.Struct({ @@ -102,6 +103,7 @@ describe("DesktopSettings", () => { assert.deepEqual( DesktopAppSettings.resolveDefaultDesktopSettings("0.0.17-nightly.20260415.1"), { + backendMode: "managed", mainWindowBounds: null, mainWindowMaximized: false, serverExposureMode: "local-only", @@ -121,6 +123,7 @@ describe("DesktopSettings", () => { Effect.gen(function* () { const settings = yield* DesktopAppSettings.DesktopAppSettings; yield* writeSettingsPatch({ + backendMode: "client-only", serverExposureMode: "network-accessible", tailscaleServeEnabled: true, tailscaleServePort: 8443, @@ -129,6 +132,7 @@ describe("DesktopSettings", () => { }); assert.deepEqual(yield* settings.load, { + backendMode: "client-only", mainWindowBounds: null, mainWindowMaximized: false, serverExposureMode: "network-accessible", @@ -156,6 +160,10 @@ describe("DesktopSettings", () => { assert.isTrue(updateChannel.changed); assert.equal(updateChannel.settings.updateChannel, "nightly"); assert.equal(updateChannel.settings.updateChannelConfiguredByUser, true); + + const backendMode = yield* settings.setBackendMode("managed"); + assert.isTrue(backendMode.changed); + assert.equal(backendMode.settings.backendMode, "managed"); }), ), ); @@ -235,6 +243,7 @@ describe("DesktopSettings", () => { ); assert.deepEqual(yield* settings.load, { + backendMode: "managed", mainWindowBounds: { x: 120, y: 80, width: 1280, height: 900 }, mainWindowMaximized: false, serverExposureMode: "network-accessible", @@ -277,11 +286,13 @@ describe("DesktopSettings", () => { yield* settings.setMainWindowBounds({ x: -1200, y: 40, width: 1440, height: 960 }, true); yield* settings.setServerExposureMode("network-accessible"); + yield* settings.setBackendMode("client-only"); const persisted = yield* decodeDesktopSettingsPatch( yield* fileSystem.readFileString(environment.desktopSettingsPath), ); assert.deepEqual(persisted, { + backendMode: "client-only", mainWindowBounds: { x: -1200, y: 40, width: 1440, height: 960 }, mainWindowMaximized: true, serverExposureMode: "network-accessible", @@ -300,6 +311,7 @@ describe("DesktopSettings", () => { }); assert.deepEqual(yield* settings.load, { + backendMode: "managed", mainWindowBounds: null, mainWindowMaximized: false, serverExposureMode: "local-only", @@ -327,6 +339,7 @@ describe("DesktopSettings", () => { }); assert.deepEqual(yield* settings.load, { + backendMode: "managed", mainWindowBounds: null, mainWindowMaximized: false, serverExposureMode: "local-only", @@ -353,6 +366,7 @@ describe("DesktopSettings", () => { }); assert.deepEqual(yield* settings.load, { + backendMode: "managed", mainWindowBounds: null, mainWindowMaximized: false, serverExposureMode: "local-only", diff --git a/apps/desktop/src/settings/DesktopAppSettings.ts b/apps/desktop/src/settings/DesktopAppSettings.ts index 466c9a9b5f8..25befef96fb 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.ts @@ -1,6 +1,8 @@ import { DesktopServerExposureModeSchema, + DesktopBackendModeSchema, DesktopUpdateChannelSchema, + type DesktopBackendMode, type DesktopServerExposureMode, type DesktopUpdateChannel, } from "@t3tools/contracts"; @@ -20,6 +22,7 @@ import { resolveDefaultDesktopUpdateChannel } from "../updates/updateChannels.ts import { isValidDistroName } from "../wsl/wslPathParsing.ts"; export interface DesktopSettings { + readonly backendMode: DesktopBackendMode; readonly mainWindowBounds: DesktopWindowBounds | null; readonly mainWindowMaximized: boolean; readonly serverExposureMode: DesktopServerExposureMode; @@ -67,6 +70,7 @@ export const DEFAULT_MAIN_WINDOW_SIZE = { } as const; export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { + backendMode: "managed", mainWindowBounds: null, mainWindowMaximized: false, serverExposureMode: "local-only", @@ -87,6 +91,7 @@ const DesktopWindowBoundsDocument = Schema.Struct({ }); const DesktopSettingsDocument = Schema.Struct({ + backendMode: Schema.optionalKey(DesktopBackendModeSchema), mainWindowBounds: Schema.optionalKey(Schema.NullOr(DesktopWindowBoundsDocument)), mainWindowMaximized: Schema.optionalKey(Schema.Boolean), serverExposureMode: Schema.optionalKey(DesktopServerExposureModeSchema), @@ -148,6 +153,9 @@ export class DesktopAppSettings extends Context.Service< bounds: DesktopWindowBounds, isMaximized: boolean, ) => Effect.Effect; + readonly setBackendMode: ( + mode: DesktopBackendMode, + ) => Effect.Effect; readonly setServerExposureMode: ( mode: DesktopServerExposureMode, ) => Effect.Effect; @@ -216,6 +224,7 @@ function normalizeDesktopSettingsDocument( (parsed.wslBackendEnabled === undefined && parsed.wslMode === "wsl"); return { + backendMode: parsed.backendMode === "client-only" ? "client-only" : "managed", mainWindowBounds, mainWindowMaximized: mainWindowBounds !== null && parsed.mainWindowMaximized === true, serverExposureMode: @@ -238,6 +247,9 @@ function toDesktopSettingsDocument( ): DesktopSettingsDocument { const document: Mutable = {}; + if (settings.backendMode !== defaults.backendMode) { + document.backendMode = settings.backendMode; + } if (settings.mainWindowBounds !== null) { document.mainWindowBounds = settings.mainWindowBounds; } @@ -284,6 +296,18 @@ function setServerExposureMode( }; } +function setBackendMode( + settings: DesktopSettings, + requestedMode: DesktopBackendMode, +): DesktopSettings { + return settings.backendMode === requestedMode + ? settings + : { + ...settings, + backendMode: requestedMode, + }; +} + function setMainWindowBounds( settings: DesktopSettings, bounds: DesktopWindowBounds, @@ -506,6 +530,10 @@ export const make = Effect.gen(function* () { }, }), ), + setBackendMode: (mode) => + persist((settings) => setBackendMode(settings, mode)).pipe( + Effect.withSpan("desktop.settings.setBackendMode", { attributes: { mode } }), + ), setServerExposureMode: (mode) => persist((settings) => setServerExposureMode(settings, mode)).pipe( Effect.withSpan("desktop.settings.setServerExposureMode", { attributes: { mode } }), @@ -565,6 +593,7 @@ export const layerTest = (initialSettings: DesktopSettings = DEFAULT_DESKTOP_SET load: SynchronizedRef.get(settingsRef), setMainWindowBounds: (bounds, isMaximized) => update((settings) => setMainWindowBounds(settings, bounds, isMaximized)), + setBackendMode: (mode) => update((settings) => setBackendMode(settings, mode)), setServerExposureMode: (mode) => update((settings) => setServerExposureMode(settings, mode)), setTailscaleServe: (input) => update((settings) => setTailscaleServe(settings, input)), diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index 32224c7a5ca..85757a468cf 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -159,6 +159,7 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { get: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS), load: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS), setMainWindowBounds: () => Effect.die("unexpected main window bounds update"), + setBackendMode: () => Effect.die("unexpected backend mode update"), setServerExposureMode: () => Effect.die("unexpected server exposure update"), setTailscaleServe: () => Effect.die("unexpected Tailscale Serve update"), setUpdateChannel: () => Effect.fail(setUpdateChannelError), diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index 168846466ed..f8071f634ae 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -74,6 +74,7 @@ const makeDesktopWindowLayer = (selectedAction: Deferred.Deferred) => activate: Effect.void, createMainIfBackendReady: Effect.void, showConnectingSplash: Effect.void, + handleRendererReady: Effect.void, handleBackendReady: () => Effect.void, handleBackendNotReady: Effect.void, flushMainWindowBounds: Effect.void, diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 587da8d4431..8cb74dffc05 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -211,6 +211,7 @@ function makeTestLayer(input: { } return { settings: desktopSettings, changed }; }), + setBackendMode: () => Effect.die("unexpected backend mode update"), setServerExposureMode: () => Effect.die("unexpected server exposure update"), setTailscaleServe: () => Effect.die("unexpected Tailscale Serve update"), setUpdateChannel: () => Effect.die("unexpected update channel change"), @@ -436,6 +437,26 @@ describe("DesktopWindow", () => { }), ); + it.effect("opens once the renderer is ready without a backend callback", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleRendererReady; + assert.equal(yield* Ref.get(createCount), 1); + assert.deepEqual(fakeWindow.loadURL.mock.calls[0], ["t3code-dev://app/"]); + }).pipe(Effect.provide(layer)); + }), + ); + it.effect("uses the persisted main window bounds when opening the window", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index db4b698434d..baaf1580765 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -66,6 +66,9 @@ export class DesktopWindow extends Context.Service< // mode), before the WSL backend that serves the renderer is ready. It is // dismissed automatically once the real main window reveals. readonly showConnectingSplash: Effect.Effect; + // Marks the packaged/Vite renderer as loadable independently of whether + // this desktop process owns a backend. + readonly handleRendererReady: Effect.Effect; // Marks the primary backend as ready so `createMainIfBackendReady` and the // macOS "activate without windows" path may open the real main window. The // renderer now always loads the local client URL (getDesktopUrl) and connects @@ -727,6 +730,11 @@ export const make = Effect.gen(function* () { Effect.withSpan("desktop.window.showConnectingSplash"), ); + const handleRendererReady = Ref.set(backendReadyRef, true).pipe( + Effect.andThen(createMainIfBackendReady), + Effect.withSpan("desktop.window.handleRendererReady"), + ); + return DesktopWindow.of({ createMain, ensureMain, @@ -754,10 +762,10 @@ export const make = Effect.gen(function* () { }).pipe(Effect.withSpan("desktop.window.activate")), createMainIfBackendReady, showConnectingSplash, + handleRendererReady, handleBackendReady: Effect.fn("desktop.window.handleBackendReady")(function* (httpBaseUrl) { - yield* Ref.set(backendReadyRef, true); yield* logWindowInfo("backend ready", { source: "http", url: httpBaseUrl.href }); - yield* createMainIfBackendReady; + yield* handleRendererReady; }), handleBackendNotReady: Ref.set(backendReadyRef, false).pipe( Effect.withSpan("desktop.window.handleBackendNotReady"), diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 52b89530127..03f95707581 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -24,6 +24,8 @@ import { type AuthPairingLink, type AdvertisedEndpoint, type DesktopDiscoveredSshHost, + type DesktopBackendMode, + type DesktopBackendModeState, type DesktopSshEnvironmentTarget, type DesktopServerExposureState, type DesktopWslState, @@ -1711,6 +1713,10 @@ function CloudRemoteEnvironmentRows({ export function ConnectionsSettings() { const desktopBridge = window.desktopBridge; + const [desktopBackendModeState, setDesktopBackendModeState] = + useState(() => desktopBridge?.getBackendModeState?.() ?? null); + const [desktopBackendModeError, setDesktopBackendModeError] = useState(null); + const [isUpdatingDesktopBackendMode, setIsUpdatingDesktopBackendMode] = useState(false); const { environments } = useEnvironments(); const primaryEnvironment = usePrimaryEnvironment(); const connectPairing = useAtomCommand(connectPairingAtom, { reportFailure: false }); @@ -1734,6 +1740,10 @@ export function ConnectionsSettings() { .toSorted((left, right) => left.label.localeCompare(right.label)), [environments], ); + const hasUsableClientOnlyEnvironment = savedEnvironments.some( + (environment) => !isDesktopLocalConnectionTarget(environment.entry.target), + ); + const isClientOnlyDesktop = desktopBackendModeState?.effectiveMode === "client-only"; const savedDesktopSshEnvironmentsByAlias = useMemo( () => savedEnvironments.reduce>( @@ -1841,7 +1851,8 @@ export function ConnectionsSettings() { const setDefaultAdvertisedEndpointKey = useUiStateStore( (state) => state.setDefaultAdvertisedEndpointKey, ); - const canManageLocalBackend = currentSessionScopes?.includes(AuthAccessWriteScope) ?? false; + const canManageLocalBackend = + !isClientOnlyDesktop && (currentSessionScopes?.includes(AuthAccessWriteScope) ?? false); const canManageRelay = currentSessionScopes?.includes(AuthRelayWriteScope) ?? false; const authAccessChanges = useEnvironmentQuery( canManageLocalBackend && primaryEnvironmentId !== null @@ -2975,9 +2986,88 @@ export function ConnectionsSettings() { /> ); + const handleDesktopBackendModeChange = async (mode: DesktopBackendMode) => { + if (!desktopBridge || !desktopBackendModeState) return; + if (mode === desktopBackendModeState.configuredMode) return; + if (mode === "client-only" && !hasUsableClientOnlyEnvironment) { + setDesktopBackendModeError( + "Pair and save an environment before switching to client-only mode.", + ); + setAddBackendDialogOpen(true); + return; + } + + setIsUpdatingDesktopBackendMode(true); + setDesktopBackendModeError(null); + try { + const next = await desktopBridge.setBackendMode(mode); + setDesktopBackendModeState(next); + setIsUpdatingDesktopBackendMode(false); + } catch (error) { + setDesktopBackendModeError( + error instanceof Error ? error.message : "Could not update the desktop backend mode.", + ); + setIsUpdatingDesktopBackendMode(false); + } + }; + return ( - {canManageLocalBackend ? ( + {desktopBridge && desktopBackendModeState ? ( + + {desktopBackendModeError} + ) : desktopBackendModeState.cliOverride !== null ? ( + + This launch is overridden by --backend-mode= + {desktopBackendModeState.cliOverride}. The saved preference applies when launched + without that flag. + + ) : null + } + control={ + + } + /> + + ) : null} + + {isClientOnlyDesktop ? null : canManageLocalBackend ? ( <> {primaryVersionMismatch ? ( diff --git a/apps/web/src/connection/platform.ts b/apps/web/src/connection/platform.ts index 56d25fa142e..699711ffcc3 100644 --- a/apps/web/src/connection/platform.ts +++ b/apps/web/src/connection/platform.ts @@ -45,7 +45,8 @@ import { FetchHttpClient } from "effect/unstable/http"; import { readDesktopPrimaryBearerToken } from "../environments/primary/desktopAuth"; import { primaryEnvironmentHttpLayer } from "../environments/primary/httpLayer"; import { - readPrimaryEnvironmentTarget, + readOptionalPrimaryEnvironmentTarget, + isDesktopClientOnlyMode, type PrimaryEnvironmentTarget, } from "../environments/primary/target"; import { clearComposerDraftsEnvironment } from "../composerDraftStore"; @@ -399,7 +400,7 @@ export type PrimaryEnvironmentTargetRead = }; export function readPrimaryEnvironmentTargetResult( - readTarget: () => PrimaryEnvironmentTarget | null = readPrimaryEnvironmentTarget, + readTarget: () => PrimaryEnvironmentTarget | null = readOptionalPrimaryEnvironmentTarget, ): PrimaryEnvironmentTargetRead { try { return { _tag: "Success", target: readTarget() }; @@ -456,7 +457,7 @@ export function secondaryRegistrationsToRetainAfterTopologyRead( const platformConnectionSourceLayer = Layer.effect( PlatformConnectionSource, Effect.gen(function* () { - if (isHostedStaticApp()) { + if (isHostedStaticApp() || isDesktopClientOnlyMode()) { return PlatformConnectionSource.of({ registrations: Stream.empty, }); diff --git a/apps/web/src/environments/primary/bootstrap.test.ts b/apps/web/src/environments/primary/bootstrap.test.ts index e200ecf2ff8..6ee612f54c1 100644 --- a/apps/web/src/environments/primary/bootstrap.test.ts +++ b/apps/web/src/environments/primary/bootstrap.test.ts @@ -8,6 +8,7 @@ import { isPrimaryEnvironmentProtocolUnsupportedError, isPrimaryEnvironmentUrlInvalidError, readPrimaryEnvironmentTarget, + readOptionalPrimaryEnvironmentTarget, resolvePrimaryEnvironmentHttpUrl, resolveInitialPrimaryEnvironmentDescriptor, resetPrimaryEnvironmentDescriptorForTests, @@ -257,4 +258,21 @@ describe("environmentBootstrap", () => { message: "The window-origin primary environment target uses unsupported protocol file:.", }); }); + + it("returns no primary target for a client-only desktop without using the custom origin", () => { + vi.stubGlobal("window", { + location: new URL("t3code://app/"), + history: { replaceState: vi.fn() }, + desktopBridge: { + getBackendModeState: () => ({ + effectiveMode: "client-only", + configuredMode: "client-only", + cliOverride: null, + }), + getLocalEnvironmentBootstraps: () => [], + }, + }); + + expect(readOptionalPrimaryEnvironmentTarget()).toBeNull(); + }); }); diff --git a/apps/web/src/environments/primary/index.ts b/apps/web/src/environments/primary/index.ts index f85c91c1536..ed01813c67d 100644 --- a/apps/web/src/environments/primary/index.ts +++ b/apps/web/src/environments/primary/index.ts @@ -48,6 +48,8 @@ export { PrimaryEnvironmentProtocolUnsupportedError, PrimaryEnvironmentUrlInvalidError, readPrimaryEnvironmentTarget, + readOptionalPrimaryEnvironmentTarget, + isDesktopClientOnlyMode, resolvePrimaryEnvironmentHttpUrl, isLoopbackHostname, type PrimaryEnvironmentTarget, diff --git a/apps/web/src/environments/primary/target.ts b/apps/web/src/environments/primary/target.ts index cc002419fe7..f3fed57afeb 100644 --- a/apps/web/src/environments/primary/target.ts +++ b/apps/web/src/environments/primary/target.ts @@ -73,6 +73,11 @@ export interface PrimaryEnvironmentTarget { }; } +export function isDesktopClientOnlyMode(): boolean { + const bridge = window.desktopBridge; + return bridge?.getBackendModeState?.().effectiveMode === "client-only"; +} + const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]); function getDesktopLocalEnvironmentBootstrap(): DesktopEnvironmentBootstrap | null { @@ -292,3 +297,7 @@ export function readPrimaryEnvironmentTarget(): PrimaryEnvironmentTarget { resolveWindowOriginPrimaryTarget() ); } + +export function readOptionalPrimaryEnvironmentTarget(): PrimaryEnvironmentTarget | null { + return isDesktopClientOnlyMode() ? null : readPrimaryEnvironmentTarget(); +} diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 346991d114d..ed943e7e59c 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -36,7 +36,10 @@ import { import { useUiStateStore } from "../uiStateStore"; import { syncBrowserChromeTheme } from "../hooks/useTheme"; import { configureClientTracing } from "../observability/clientTracing"; -import { resolveInitialServerAuthGateState } from "../environments/primary"; +import { + isDesktopClientOnlyMode, + resolveInitialServerAuthGateState, +} from "../environments/primary"; import { hasHostedPairingRequest, isHostedStaticApp } from "../hostedPairing"; import { shellEnvironment } from "../state/shell"; import { useAtomValue } from "@effect/atom-react"; @@ -63,7 +66,7 @@ export const Route = createRootRoute({ }; } - if (isHostedStaticApp(new URL(window.location.href))) { + if (isHostedStaticApp(new URL(window.location.href)) || isDesktopClientOnlyMode()) { return { authGateState: { status: "hosted-static", diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 4b1676d7926..b8323be6814 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -163,6 +163,7 @@ export type DesktopRuntimeArch = "arm64" | "x64" | "other"; export type DesktopTheme = "light" | "dark" | "system"; export type DesktopUpdateChannel = "latest" | "nightly"; export type DesktopAppStageLabel = "Alpha" | "Dev" | "Nightly"; +export type DesktopBackendMode = "managed" | "client-only"; export const DesktopUpdateStatusSchema = Schema.Literals([ "disabled", @@ -178,6 +179,19 @@ export const DesktopRuntimeArchSchema = Schema.Literals(["arm64", "x64", "other" export const DesktopThemeSchema = Schema.Literals(["light", "dark", "system"]); export const DesktopUpdateChannelSchema = Schema.Literals(["latest", "nightly"]); export const DesktopAppStageLabelSchema = Schema.Literals(["Alpha", "Dev", "Nightly"]); +export const DesktopBackendModeSchema = Schema.Literals(["managed", "client-only"]); + +export interface DesktopBackendModeState { + effectiveMode: DesktopBackendMode; + configuredMode: DesktopBackendMode; + cliOverride: DesktopBackendMode | null; +} + +export const DesktopBackendModeStateSchema = Schema.Struct({ + effectiveMode: DesktopBackendModeSchema, + configuredMode: DesktopBackendModeSchema, + cliOverride: Schema.NullOr(DesktopBackendModeSchema), +}); export interface DesktopAppBranding { baseName: string; @@ -974,6 +988,8 @@ export const DesktopPreviewAutomationWaitForInputSchema = Schema.Struct({ export interface DesktopBridge { getAppBranding: () => DesktopAppBranding | null; + getBackendModeState: () => DesktopBackendModeState; + setBackendMode: (mode: DesktopBackendMode) => Promise; // One bootstrap per pool instance currently registered with bootstrap // info (omits instances whose backend hasn't produced a config yet). // The primary backend is identified by id === PRIMARY_LOCAL_ENVIRONMENT_ID. From 29a9c9d45c6e05b673abc004debcfba191a95a57 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Fri, 24 Jul 2026 13:12:02 -0700 Subject: [PATCH 02/10] fix(desktop): harden client-only mode transitions --- apps/desktop/src/app/DesktopApp.ts | 8 +++- apps/desktop/src/app/DesktopLifecycle.ts | 18 +++++-- .../src/ipc/methods/backendMode.test.ts | 47 +++++++++++++++++++ apps/desktop/src/preload.ts | 7 ++- 4 files changed, 72 insertions(+), 8 deletions(-) create mode 100644 apps/desktop/src/ipc/methods/backendMode.test.ts diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index 1f98c7ec699..904120558e1 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -199,7 +199,13 @@ const bootstrap = Effect.gen(function* () { yield* installDesktopIpcHandlers(); yield* logBootstrapInfo("bootstrap ipc handlers registered"); if (!(yield* Ref.get(state.quitting))) { - yield* desktopWindow.handleRendererReady; + yield* desktopWindow.handleRendererReady.pipe( + Effect.catch((error) => + logBootstrapWarning("failed to open main window after renderer readiness", { + error: error.message, + }), + ), + ); } return; } diff --git a/apps/desktop/src/app/DesktopLifecycle.ts b/apps/desktop/src/app/DesktopLifecycle.ts index f8e05915718..b087bc63209 100644 --- a/apps/desktop/src/app/DesktopLifecycle.ts +++ b/apps/desktop/src/app/DesktopLifecycle.ts @@ -43,7 +43,7 @@ export class DesktopLifecycle extends Context.Service< { readonly relaunch: ( reason: string, - ) => Effect.Effect; + ) => Effect.Effect; readonly register: Effect.Effect; } >()("@t3tools/desktop/app/DesktopLifecycle") {} @@ -146,6 +146,18 @@ export const make = DesktopLifecycle.of({ const environment = yield* DesktopEnvironment.DesktopEnvironment; const state = yield* DesktopState.DesktopState; yield* logLifecycleInfo("desktop relaunch requested", { reason }); + if (!environment.isDevelopment) { + yield* electronApp + .relaunch({ + execPath: process.execPath, + args: process.argv.slice(1), + }) + .pipe( + Effect.catchCause((cause) => + Effect.fail(new DesktopLifecycleRelaunchError({ reason, cause })), + ), + ); + } yield* Effect.gen(function* () { yield* Effect.yieldNow; yield* Ref.set(state.quitting, true); @@ -154,10 +166,6 @@ export const make = DesktopLifecycle.of({ yield* electronApp.exit(75); return; } - yield* electronApp.relaunch({ - execPath: process.execPath, - args: process.argv.slice(1), - }); yield* electronApp.exit(0); }).pipe( Effect.catchCause((cause) => { diff --git a/apps/desktop/src/ipc/methods/backendMode.test.ts b/apps/desktop/src/ipc/methods/backendMode.test.ts new file mode 100644 index 00000000000..350ae040c39 --- /dev/null +++ b/apps/desktop/src/ipc/methods/backendMode.test.ts @@ -0,0 +1,47 @@ +import { DesktopBackendModeStateSchema } from "@t3tools/contracts"; +import { assert, describe, it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; + +import * as DesktopBackendMode from "../../app/DesktopBackendMode.ts"; +import * as DesktopLifecycle from "../../app/DesktopLifecycle.ts"; +import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; +import { setBackendMode } from "./backendMode.ts"; + +const decodeBackendModeState = Schema.decodeUnknownEffect(DesktopBackendModeStateSchema); + +const unusedLifecycleRuntimeLayer = + Layer.empty as Layer.Layer; + +describe("backend mode IPC", () => { + it.effect("rejects the update when a packaged relaunch cannot be scheduled", () => { + const relaunchError = new DesktopLifecycle.DesktopLifecycleRelaunchError({ + reason: "backendMode=client-only", + cause: Cause.die(new Error("relaunch failed")), + }); + const layer = Layer.mergeAll( + DesktopBackendMode.layerTest(), + DesktopAppSettings.layerTest(), + Layer.succeed( + DesktopLifecycle.DesktopLifecycle, + DesktopLifecycle.DesktopLifecycle.of({ + relaunch: () => Effect.fail(relaunchError), + register: Effect.void, + }), + ), + unusedLifecycleRuntimeLayer, + ); + + return Effect.gen(function* () { + const launchMode = yield* DesktopBackendMode.DesktopBackendMode; + yield* launchMode.latch("managed"); + const error = yield* setBackendMode + .handler("client-only") + .pipe(Effect.flatMap(decodeBackendModeState), Effect.flip); + + assert.strictEqual(error, relaunchError); + }).pipe(Effect.provide(layer)); + }); +}); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index fe89c1c80d2..d444914983f 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -38,9 +38,12 @@ contextBridge.exposeInMainWorld("desktopBridge", { getBackendModeState: () => { const result = ipcRenderer.sendSync(IpcChannels.GET_BACKEND_MODE_STATE_CHANNEL); if (typeof result !== "object" || result === null) { + // An unavailable mode handler means the renderer cannot safely assume + // that this process owns a backend. Fail closed into the connection-only + // routing path instead of trying to resolve t3code:// as an HTTP backend. return { - effectiveMode: "managed", - configuredMode: "managed", + effectiveMode: "client-only", + configuredMode: "client-only", cliOverride: null, }; } From b425c8b475008b3ff2d047522d4ce9f2e50439be Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Fri, 24 Jul 2026 12:47:55 -0700 Subject: [PATCH 03/10] feat: discover local t3 serve instances for explicit pairing Let a Linux desktop client find a loopback `t3 serve` running as the same user and pair with it, without copying a URL out of the terminal. - A headless server bound to loopback publishes an owner-only (0700 directory, 0600 file) JSON advertisement under XDG_RUNTIME_DIR, written atomically, rotated before the credential expires, and removed on shutdown. The advertisement owns its own pairing credential rather than the one printed to the terminal. - The desktop scans that directory and validates ownership, permissions, size, path containment, timestamps, canonical loopback URLs and environment identity before presenting a candidate. - Pairing requires an explicit Pair action, re-reads the advertisement first so a rotated credential is used, saves to the encrypted connection catalog, and never takes ownership of the server process. Co-Authored-By: Claude --- .../app/DesktopLocalServerDiscovery.test.ts | 111 +++++++++ .../src/app/DesktopLocalServerDiscovery.ts | 202 ++++++++++++++++ apps/desktop/src/ipc/DesktopIpcHandlers.ts | 2 + apps/desktop/src/ipc/channels.ts | 1 + .../src/ipc/methods/localServerDiscovery.ts | 17 ++ apps/desktop/src/main.ts | 2 + apps/desktop/src/preload.ts | 1 + .../src/localServerAdvertisement.test.ts | 206 ++++++++++++++++ apps/server/src/localServerAdvertisement.ts | 226 ++++++++++++++++++ apps/server/src/serverRuntimeStartup.ts | 4 + apps/server/src/startupAccess.test.ts | 11 + apps/server/src/startupAccess.ts | 17 ++ .../ConnectionsSettings.logic.test.ts | 56 ++++- .../settings/ConnectionsSettings.logic.ts | 35 ++- .../settings/ConnectionsSettings.tsx | 183 +++++++++++++- packages/contracts/src/index.ts | 1 + packages/contracts/src/ipc.ts | 2 + .../contracts/src/localServerDiscovery.ts | 24 ++ packages/shared/package.json | 4 + .../shared/src/localServerDiscovery.test.ts | 67 ++++++ packages/shared/src/localServerDiscovery.ts | 87 +++++++ 21 files changed, 1254 insertions(+), 5 deletions(-) create mode 100644 apps/desktop/src/app/DesktopLocalServerDiscovery.test.ts create mode 100644 apps/desktop/src/app/DesktopLocalServerDiscovery.ts create mode 100644 apps/desktop/src/ipc/methods/localServerDiscovery.ts create mode 100644 apps/server/src/localServerAdvertisement.test.ts create mode 100644 apps/server/src/localServerAdvertisement.ts create mode 100644 packages/contracts/src/localServerDiscovery.ts create mode 100644 packages/shared/src/localServerDiscovery.test.ts create mode 100644 packages/shared/src/localServerDiscovery.ts diff --git a/apps/desktop/src/app/DesktopLocalServerDiscovery.test.ts b/apps/desktop/src/app/DesktopLocalServerDiscovery.test.ts new file mode 100644 index 00000000000..fe32f2c6f7c --- /dev/null +++ b/apps/desktop/src/app/DesktopLocalServerDiscovery.test.ts @@ -0,0 +1,111 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, expect, it } from "@effect/vitest"; +import { EnvironmentId, LocalServerAdvertisement } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as TestClock from "effect/testing/TestClock"; + +import { make } from "./DesktopLocalServerDiscovery.ts"; + +const environmentId = EnvironmentId.make("environment-local"); +const descriptor = { + environmentId, + label: "Local development server", + platform: { os: "linux", arch: "x64" }, + serverVersion: "0.0.28", + capabilities: { repositoryIdentity: true }, +} as const; +const encodeRecord = Schema.encodeUnknownEffect(Schema.fromJsonString(LocalServerAdvertisement)); + +const makeRecord = ( + overrides: Partial = {}, +): LocalServerAdvertisement => ({ + version: 1, + instanceId: "instance-local", + pid: 1234, + startedAt: "2026-01-01T00:00:00.000Z", + httpBaseUrl: "http://127.0.0.1:3773/", + pairingUrl: "http://127.0.0.1:3773/pair#token=PAIRCODE", + pairingExpiresAt: "2099-01-01T00:00:00.000Z", + environmentId, + label: "Advertisement label", + ...overrides, +}); + +it.effect("discovers private, live, identity-matched loopback advertisements", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const runtimeDirectory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-local-discovery-test-", + }); + const advertisementDirectory = path.join(runtimeDirectory, "t3code", "servers"); + const recordPath = path.join(advertisementDirectory, "instance-local.json"); + yield* fileSystem.makeDirectory(advertisementDirectory, { recursive: true, mode: 0o700 }); + yield* fileSystem.chmod(advertisementDirectory, 0o700); + yield* fileSystem.writeFileString(recordPath, yield* encodeRecord(makeRecord()), { + mode: 0o600, + }); + yield* fileSystem.chmod(recordPath, 0o600); + + const discovery = yield* make({ + platform: "linux", + xdgRuntimeDirectory: runtimeDirectory, + uid: process.getuid?.(), + probeEnvironment: () => Effect.succeed(descriptor), + }); + const discovered = yield* discovery.discover; + + assert.strictEqual(discovered.length, 1); + expect(discovered[0]?.environmentId).toBe(environmentId); + expect(discovered[0]?.label).toBe(descriptor.label); + }).pipe(Effect.provide(NodeServices.layer), TestClock.withLive), +); + +it.effect("ignores unsafe, expired, and identity-mismatched advertisements", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const runtimeDirectory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-local-discovery-rejection-test-", + }); + const advertisementDirectory = path.join(runtimeDirectory, "t3code", "servers"); + yield* fileSystem.makeDirectory(advertisementDirectory, { recursive: true, mode: 0o700 }); + yield* fileSystem.chmod(advertisementDirectory, 0o700); + + const records = [ + makeRecord({ + instanceId: "non-loopback", + httpBaseUrl: "http://192.168.1.20:3773/", + pairingUrl: "http://192.168.1.20:3773/pair#token=PAIRCODE", + }), + makeRecord({ + instanceId: "expired", + pairingExpiresAt: "2000-01-01T00:00:00.000Z", + }), + makeRecord({ instanceId: "identity-mismatch" }), + ]; + for (const record of records) { + const recordPath = path.join(advertisementDirectory, `${record.instanceId}.json`); + yield* fileSystem.writeFileString(recordPath, yield* encodeRecord(record), { mode: 0o600 }); + yield* fileSystem.chmod(recordPath, 0o600); + } + + const discovery = yield* make({ + platform: "linux", + xdgRuntimeDirectory: runtimeDirectory, + uid: process.getuid?.(), + probeEnvironment: (httpBaseUrl) => + httpBaseUrl === "http://127.0.0.1:3773/" + ? Effect.succeed({ + ...descriptor, + environmentId: EnvironmentId.make("another-environment"), + }) + : Effect.succeed(descriptor), + }); + + expect(yield* discovery.discover).toEqual([]); + }).pipe(Effect.provide(NodeServices.layer), TestClock.withLive), +); diff --git a/apps/desktop/src/app/DesktopLocalServerDiscovery.ts b/apps/desktop/src/app/DesktopLocalServerDiscovery.ts new file mode 100644 index 00000000000..c2b4c9cfef8 --- /dev/null +++ b/apps/desktop/src/app/DesktopLocalServerDiscovery.ts @@ -0,0 +1,202 @@ +import { fetchRemoteEnvironmentDescriptor } from "@t3tools/client-runtime/environment"; +import { + LocalServerAdvertisement, + type LocalServerAdvertisement as LocalServerAdvertisementRecord, + type ExecutionEnvironmentDescriptor, +} from "@t3tools/contracts"; +import { + isValidLocalServerPairingUrl, + LOCAL_SERVER_ADVERTISEMENT_DIRECTORY_MODE, + LOCAL_SERVER_ADVERTISEMENT_FILE_MODE, + LOCAL_SERVER_ADVERTISEMENT_MAX_BYTES, + parseCanonicalLoopbackHttpBaseUrl, + resolveLocalServerAdvertisementDirectory, +} from "@t3tools/shared/localServerDiscovery"; +import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as HttpClient from "effect/unstable/http/HttpClient"; + +const decodeAdvertisement = Schema.decodeUnknownEffect( + Schema.fromJsonString(LocalServerAdvertisement), +); + +type ProbeEnvironment = ( + httpBaseUrl: string, +) => Effect.Effect; + +export interface DesktopLocalServerDiscoveryOptions { + readonly platform: NodeJS.Platform; + readonly xdgRuntimeDirectory: string | undefined; + readonly uid: number | undefined; + readonly probeEnvironment: ProbeEnvironment; +} + +export class DesktopLocalServerDiscovery extends Context.Service< + DesktopLocalServerDiscovery, + { + readonly discover: Effect.Effect>; + } +>()("@t3tools/desktop/app/DesktopLocalServerDiscovery") {} + +function ownedWithMode(input: { + readonly actualUid: Option.Option; + readonly expectedUid: number | undefined; + readonly actualMode: number; + readonly expectedMode: number; +}): boolean { + return ( + input.expectedUid !== undefined && + Option.getOrUndefined(input.actualUid) === input.expectedUid && + (input.actualMode & 0o777) === input.expectedMode + ); +} + +function hasValidTimestamps(record: LocalServerAdvertisementRecord, nowMs: number): boolean { + const startedAtMs = Date.parse(record.startedAt); + const expiresAtMs = Date.parse(record.pairingExpiresAt); + return ( + Number.isFinite(startedAtMs) && + Number.isFinite(expiresAtMs) && + startedAtMs <= nowMs + 60_000 && + expiresAtMs > nowMs + ); +} + +export const make = Effect.fn("desktop.localServerDiscovery.make")(function* ( + options: DesktopLocalServerDiscoveryOptions, +) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const directory = resolveLocalServerAdvertisementDirectory({ + platform: options.platform, + xdgRuntimeDirectory: options.xdgRuntimeDirectory, + path, + }); + + const discover = Effect.gen(function* () { + if (directory === null) { + return []; + } + + const directoryInfo = yield* fileSystem.stat(directory).pipe(Effect.option); + if ( + Option.isNone(directoryInfo) || + directoryInfo.value.type !== "Directory" || + !ownedWithMode({ + actualUid: directoryInfo.value.uid, + expectedUid: options.uid, + actualMode: directoryInfo.value.mode, + expectedMode: LOCAL_SERVER_ADVERTISEMENT_DIRECTORY_MODE, + }) + ) { + return []; + } + + const canonicalDirectory = yield* fileSystem.realPath(directory).pipe(Effect.option); + if (Option.isNone(canonicalDirectory)) { + return []; + } + const entries = yield* fileSystem + .readDirectory(directory) + .pipe(Effect.orElseSucceed((): ReadonlyArray => [])); + const nowMs = DateTime.toEpochMillis(yield* DateTime.now); + + const discovered = yield* Effect.forEach( + entries.filter((entry) => entry.endsWith(".json")), + (entry) => + Effect.gen(function* () { + const recordPath = path.join(directory, entry); + const recordInfo = yield* fileSystem.stat(recordPath).pipe(Effect.option); + if ( + Option.isNone(recordInfo) || + recordInfo.value.type !== "File" || + Number(recordInfo.value.size) > LOCAL_SERVER_ADVERTISEMENT_MAX_BYTES || + !ownedWithMode({ + actualUid: recordInfo.value.uid, + expectedUid: options.uid, + actualMode: recordInfo.value.mode, + expectedMode: LOCAL_SERVER_ADVERTISEMENT_FILE_MODE, + }) + ) { + return null; + } + + const canonicalRecordPath = yield* fileSystem.realPath(recordPath).pipe(Effect.option); + if ( + Option.isNone(canonicalRecordPath) || + path.dirname(canonicalRecordPath.value) !== canonicalDirectory.value + ) { + return null; + } + + const raw = yield* fileSystem.readFileString(recordPath).pipe(Effect.option); + if (Option.isNone(raw)) { + return null; + } + const decoded = yield* decodeAdvertisement(raw.value).pipe(Effect.option); + if (Option.isNone(decoded) || !hasValidTimestamps(decoded.value, nowMs)) { + return null; + } + + const httpBaseUrl = parseCanonicalLoopbackHttpBaseUrl(decoded.value.httpBaseUrl); + if ( + httpBaseUrl === null || + !isValidLocalServerPairingUrl({ + pairingUrl: decoded.value.pairingUrl, + httpBaseUrl, + }) + ) { + return null; + } + + const descriptor = yield* options.probeEnvironment(decoded.value.httpBaseUrl); + if (descriptor === null || descriptor.environmentId !== decoded.value.environmentId) { + return null; + } + return { + ...decoded.value, + label: descriptor.label, + } satisfies LocalServerAdvertisementRecord; + }), + { concurrency: 4 }, + ); + + return discovered + .filter((record): record is LocalServerAdvertisementRecord => record !== null) + .toSorted( + (left, right) => + left.label.localeCompare(right.label) || left.instanceId.localeCompare(right.instanceId), + ); + }); + + return DesktopLocalServerDiscovery.of({ discover }); +}); + +export const layer = Layer.effect( + DesktopLocalServerDiscovery, + Effect.gen(function* () { + const httpClient = yield* HttpClient.HttpClient; + const platform = yield* HostProcessPlatform; + const hostEnvironment = yield* HostProcessEnvironment; + return yield* make({ + platform, + xdgRuntimeDirectory: hostEnvironment.XDG_RUNTIME_DIR, + uid: process.getuid?.(), + probeEnvironment: (httpBaseUrl) => + fetchRemoteEnvironmentDescriptor({ + httpBaseUrl, + timeoutMs: 2_000, + }).pipe( + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.orElseSucceed(() => null), + ), + }); + }), +); diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index d72a2f6226a..24c6ea3e887 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -3,6 +3,7 @@ import * as Effect from "effect/Effect"; import * as DesktopIpc from "./DesktopIpc.ts"; import { getClientSettings, setClientSettings } from "./methods/clientSettings.ts"; import { getBackendModeState, setBackendMode } from "./methods/backendMode.ts"; +import { discoverLocalServers } from "./methods/localServerDiscovery.ts"; import { clearConnectionCatalog, getConnectionCatalog, @@ -54,6 +55,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handleSync(getWindowFullscreenState); yield* ipc.handleSync(getLocalEnvironmentBootstraps); yield* ipc.handle(getLocalEnvironmentBearerToken); + yield* ipc.handle(discoverLocalServers); yield* ipc.handle(setBackendMode); yield* ipc.handle(getClientSettings); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 9557c9cd524..b78b4b06770 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -18,6 +18,7 @@ export const SET_BACKEND_MODE_CHANNEL = "desktop:set-backend-mode"; export const GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL = "desktop:get-local-environment-bootstraps"; export const GET_LOCAL_ENVIRONMENT_BEARER_TOKEN_CHANNEL = "desktop:get-local-environment-bearer-token"; +export const DISCOVER_LOCAL_SERVERS_CHANNEL = "desktop:discover-local-servers"; export const GET_CLIENT_SETTINGS_CHANNEL = "desktop:get-client-settings"; export const SET_CLIENT_SETTINGS_CHANNEL = "desktop:set-client-settings"; export const GET_CONNECTION_CATALOG_CHANNEL = "desktop:get-connection-catalog"; diff --git a/apps/desktop/src/ipc/methods/localServerDiscovery.ts b/apps/desktop/src/ipc/methods/localServerDiscovery.ts new file mode 100644 index 00000000000..0291ed84aa6 --- /dev/null +++ b/apps/desktop/src/ipc/methods/localServerDiscovery.ts @@ -0,0 +1,17 @@ +import { LocalServerAdvertisement } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Schema from "effect/Schema"; + +import * as DesktopLocalServerDiscovery from "../../app/DesktopLocalServerDiscovery.ts"; +import * as IpcChannels from "../channels.ts"; +import * as DesktopIpc from "../DesktopIpc.ts"; + +export const discoverLocalServers = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.DISCOVER_LOCAL_SERVERS_CHANNEL, + payload: Schema.Void, + result: Schema.Array(LocalServerAdvertisement), + handler: Effect.fn("desktop.ipc.localServerDiscovery.discover")(function* () { + const discovery = yield* DesktopLocalServerDiscovery.DesktopLocalServerDiscovery; + return yield* discovery.discover; + }), +}); diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 282a69c4c0e..ab7148597a1 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -34,6 +34,7 @@ import * as DesktopApp from "./app/DesktopApp.ts"; import * as DesktopBackendMode from "./app/DesktopBackendMode.ts"; import * as DesktopAppIdentity from "./app/DesktopAppIdentity.ts"; import * as DesktopConnectionCatalogStore from "./app/DesktopConnectionCatalogStore.ts"; +import * as DesktopLocalServerDiscovery from "./app/DesktopLocalServerDiscovery.ts"; import * as DesktopClerk from "./app/DesktopClerk.ts"; import * as DesktopApplicationMenu from "./window/DesktopApplicationMenu.ts"; import * as DesktopAssets from "./app/DesktopAssets.ts"; @@ -130,6 +131,7 @@ const desktopFoundationLayer = Layer.mergeAll( DesktopAppSettings.layer, DesktopClientSettings.layer, DesktopConnectionCatalogStore.layer.pipe(Layer.provideMerge(DesktopSavedEnvironments.layer)), + DesktopLocalServerDiscovery.layer, DesktopAssets.layer, DesktopObservability.layer, ).pipe(Layer.provideMerge(desktopEnvironmentLayer)); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index d444914983f..6dae0d09f8d 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -59,6 +59,7 @@ contextBridge.exposeInMainWorld("desktopBridge", { }, getLocalEnvironmentBearerToken: () => ipcRenderer.invoke(IpcChannels.GET_LOCAL_ENVIRONMENT_BEARER_TOKEN_CHANNEL), + discoverLocalServers: () => ipcRenderer.invoke(IpcChannels.DISCOVER_LOCAL_SERVERS_CHANNEL), getClientSettings: () => ipcRenderer.invoke(IpcChannels.GET_CLIENT_SETTINGS_CHANNEL), setClientSettings: (settings) => ipcRenderer.invoke(IpcChannels.SET_CLIENT_SETTINGS_CHANNEL, settings), diff --git a/apps/server/src/localServerAdvertisement.test.ts b/apps/server/src/localServerAdvertisement.test.ts new file mode 100644 index 00000000000..2d5695ca518 --- /dev/null +++ b/apps/server/src/localServerAdvertisement.test.ts @@ -0,0 +1,206 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, expect, it } from "@effect/vitest"; +import { EnvironmentId, LocalServerAdvertisement } from "@t3tools/contracts"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as TestClock from "effect/testing/TestClock"; + +import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; +import * as ServerConfig from "./config.ts"; +import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; +import { + resolveAdvertisementRefreshDelayMs, + startLocalServerAdvertisement, +} from "./localServerAdvertisement.ts"; + +const decodeRecord = Schema.decodeUnknownEffect(Schema.fromJsonString(LocalServerAdvertisement)); + +it("refreshes one minute before expiry with a bounded minimum delay", () => { + expect( + resolveAdvertisementRefreshDelayMs({ + nowMs: 1_000, + pairingExpiresAt: "1970-01-01T00:05:01.000Z", + }), + ).toBe(240_000); + expect( + resolveAdvertisementRefreshDelayMs({ + nowMs: 1_000, + pairingExpiresAt: "1970-01-01T00:00:02.000Z", + }), + ).toBe(1_000); + expect( + resolveAdvertisementRefreshDelayMs({ + nowMs: 1_000, + pairingExpiresAt: "not-a-date", + }), + ).toBe(1_000); +}); + +it.effect("publishes, rotates, and removes a private loopback advertisement", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const runtimeDirectory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-server-advertisement-test-", + }); + const baseConfig = yield* Effect.gen(function* () { + return yield* ServerConfig.ServerConfig; + }).pipe(Effect.provide(ServerConfig.layerTest(runtimeDirectory, runtimeDirectory))); + const config = ServerConfig.ServerConfig.of({ + ...baseConfig, + host: "127.0.0.1", + startupPresentation: "headless", + }); + const revokedIds: Array = []; + const issuedCredentials = [ + { + id: "discovery-initial-id", + credential: "DISCOVERY_INITIAL", + expiresAt: DateTime.makeUnsafe("1970-01-01T00:02:00.000Z"), + }, + { + id: "rotated-id", + credential: "ROTATED", + expiresAt: DateTime.makeUnsafe("1970-01-01T00:07:00.000Z"), + }, + ]; + const authLayer = Layer.mock(EnvironmentAuth.EnvironmentAuth)({ + issueStartupPairingCredential: () => Effect.sync(() => issuedCredentials.shift()!), + revokePairingLink: (id) => + Effect.sync(() => { + revokedIds.push(id); + return true; + }), + }); + const environment = ServerEnvironment.ServerEnvironment.of({ + getEnvironmentId: Effect.succeed(EnvironmentId.make("environment-local")), + getDescriptor: Effect.succeed({ + environmentId: EnvironmentId.make("environment-local"), + label: "Local server", + platform: { os: "linux", arch: "x64" }, + serverVersion: "0.0.28", + capabilities: { repositoryIdentity: true }, + }), + }); + const advertisementScope = yield* Scope.make(); + + yield* startLocalServerAdvertisement({ + terminalAccessInfo: { + pairingCredentialId: "terminal-id", + connectionString: "http://127.0.0.1:3773", + token: "TERMINAL", + pairingUrl: "http://127.0.0.1:3773/pair#token=TERMINAL", + pairingExpiresAt: "1970-01-01T00:05:00.000Z", + }, + platform: "linux", + xdgRuntimeDirectory: runtimeDirectory, + }).pipe( + Effect.provideService(ServerConfig.ServerConfig, config), + Effect.provideService(ServerEnvironment.ServerEnvironment, environment), + Effect.provide(authLayer), + Scope.provide(advertisementScope), + ); + yield* Effect.sleep("20 millis").pipe(TestClock.withLive); + + const directory = path.join(runtimeDirectory, "t3code", "servers"); + const entries = yield* fileSystem.readDirectory(directory); + assert.strictEqual(entries.length, 1); + const recordPath = path.join(directory, entries[0]!); + expect((yield* fileSystem.stat(directory)).mode & 0o777).toBe(0o700); + expect((yield* fileSystem.stat(recordPath)).mode & 0o777).toBe(0o600); + expect( + (yield* decodeRecord(yield* fileSystem.readFileString(recordPath))).pairingUrl, + ).toContain("token=DISCOVERY_INITIAL"); + + yield* TestClock.adjust("1 minute"); + yield* Effect.sleep("20 millis").pipe(TestClock.withLive); + expect( + (yield* decodeRecord(yield* fileSystem.readFileString(recordPath))).pairingUrl, + ).toContain("token=ROTATED"); + expect(revokedIds).toContain("discovery-initial-id"); + expect(revokedIds).not.toContain("terminal-id"); + + yield* Scope.close(advertisementScope, Exit.void); + expect(yield* fileSystem.exists(recordPath)).toBe(false); + expect(revokedIds).toContain("rotated-id"); + expect(revokedIds).not.toContain("terminal-id"); + }).pipe(Effect.provide(NodeServices.layer)), +); + +it.effect("captures revoke defects and interruptions during cleanup", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const runtimeDirectory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-server-advertisement-revoke-test-", + }); + const baseConfig = yield* Effect.gen(function* () { + return yield* ServerConfig.ServerConfig; + }).pipe(Effect.provide(ServerConfig.layerTest(runtimeDirectory, runtimeDirectory))); + const config = ServerConfig.ServerConfig.of({ + ...baseConfig, + host: "127.0.0.1", + startupPresentation: "headless", + }); + const environment = ServerEnvironment.ServerEnvironment.of({ + getEnvironmentId: Effect.succeed(EnvironmentId.make("environment-local")), + getDescriptor: Effect.succeed({ + environmentId: EnvironmentId.make("environment-local"), + label: "Local server", + platform: { os: "linux", arch: "x64" }, + serverVersion: "0.0.28", + capabilities: { repositoryIdentity: true }, + }), + }); + const revokeFailures = [ + ["defect", Effect.die("revoke defect")], + ["interruption", Effect.interrupt], + ] as const; + + for (const [failureKind, revokeFailure] of revokeFailures) { + const authLayer = Layer.mock(EnvironmentAuth.EnvironmentAuth)({ + issueStartupPairingCredential: () => + Effect.succeed({ + id: `${failureKind}-id`, + credential: failureKind.toUpperCase(), + expiresAt: DateTime.makeUnsafe("2099-01-01T00:00:00.000Z"), + }), + revokePairingLink: () => revokeFailure, + }); + const advertisementScope = yield* Scope.make(); + + yield* startLocalServerAdvertisement({ + terminalAccessInfo: { + pairingCredentialId: "terminal-id", + connectionString: "http://127.0.0.1:3773", + token: "TERMINAL", + pairingUrl: "http://127.0.0.1:3773/pair#token=TERMINAL", + pairingExpiresAt: "2099-01-01T00:00:00.000Z", + }, + platform: "linux", + xdgRuntimeDirectory: runtimeDirectory, + }).pipe( + Effect.provideService(ServerConfig.ServerConfig, config), + Effect.provideService(ServerEnvironment.ServerEnvironment, environment), + Effect.provide(authLayer), + Scope.provide(advertisementScope), + ); + yield* Effect.sleep("20 millis").pipe(TestClock.withLive); + + const directory = path.join(runtimeDirectory, "t3code", "servers"); + const entries = yield* fileSystem.readDirectory(directory); + assert.strictEqual(entries.length, 1); + const recordPath = path.join(directory, entries[0]!); + const closeExit = yield* Effect.exit(Scope.close(advertisementScope, Exit.void)); + + expect(Exit.isSuccess(closeExit), failureKind).toBe(true); + expect(yield* fileSystem.exists(recordPath)).toBe(false); + } + }).pipe(Effect.provide(NodeServices.layer)), +); diff --git a/apps/server/src/localServerAdvertisement.ts b/apps/server/src/localServerAdvertisement.ts new file mode 100644 index 00000000000..e3f7eb29f5a --- /dev/null +++ b/apps/server/src/localServerAdvertisement.ts @@ -0,0 +1,226 @@ +import { + LocalServerAdvertisement, + type LocalServerAdvertisement as LocalServerAdvertisementRecord, +} from "@t3tools/contracts"; +import { + LOCAL_SERVER_ADVERTISEMENT_DIRECTORY_MODE, + LOCAL_SERVER_ADVERTISEMENT_FILE_MODE, + resolveLocalServerAdvertisementDirectory, +} from "@t3tools/shared/localServerDiscovery"; +import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; +import * as Path from "effect/Path"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; + +import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; +import * as ServerConfig from "./config.ts"; +import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; +import { + buildPairingUrl, + resolveLocalAdvertisementHttpBaseUrl, + type HeadlessServeAccessInfo, +} from "./startupAccess.ts"; + +const REFRESH_MARGIN_MS = 60_000; +const MINIMUM_REFRESH_DELAY_MS = 1_000; +// A failed rotation leaves `currentAccessInfo` pointing at an already-expiring +// credential, so the next refresh delay collapses to the minimum. Back off on +// an explicit interval instead of retrying once a second for the life of the +// process. +const FAILED_ROTATION_RETRY_DELAY_MS = 30_000; + +export function resolveAdvertisementRefreshDelayMs(input: { + readonly nowMs: number; + readonly pairingExpiresAt: string; +}): number { + const expiresAtMs = Date.parse(input.pairingExpiresAt); + if (!Number.isFinite(expiresAtMs)) { + return MINIMUM_REFRESH_DELAY_MS; + } + return Math.max(MINIMUM_REFRESH_DELAY_MS, expiresAtMs - input.nowMs - REFRESH_MARGIN_MS); +} + +const encodeAdvertisement = Schema.encodeUnknownEffect( + Schema.fromJsonString(LocalServerAdvertisement), +); + +const writeAdvertisement = Effect.fn("server.localAdvertisement.write")(function* (input: { + readonly directory: string; + readonly recordPath: string; + readonly tempPath: string; + readonly record: LocalServerAdvertisementRecord; +}) { + const fileSystem = yield* FileSystem.FileSystem; + yield* fileSystem.makeDirectory(input.directory, { + recursive: true, + mode: LOCAL_SERVER_ADVERTISEMENT_DIRECTORY_MODE, + }); + yield* fileSystem.chmod(input.directory, LOCAL_SERVER_ADVERTISEMENT_DIRECTORY_MODE); + const encoded = yield* encodeAdvertisement(input.record); + yield* Effect.gen(function* () { + yield* fileSystem.writeFileString(input.tempPath, `${encoded}\n`, { + mode: LOCAL_SERVER_ADVERTISEMENT_FILE_MODE, + }); + yield* fileSystem.chmod(input.tempPath, LOCAL_SERVER_ADVERTISEMENT_FILE_MODE); + yield* fileSystem.rename(input.tempPath, input.recordPath); + yield* fileSystem.chmod(input.recordPath, LOCAL_SERVER_ADVERTISEMENT_FILE_MODE); + }).pipe(Effect.ensuring(fileSystem.remove(input.tempPath, { force: true }).pipe(Effect.ignore))); +}); + +export const startLocalServerAdvertisement = Effect.fn("server.localAdvertisement.start")( + function* (input: { + readonly terminalAccessInfo: HeadlessServeAccessInfo; + readonly platform?: NodeJS.Platform; + readonly xdgRuntimeDirectory?: string; + }) { + const serverConfig = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const crypto = yield* Crypto.Crypto; + const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; + const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; + const hostPlatform = yield* HostProcessPlatform; + const hostEnvironment = yield* HostProcessEnvironment; + const platform = input.platform ?? hostPlatform; + const xdgRuntimeDirectory = input.xdgRuntimeDirectory ?? hostEnvironment.XDG_RUNTIME_DIR; + const directory = resolveLocalServerAdvertisementDirectory({ + platform, + xdgRuntimeDirectory, + path, + }); + const port = Number(new URL(input.terminalAccessInfo.connectionString).port); + const httpBaseUrl = resolveLocalAdvertisementHttpBaseUrl(serverConfig.host, port); + if ( + directory === null || + httpBaseUrl === null || + serverConfig.startupPresentation !== "headless" + ) { + return; + } + + const instanceId = yield* crypto.randomUUIDv4; + const startedAt = DateTime.formatIso(yield* DateTime.now); + const environment = yield* serverEnvironment.getDescriptor; + const recordPath = path.join(directory, `${instanceId}.json`); + const tempPath = path.join(directory, `.${instanceId}.${process.pid}.tmp`); + + const publish = Effect.fn("server.localAdvertisement.publish")(function* ( + accessInfo: HeadlessServeAccessInfo, + ) { + yield* writeAdvertisement({ + directory, + recordPath, + tempPath, + record: { + version: 1, + instanceId, + pid: process.pid, + startedAt, + httpBaseUrl, + pairingUrl: buildPairingUrl(httpBaseUrl, accessInfo.token), + pairingExpiresAt: accessInfo.pairingExpiresAt, + environmentId: environment.environmentId, + label: environment.label, + }, + }); + }); + + const revoke = Effect.fn("server.localAdvertisement.revoke")(function* ( + accessInfo: HeadlessServeAccessInfo, + ) { + const revokeExit = yield* Effect.exit( + serverAuth.revokePairingLink(accessInfo.pairingCredentialId), + ); + if (Exit.isFailure(revokeExit)) { + yield* Effect.logWarning("Could not revoke a local discovery pairing credential.", { + pairingCredentialId: accessInfo.pairingCredentialId, + cause: revokeExit.cause, + }); + } + }); + + const issueAccessInfo = serverAuth.issueStartupPairingCredential().pipe( + Effect.map( + (issued): HeadlessServeAccessInfo => ({ + pairingCredentialId: issued.id, + connectionString: input.terminalAccessInfo.connectionString, + token: issued.credential, + pairingUrl: buildPairingUrl(input.terminalAccessInfo.connectionString, issued.credential), + pairingExpiresAt: DateTime.formatIso(issued.expiresAt), + }), + ), + ); + + const initialAccessInfoExit = yield* Effect.exit(issueAccessInfo); + if (Exit.isFailure(initialAccessInfoExit)) { + yield* Effect.logWarning("Local T3 Code server discovery is unavailable.", { + recordPath, + cause: initialAccessInfoExit.cause, + }); + return; + } + const initialAccessInfo = initialAccessInfoExit.value; + const currentAccessInfo = yield* Ref.make(initialAccessInfo); + + const cleanup = Effect.gen(function* () { + yield* fileSystem.remove(recordPath, { force: true }).pipe(Effect.ignore); + yield* fileSystem.remove(tempPath, { force: true }).pipe(Effect.ignore); + yield* revoke(yield* Ref.get(currentAccessInfo)); + }); + + const initialPublish = yield* Effect.exit(publish(initialAccessInfo)); + if (Exit.isFailure(initialPublish)) { + yield* Effect.logWarning("Local T3 Code server discovery is unavailable.", { + recordPath, + cause: initialPublish.cause, + }); + yield* revoke(initialAccessInfo); + return; + } + + const rotate = Effect.forever( + Effect.gen(function* () { + const current = yield* Ref.get(currentAccessInfo); + const now = yield* DateTime.now; + yield* Effect.sleep( + Duration.millis( + resolveAdvertisementRefreshDelayMs({ + nowMs: DateTime.toEpochMillis(now), + pairingExpiresAt: current.pairingExpiresAt, + }), + ), + ); + const nextExit = yield* Effect.exit(issueAccessInfo); + if (Exit.isFailure(nextExit)) { + yield* Effect.logWarning("Could not rotate a local discovery pairing credential.", { + cause: nextExit.cause, + }); + yield* Effect.sleep(Duration.millis(FAILED_ROTATION_RETRY_DELAY_MS)); + return; + } + + const next = nextExit.value; + const publishExit = yield* Effect.exit(publish(next)); + if (Exit.isFailure(publishExit)) { + yield* Effect.logWarning("Could not refresh the local server discovery record.", { + recordPath, + cause: publishExit.cause, + }); + yield* revoke(next); + yield* Effect.sleep(Duration.millis(FAILED_ROTATION_RETRY_DELAY_MS)); + return; + } + yield* Ref.set(currentAccessInfo, next); + yield* revoke(current); + }), + ); + + yield* Effect.forkScoped(rotate.pipe(Effect.ensuring(cleanup))); + }, +); diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index b52b577c5b5..932eacf77d8 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -33,6 +33,7 @@ import * as ServerSettings from "./serverSettings.ts"; import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; +import { startLocalServerAdvertisement } from "./localServerAdvertisement.ts"; import * as ProviderSessionReaper from "./provider/Services/ProviderSessionReaper.ts"; import { formatHeadlessServeOutput, @@ -450,6 +451,9 @@ export const make = Effect.gen(function* () { if (serverConfig.startupPresentation === "headless") { yield* Effect.logDebug("startup phase: headless access info"); const accessInfo = yield* issueHeadlessServeAccessInfo(); + yield* startLocalServerAdvertisement({ + terminalAccessInfo: accessInfo, + }); yield* runStartupPhase( "headless.output", Console.log(formatHeadlessServeOutput(accessInfo)), diff --git a/apps/server/src/startupAccess.test.ts b/apps/server/src/startupAccess.test.ts index 03c01170f15..f40e602d4a7 100644 --- a/apps/server/src/startupAccess.test.ts +++ b/apps/server/src/startupAccess.test.ts @@ -6,6 +6,7 @@ import { renderTerminalQrCode, resolveHeadlessConnectionHost, resolveHeadlessConnectionString, + resolveLocalAdvertisementHttpBaseUrl, resolveListeningPort, } from "./startupAccess.ts"; @@ -58,6 +59,14 @@ it("builds a pairing URL that embeds the token in the hash", () => { ); }); +it("resolves canonical loopback advertisement URLs only for loopback listeners", () => { + expect(resolveLocalAdvertisementHttpBaseUrl(undefined, 3773)).toBe("http://127.0.0.1:3773/"); + expect(resolveLocalAdvertisementHttpBaseUrl("localhost", 3773)).toBe("http://127.0.0.1:3773/"); + expect(resolveLocalAdvertisementHttpBaseUrl("::1", 3773)).toBe("http://[::1]:3773/"); + expect(resolveLocalAdvertisementHttpBaseUrl("0.0.0.0", 3773)).toBeNull(); + expect(resolveLocalAdvertisementHttpBaseUrl("192.168.1.42", 3773)).toBeNull(); +}); + it("renders terminal QR codes as a multi-line unicode block grid", () => { const qrCode = renderTerminalQrCode("http://192.168.1.42:3773/pair#token=PAIRCODE"); @@ -67,9 +76,11 @@ it("renders terminal QR codes as a multi-line unicode block grid", () => { it("formats headless serve output with the connection string, token, pairing url, and qr code", () => { const output = formatHeadlessServeOutput({ + pairingCredentialId: "pairing-id", connectionString: "http://192.168.1.42:3773", token: "PAIRCODE", pairingUrl: "http://192.168.1.42:3773/pair#token=PAIRCODE", + pairingExpiresAt: "2026-01-01T00:05:00.000Z", }); expect(output).toContain("Connection string: http://192.168.1.42:3773"); diff --git a/apps/server/src/startupAccess.ts b/apps/server/src/startupAccess.ts index 7df131669ba..1a2c5040e27 100644 --- a/apps/server/src/startupAccess.ts +++ b/apps/server/src/startupAccess.ts @@ -1,6 +1,7 @@ import * as NodeOS from "node:os"; import { QrCode } from "@t3tools/shared/qrCode"; +import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import { HttpServer } from "effect/unstable/http"; @@ -8,9 +9,11 @@ import { ServerConfig } from "./config.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; export interface HeadlessServeAccessInfo { + readonly pairingCredentialId: string; readonly connectionString: string; readonly token: string; readonly pairingUrl: string; + readonly pairingExpiresAt: string; } type NetworkInterfacesMap = ReturnType; @@ -97,6 +100,18 @@ export const buildPairingUrl = (connectionString: string, token: string): string return url.toString(); }; +export const resolveLocalAdvertisementHttpBaseUrl = ( + host: string | undefined, + port: number, +): string | null => { + if (!isLoopbackHost(host)) { + return null; + } + const normalized = host ? normalizeHost(host) : "127.0.0.1"; + const canonicalHost = normalized === "localhost" ? "127.0.0.1" : formatHostForUrl(normalized); + return `http://${canonicalHost}:${port}/`; +}; + export const renderTerminalQrCode = (value: string, margin = 2): string => { const qrCode = QrCode.encodeText(value, QrCode.Ecc.MEDIUM); const rows: Array = []; @@ -141,8 +156,10 @@ export const issueHeadlessServeAccessInfo = Effect.fn("issueHeadlessServeAccessI const issued = yield* serverAuth.issueStartupPairingCredential(); return { + pairingCredentialId: issued.id, connectionString, token: issued.credential, pairingUrl: buildPairingUrl(connectionString, issued.credential), + pairingExpiresAt: DateTime.formatIso(issued.expiresAt), } satisfies HeadlessServeAccessInfo; }); diff --git a/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts b/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts index c8f039e6428..f4428827523 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts +++ b/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts @@ -1,6 +1,13 @@ -import type { DesktopWslState } from "@t3tools/contracts"; +import { + EnvironmentId, + type DesktopWslState, + type LocalServerAdvertisement, +} from "@t3tools/contracts"; import { describe, expect, it, vi } from "vite-plus/test"; -import { applyWslEnableSelection } from "./ConnectionsSettings.logic"; +import { + applyWslEnableSelection, + selectLocalServerPairingCandidates, +} from "./ConnectionsSettings.logic"; const baseWslState: DesktopWslState = { enabled: false, @@ -73,3 +80,48 @@ describe("applyWslEnableSelection", () => { expect(state).toMatchObject({ enabled: true, wslOnly: true }); }); }); + +describe("selectLocalServerPairingCandidates", () => { + const advertisement = { + version: 1, + instanceId: "instance-local", + pid: 1234, + startedAt: "2026-01-01T00:00:00.000Z", + httpBaseUrl: "http://127.0.0.1:3773/", + pairingUrl: "http://127.0.0.1:3773/pair#token=PAIRCODE", + pairingExpiresAt: "2026-01-01T00:05:00.000Z", + environmentId: EnvironmentId.make("environment-local"), + label: "Local server", + } satisfies LocalServerAdvertisement; + + it("offers unsaved advertisements as explicit Pair actions", () => { + expect(selectLocalServerPairingCandidates([advertisement], [])).toEqual([ + { advertisement, pairAgain: false }, + ]); + }); + + it("suppresses usable saved environments and offers Pair again for failed credentials", () => { + expect( + selectLocalServerPairingCandidates( + [advertisement], + [ + { + environmentId: advertisement.environmentId, + connection: { phase: "connected" }, + }, + ], + ), + ).toEqual([]); + expect( + selectLocalServerPairingCandidates( + [advertisement], + [ + { + environmentId: advertisement.environmentId, + connection: { phase: "error" }, + }, + ], + ), + ).toEqual([{ advertisement, pairAgain: true }]); + }); +}); diff --git a/apps/web/src/components/settings/ConnectionsSettings.logic.ts b/apps/web/src/components/settings/ConnectionsSettings.logic.ts index 362a24dd3ff..da9b782b015 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.logic.ts +++ b/apps/web/src/components/settings/ConnectionsSettings.logic.ts @@ -1,7 +1,40 @@ -import type { DesktopBridge, DesktopWslState } from "@t3tools/contracts"; +import type { + DesktopBridge, + DesktopWslState, + EnvironmentId, + LocalServerAdvertisement, +} from "@t3tools/contracts"; type WslEnableBridge = Pick; +export interface LocalServerPairingCandidate { + readonly advertisement: LocalServerAdvertisement; + readonly pairAgain: boolean; +} + +export function selectLocalServerPairingCandidates( + advertisements: ReadonlyArray, + environments: ReadonlyArray<{ + readonly environmentId: EnvironmentId; + readonly connection: { readonly phase: string }; + }>, +): ReadonlyArray { + return advertisements.flatMap((advertisement) => { + const savedEnvironment = environments.find( + (environment) => environment.environmentId === advertisement.environmentId, + ); + if (savedEnvironment && savedEnvironment.connection.phase !== "error") { + return []; + } + return [ + { + advertisement, + pairAgain: savedEnvironment !== undefined, + }, + ]; + }); +} + export async function applyWslEnableSelection(input: { readonly bridge: WslEnableBridge; readonly mode: "both" | "wsl-only"; diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 03f95707581..c8994d4c20a 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -7,7 +7,7 @@ import { TerminalIcon, TriangleAlertIcon, } from "lucide-react"; -import { type ReactNode, memo, useCallback, useMemo, useState } from "react"; +import { type ReactNode, memo, useCallback, useEffect, useMemo, useState } from "react"; import { AuthAccessReadScope, AuthAccessWriteScope, @@ -26,6 +26,7 @@ import { type DesktopDiscoveredSshHost, type DesktopBackendMode, type DesktopBackendModeState, + type LocalServerAdvertisement, type DesktopSshEnvironmentTarget, type DesktopServerExposureState, type DesktopWslState, @@ -43,7 +44,10 @@ import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; import { cn } from "../../lib/utils"; import { formatElapsedDurationLabel, formatExpiresInLabel } from "../../timestampFormat"; import { resolveDesktopPairingUrl, resolveHostedPairingUrl } from "./pairingUrls"; -import { applyWslEnableSelection } from "./ConnectionsSettings.logic"; +import { + applyWslEnableSelection, + selectLocalServerPairingCandidates, +} from "./ConnectionsSettings.logic"; import { SettingsPageContainer, SettingsRow, @@ -141,6 +145,7 @@ import { ITEM_ROW_CLASSNAME, ITEM_ROW_INNER_CLASSNAME } from "./itemRows"; const DEFAULT_TAILSCALE_SERVE_PORT = 443; const EMPTY_ADVERTISED_ENDPOINTS: ReadonlyArray = []; const EMPTY_DISCOVERED_SSH_HOSTS: ReadonlyArray = []; +const EMPTY_LOCAL_SERVER_ADVERTISEMENTS: ReadonlyArray = []; // Sentinels for the consolidated WSL backend picker. The colon is // rejected by DISTRO_NAME_PATTERN (validated on the desktop side) so @@ -1717,6 +1722,14 @@ export function ConnectionsSettings() { useState(() => desktopBridge?.getBackendModeState?.() ?? null); const [desktopBackendModeError, setDesktopBackendModeError] = useState(null); const [isUpdatingDesktopBackendMode, setIsUpdatingDesktopBackendMode] = useState(false); + const [localServerAdvertisements, setLocalServerAdvertisements] = useState< + ReadonlyArray + >(EMPTY_LOCAL_SERVER_ADVERTISEMENTS); + const [isDiscoveringLocalServers, setIsDiscoveringLocalServers] = useState(false); + const [pairingLocalServerInstanceId, setPairingLocalServerInstanceId] = useState( + null, + ); + const [localServerDiscoveryError, setLocalServerDiscoveryError] = useState(null); const { environments } = useEnvironments(); const primaryEnvironment = usePrimaryEnvironment(); const connectPairing = useAtomCommand(connectPairingAtom, { reportFailure: false }); @@ -1744,6 +1757,92 @@ export function ConnectionsSettings() { (environment) => !isDesktopLocalConnectionTarget(environment.entry.target), ); const isClientOnlyDesktop = desktopBackendModeState?.effectiveMode === "client-only"; + const localServerPairingCandidates = useMemo( + () => selectLocalServerPairingCandidates(localServerAdvertisements, environments), + [environments, localServerAdvertisements], + ); + const refreshLocalServerAdvertisements = useCallback(async () => { + const discoverLocalServers = desktopBridge?.discoverLocalServers; + if (!discoverLocalServers) { + setLocalServerAdvertisements(EMPTY_LOCAL_SERVER_ADVERTISEMENTS); + return EMPTY_LOCAL_SERVER_ADVERTISEMENTS; + } + setIsDiscoveringLocalServers(true); + setLocalServerDiscoveryError(null); + try { + const discovered = await discoverLocalServers(); + setLocalServerAdvertisements(discovered); + setIsDiscoveringLocalServers(false); + return discovered; + } catch (error) { + setLocalServerAdvertisements(EMPTY_LOCAL_SERVER_ADVERTISEMENTS); + setLocalServerDiscoveryError( + error instanceof Error ? error.message : "Could not scan for local T3 Code servers.", + ); + setIsDiscoveringLocalServers(false); + return EMPTY_LOCAL_SERVER_ADVERTISEMENTS; + } + }, [desktopBridge]); + + useEffect(() => { + void refreshLocalServerAdvertisements(); + }, [refreshLocalServerAdvertisements]); + + const handlePairLocalServer = useCallback( + async (advertisement: LocalServerAdvertisement, pairAgain: boolean) => { + const discoverLocalServers = desktopBridge?.discoverLocalServers; + if (!discoverLocalServers) return; + setPairingLocalServerInstanceId(advertisement.instanceId); + setLocalServerDiscoveryError(null); + try { + // Re-read immediately before the explicit pairing action so a rotated + // one-time credential is used instead of a stale UI snapshot. + const currentAdvertisements = await discoverLocalServers(); + setLocalServerAdvertisements(currentAdvertisements); + const current = currentAdvertisements.find( + (candidate) => + candidate.instanceId === advertisement.instanceId && + candidate.environmentId === advertisement.environmentId, + ); + if (!current) { + throw new Error("This local server is no longer advertising a valid pairing link."); + } + + const result = await connectPairing({ pairingUrl: current.pairingUrl }); + if (result._tag === "Failure") { + if (isAtomCommandInterrupted(result)) { + setPairingLocalServerInstanceId(null); + return; + } + throw squashAtomCommandFailure(result); + } + + setLocalServerAdvertisements((previous) => + previous.filter((candidate) => candidate.instanceId !== advertisement.instanceId), + ); + setAddBackendDialogOpen(false); + toastManager.add({ + type: "success", + title: pairAgain ? "Environment paired again" : "Environment paired", + description: `${current.label} is saved and will reconnect on app startup.`, + }); + } catch (error) { + const message = + error instanceof Error ? error.message : "Could not pair the local T3 Code server."; + setLocalServerDiscoveryError(message); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not pair local server", + description: message, + }), + ); + } finally { + setPairingLocalServerInstanceId(null); + } + }, + [connectPairing, desktopBridge], + ); const savedDesktopSshEnvironmentsByAlias = useMemo( () => savedEnvironments.reduce>( @@ -2443,6 +2542,42 @@ export function ConnectionsSettings() { ); + const renderLocalServerPairingCandidates = () => ( +
+ {localServerPairingCandidates.map(({ advertisement, pairAgain }) => ( +
+ + + + + + {advertisement.label} + + + {advertisement.httpBaseUrl} · process {advertisement.pid} + + + +
+ ))} + {localServerDiscoveryError ? ( +

{localServerDiscoveryError}

+ ) : null} +
+ ); const renderSshFields = () => (
@@ -2994,6 +3129,7 @@ export function ConnectionsSettings() { "Pair and save an environment before switching to client-only mode.", ); setAddBackendDialogOpen(true); + void refreshLocalServerAdvertisements(); return; } @@ -3405,6 +3541,33 @@ export function ConnectionsSettings() { )} + {localServerPairingCandidates.length > 0 ? ( + void refreshLocalServerAdvertisements()} + > + {isDiscoveringLocalServers ? ( + + ) : ( + + )} + Scan again + + } + > +

+ These loopback servers advertised a private, short-lived pairing link. Pairing saves the + connection; this desktop will not manage the server process. +

+ {renderLocalServerPairingCandidates()} +
+ ) : null} + { setAddBackendDialogOpen(open); + if (open) { + void refreshLocalServerAdvertisements(); + } if (!open) { setSavedBackendError(null); } @@ -3444,6 +3610,19 @@ export function ConnectionsSettings() {
+ {localServerPairingCandidates.length > 0 ? ( +
+
+

+ A local T3 Code server is running +

+

+ Pair explicitly to save it without copying its URL. +

+
+ {renderLocalServerPairingCandidates()} +
+ ) : null}
{renderConnectionModeCard({ mode: "remote", diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 5936cb0fc17..61764a8ae27 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -2,6 +2,7 @@ export * from "./baseSchemas.ts"; export * from "./auth.ts"; export * from "./environment.ts"; export * from "./environmentHttp.ts"; +export * from "./localServerDiscovery.ts"; export * from "./relayClient.ts"; export * from "./desktopBootstrap.ts"; export * from "./remoteAccess.ts"; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index b8323be6814..ee3a83b5751 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -19,6 +19,7 @@ import type { VcsStatusResult, } from "./git.ts"; import type { ReviewDiffPreviewInput, ReviewDiffPreviewResult } from "./review.ts"; +import type { LocalServerAdvertisement } from "./localServerDiscovery.ts"; import type { FilesystemBrowseInput, FilesystemBrowseResult } from "./filesystem.ts"; import type { AssetCreateUrlInput, AssetCreateUrlResult } from "./assets.ts"; import type { @@ -995,6 +996,7 @@ export interface DesktopBridge { // The primary backend is identified by id === PRIMARY_LOCAL_ENVIRONMENT_ID. getLocalEnvironmentBootstraps: () => readonly DesktopEnvironmentBootstrap[]; getLocalEnvironmentBearerToken: () => Promise; + discoverLocalServers?: () => Promise; getClientSettings: () => Promise; setClientSettings: (settings: ClientSettings) => Promise; getConnectionCatalog?: () => Promise; diff --git a/packages/contracts/src/localServerDiscovery.ts b/packages/contracts/src/localServerDiscovery.ts new file mode 100644 index 00000000000..4c45e1e7abe --- /dev/null +++ b/packages/contracts/src/localServerDiscovery.ts @@ -0,0 +1,24 @@ +import * as Schema from "effect/Schema"; + +import { EnvironmentId, PositiveInt, TrimmedNonEmptyString } from "./baseSchemas.ts"; + +export const LocalServerAdvertisementVersion = Schema.Literal(1); +export type LocalServerAdvertisementVersion = typeof LocalServerAdvertisementVersion.Type; + +/** + * Private, short-lived handoff record published by a loopback-only `t3 serve` + * process. The pairing URL contains a one-time credential; this is runtime + * discovery state, never a steady-state credential store. + */ +export const LocalServerAdvertisement = Schema.Struct({ + version: LocalServerAdvertisementVersion, + instanceId: TrimmedNonEmptyString, + pid: PositiveInt, + startedAt: TrimmedNonEmptyString, + httpBaseUrl: TrimmedNonEmptyString, + pairingUrl: TrimmedNonEmptyString, + pairingExpiresAt: TrimmedNonEmptyString, + environmentId: EnvironmentId, + label: TrimmedNonEmptyString, +}); +export type LocalServerAdvertisement = typeof LocalServerAdvertisement.Type; diff --git a/packages/shared/package.json b/packages/shared/package.json index 7d0cc5eb820..37394dc5d0e 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -194,6 +194,10 @@ "./httpReadiness": { "types": "./src/httpReadiness.ts", "import": "./src/httpReadiness.ts" + }, + "./localServerDiscovery": { + "types": "./src/localServerDiscovery.ts", + "import": "./src/localServerDiscovery.ts" } }, "scripts": { diff --git a/packages/shared/src/localServerDiscovery.test.ts b/packages/shared/src/localServerDiscovery.test.ts new file mode 100644 index 00000000000..2460ed52f28 --- /dev/null +++ b/packages/shared/src/localServerDiscovery.test.ts @@ -0,0 +1,67 @@ +import { expect, it } from "@effect/vitest"; + +import { + isCanonicalLoopbackHostname, + isValidLocalServerPairingUrl, + parseCanonicalLoopbackHttpBaseUrl, +} from "./localServerDiscovery.ts"; + +it("accepts canonical IPv4 and IPv6 loopback hosts", () => { + expect(isCanonicalLoopbackHostname("127.0.0.1")).toBe(true); + expect(isCanonicalLoopbackHostname("127.12.34.56")).toBe(true); + expect(isCanonicalLoopbackHostname("::1")).toBe(true); + expect(isCanonicalLoopbackHostname("localhost")).toBe(false); + expect(isCanonicalLoopbackHostname("127.00.0.1")).toBe(false); + expect(isCanonicalLoopbackHostname("192.168.1.2")).toBe(false); +}); + +it("accepts only normalized loopback HTTP base URLs", () => { + expect(parseCanonicalLoopbackHttpBaseUrl("http://127.0.0.1:3773/")?.origin).toBe( + "http://127.0.0.1:3773", + ); + expect(parseCanonicalLoopbackHttpBaseUrl("http://[::1]:3773/")?.hostname).toBe("[::1]"); + expect(parseCanonicalLoopbackHttpBaseUrl("http://localhost:3773/")).toBeNull(); + expect(parseCanonicalLoopbackHttpBaseUrl("https://127.0.0.1:3773/")).toBeNull(); + expect(parseCanonicalLoopbackHttpBaseUrl("http://127.0.0.1:3773/path")).toBeNull(); + expect(parseCanonicalLoopbackHttpBaseUrl("http://127.0.0.1/")).toBeNull(); +}); + +it("requires a same-origin pairing URL with a non-empty fragment token", () => { + const httpBaseUrl = new URL("http://127.0.0.1:3773/"); + expect( + isValidLocalServerPairingUrl({ + httpBaseUrl, + pairingUrl: "http://127.0.0.1:3773/pair#token=PAIRCODE", + }), + ).toBe(true); + expect( + isValidLocalServerPairingUrl({ + httpBaseUrl, + pairingUrl: "http://127.0.0.1:3774/pair#token=PAIRCODE", + }), + ).toBe(false); + expect( + isValidLocalServerPairingUrl({ + httpBaseUrl, + pairingUrl: "http://127.0.0.1:3773/pair", + }), + ).toBe(false); +}); + +it("rejects pairing URLs that could retarget pairing at another host", () => { + const httpBaseUrl = new URL("http://127.0.0.1:3773/"); + // `readHostedPairingRequest` honours a `host` query parameter, so a query + // string on a "loopback" link would send the credential somewhere else. + expect( + isValidLocalServerPairingUrl({ + httpBaseUrl, + pairingUrl: "http://127.0.0.1:3773/pair?host=https://evil.example#token=PAIRCODE", + }), + ).toBe(false); + expect( + isValidLocalServerPairingUrl({ + httpBaseUrl, + pairingUrl: "http://user:pw@127.0.0.1:3773/pair#token=PAIRCODE", + }), + ).toBe(false); +}); diff --git a/packages/shared/src/localServerDiscovery.ts b/packages/shared/src/localServerDiscovery.ts new file mode 100644 index 00000000000..1b778ce55d6 --- /dev/null +++ b/packages/shared/src/localServerDiscovery.ts @@ -0,0 +1,87 @@ +import type * as Path from "effect/Path"; + +export const LOCAL_SERVER_ADVERTISEMENT_DIRECTORY_PARTS = ["t3code", "servers"] as const; +export const LOCAL_SERVER_ADVERTISEMENT_DIRECTORY_MODE = 0o700; +export const LOCAL_SERVER_ADVERTISEMENT_FILE_MODE = 0o600; +export const LOCAL_SERVER_ADVERTISEMENT_MAX_BYTES = 64 * 1024; + +export function resolveLocalServerAdvertisementDirectory(input: { + readonly platform: NodeJS.Platform; + readonly xdgRuntimeDirectory: string | undefined; + readonly path: Path.Path; +}): string | null { + if (input.platform !== "linux") { + return null; + } + const runtimeDirectory = input.xdgRuntimeDirectory?.trim(); + if (!runtimeDirectory || !input.path.isAbsolute(runtimeDirectory)) { + return null; + } + return input.path.join(runtimeDirectory, ...LOCAL_SERVER_ADVERTISEMENT_DIRECTORY_PARTS); +} + +export function isCanonicalLoopbackHostname(hostname: string): boolean { + // `URL.hostname` keeps the brackets on IPv6 hosts, so "[::1]" is the live + // branch and the bare "::1" form is only accepted for direct callers. + if (hostname === "::1" || hostname === "[::1]") { + return true; + } + const octets = hostname.split("."); + if (octets.length !== 4 || octets[0] !== "127") { + return false; + } + return octets.every((octet) => { + if (!/^\d{1,3}$/.test(octet)) { + return false; + } + const value = Number(octet); + return value >= 0 && value <= 255 && String(value) === octet; + }); +} + +export function parseCanonicalLoopbackHttpBaseUrl(value: string): URL | null { + let url: URL; + try { + url = new URL(value); + } catch { + return null; + } + if ( + url.protocol !== "http:" || + !isCanonicalLoopbackHostname(url.hostname) || + url.username !== "" || + url.password !== "" || + url.port === "" || + url.search !== "" || + url.hash !== "" || + url.pathname !== "/" + ) { + return null; + } + return url; +} + +export function isValidLocalServerPairingUrl(input: { + readonly pairingUrl: string; + readonly httpBaseUrl: URL; +}): boolean { + let pairingUrl: URL; + try { + pairingUrl = new URL(input.pairingUrl); + } catch { + return false; + } + const token = new URLSearchParams(pairingUrl.hash.slice(1)).get("token")?.trim(); + return ( + pairingUrl.origin === input.httpBaseUrl.origin && + pairingUrl.username === "" && + pairingUrl.password === "" && + pairingUrl.pathname === "/pair" && + // Security check, not tidiness: `readHostedPairingRequest` reads a `host` + // query parameter and would retarget pairing at an arbitrary remote host. + // Rejecting any query string keeps a discovered link pinned to loopback. + pairingUrl.search === "" && + token !== undefined && + token.length > 0 + ); +} From 901fda7ffd287a16fe0f5dfaf9e4b823f5fbf295 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Fri, 24 Jul 2026 13:25:34 -0700 Subject: [PATCH 04/10] refactor: prove local pairing by filesystem challenge, not a stored credential The advertisement previously carried a live admin-scoped pairing URL and rotated it every ~4 minutes, so a credential was continuously at rest in XDG_RUNTIME_DIR for the whole uptime of `t3 serve`. Same-UID access was already game over for code execution, but the admin scopes went further: AuthAccessWriteScope mints durable credentials and AuthRelayWriteScope relay-links the environment, turning a passive one-shot file read into persistent, off-machine access. Advertise presence instead, and prove same-user at pair time: - The record is now credential-free: instanceId, pid, startedAt, httpBaseUrl, environmentId, label. Published once, removed on shutdown. The rotation loop, the shutdown revoke and the tracked credential are all gone. - New unauthenticated `POST /api/auth/local-pair`. The caller writes a 256-bit nonce to a 0600 file in the owner-private challenge directory and presents the path; the server canonicalizes both paths, requires containment, requires a regular 0600 file owned by its own uid, and compares contents to the nonce in constant time. Only then does it issue a credential, in the response. Every check fails closed and all rejections collapse to one `invalid_credential`. - The desktop performs the handshake in the main process behind a new `pairLocalServer(instanceId)` IPC and deletes the challenge file in an `ensuring` block. The renderer now receives presence records only, and a credential exactly once, after the user clicks Pair. Because advertisements no longer expire, a record left by a killed process is rejected by the environment-identity probe instead. Co-Authored-By: Claude --- .../app/DesktopLocalServerDiscovery.test.ts | 202 ++++++++++--- .../src/app/DesktopLocalServerDiscovery.ts | 168 +++++++++-- apps/desktop/src/ipc/DesktopIpcHandlers.ts | 3 +- apps/desktop/src/ipc/channels.ts | 1 + .../src/ipc/methods/localServerDiscovery.ts | 12 +- apps/desktop/src/preload.ts | 2 + apps/server/src/auth/http.ts | 28 +- apps/server/src/auth/localPairing.test.ts | 210 ++++++++++++++ apps/server/src/auth/localPairing.ts | 172 +++++++++++ apps/server/src/auth/utils.ts | 11 + .../src/localServerAdvertisement.test.ts | 266 +++++++----------- apps/server/src/localServerAdvertisement.ts | 149 ++-------- apps/server/src/localServerDiscoveryState.ts | 43 +++ apps/server/src/server.test.ts | 5 +- apps/server/src/server.ts | 2 + apps/server/src/serverRuntimeStartup.ts | 2 +- apps/server/src/startupAccess.test.ts | 2 - apps/server/src/startupAccess.ts | 5 - .../ConnectionsSettings.logic.test.ts | 2 - .../settings/ConnectionsSettings.tsx | 24 +- apps/web/test/environmentHttpTest.ts | 1 + packages/contracts/src/environmentHttp.ts | 11 + packages/contracts/src/ipc.ts | 5 +- .../contracts/src/localServerDiscovery.ts | 33 ++- packages/shared/src/localServerDiscovery.ts | 62 +++- 25 files changed, 1048 insertions(+), 373 deletions(-) create mode 100644 apps/server/src/auth/localPairing.test.ts create mode 100644 apps/server/src/auth/localPairing.ts create mode 100644 apps/server/src/localServerDiscoveryState.ts diff --git a/apps/desktop/src/app/DesktopLocalServerDiscovery.test.ts b/apps/desktop/src/app/DesktopLocalServerDiscovery.test.ts index fe32f2c6f7c..c654f7322e7 100644 --- a/apps/desktop/src/app/DesktopLocalServerDiscovery.test.ts +++ b/apps/desktop/src/app/DesktopLocalServerDiscovery.test.ts @@ -1,13 +1,18 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, expect, it } from "@effect/vitest"; -import { EnvironmentId, LocalServerAdvertisement } from "@t3tools/contracts"; +import { + EnvironmentId, + LocalServerAdvertisement, + type LocalServerPairingChallenge, +} from "@t3tools/contracts"; +import { LOCAL_SERVER_CHALLENGE_NONCE_BYTES } from "@t3tools/shared/localServerDiscovery"; import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as TestClock from "effect/testing/TestClock"; -import { make } from "./DesktopLocalServerDiscovery.ts"; +import { LocalServerPairingError, make } from "./DesktopLocalServerDiscovery.ts"; const environmentId = EnvironmentId.make("environment-local"); const descriptor = { @@ -27,70 +32,82 @@ const makeRecord = ( pid: 1234, startedAt: "2026-01-01T00:00:00.000Z", httpBaseUrl: "http://127.0.0.1:3773/", - pairingUrl: "http://127.0.0.1:3773/pair#token=PAIRCODE", - pairingExpiresAt: "2099-01-01T00:00:00.000Z", environmentId, label: "Advertisement label", ...overrides, }); +const rejectPairing: () => Effect.Effect = () => + Effect.fail( + new LocalServerPairingError({ reason: "request_failed", detail: "unexpected pairing call" }), + ); + +const writeAdvertisement = Effect.fn("test.writeAdvertisement")(function* ( + advertisementDirectory: string, + record: LocalServerAdvertisement, +) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const recordPath = path.join(advertisementDirectory, `${record.instanceId}.json`); + yield* fileSystem.writeFileString(recordPath, yield* encodeRecord(record), { mode: 0o600 }); + yield* fileSystem.chmod(recordPath, 0o600); +}); + +const makeAdvertisementDirectory = Effect.fn("test.makeAdvertisementDirectory")(function* ( + prefix: string, +) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const runtimeDirectory = yield* fileSystem.makeTempDirectoryScoped({ prefix }); + const advertisementDirectory = path.join(runtimeDirectory, "t3code", "servers"); + yield* fileSystem.makeDirectory(advertisementDirectory, { recursive: true, mode: 0o700 }); + yield* fileSystem.chmod(advertisementDirectory, 0o700); + return { runtimeDirectory, advertisementDirectory }; +}); + it.effect("discovers private, live, identity-matched loopback advertisements", () => Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const runtimeDirectory = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-local-discovery-test-", - }); - const advertisementDirectory = path.join(runtimeDirectory, "t3code", "servers"); - const recordPath = path.join(advertisementDirectory, "instance-local.json"); - yield* fileSystem.makeDirectory(advertisementDirectory, { recursive: true, mode: 0o700 }); - yield* fileSystem.chmod(advertisementDirectory, 0o700); - yield* fileSystem.writeFileString(recordPath, yield* encodeRecord(makeRecord()), { - mode: 0o600, - }); - yield* fileSystem.chmod(recordPath, 0o600); + const { runtimeDirectory, advertisementDirectory } = yield* makeAdvertisementDirectory( + "t3-local-discovery-test-", + ); + yield* writeAdvertisement(advertisementDirectory, makeRecord()); const discovery = yield* make({ platform: "linux", xdgRuntimeDirectory: runtimeDirectory, uid: process.getuid?.(), probeEnvironment: () => Effect.succeed(descriptor), + postPairingChallenge: rejectPairing, }); const discovered = yield* discovery.discover; assert.strictEqual(discovered.length, 1); expect(discovered[0]?.environmentId).toBe(environmentId); expect(discovered[0]?.label).toBe(descriptor.label); + // The advertisement is public-to-this-user metadata only. + expect(discovered[0]).not.toHaveProperty("pairingUrl"); }).pipe(Effect.provide(NodeServices.layer), TestClock.withLive), ); -it.effect("ignores unsafe, expired, and identity-mismatched advertisements", () => +it.effect("ignores unsafe, dead, and identity-mismatched advertisements", () => Effect.gen(function* () { - const fileSystem = yield* FileSystem.FileSystem; - const path = yield* Path.Path; - const runtimeDirectory = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-local-discovery-rejection-test-", - }); - const advertisementDirectory = path.join(runtimeDirectory, "t3code", "servers"); - yield* fileSystem.makeDirectory(advertisementDirectory, { recursive: true, mode: 0o700 }); - yield* fileSystem.chmod(advertisementDirectory, 0o700); + const { runtimeDirectory, advertisementDirectory } = yield* makeAdvertisementDirectory( + "t3-local-discovery-rejection-test-", + ); const records = [ makeRecord({ instanceId: "non-loopback", httpBaseUrl: "http://192.168.1.20:3773/", - pairingUrl: "http://192.168.1.20:3773/pair#token=PAIRCODE", - }), - makeRecord({ - instanceId: "expired", - pairingExpiresAt: "2000-01-01T00:00:00.000Z", }), + // Records no longer expire, so a leftover record from a dead process is + // only rejected because nothing answers the identity probe. + makeRecord({ instanceId: "dead-process", httpBaseUrl: "http://127.0.0.1:3774/" }), makeRecord({ instanceId: "identity-mismatch" }), + makeRecord({ instanceId: "absurd-start", startedAt: "2999-01-01T00:00:00.000Z" }), ]; for (const record of records) { - const recordPath = path.join(advertisementDirectory, `${record.instanceId}.json`); - yield* fileSystem.writeFileString(recordPath, yield* encodeRecord(record), { mode: 0o600 }); - yield* fileSystem.chmod(recordPath, 0o600); + yield* writeAdvertisement(advertisementDirectory, record); } const discovery = yield* make({ @@ -103,9 +120,124 @@ it.effect("ignores unsafe, expired, and identity-mismatched advertisements", () ...descriptor, environmentId: EnvironmentId.make("another-environment"), }) - : Effect.succeed(descriptor), + : Effect.succeed(null), + postPairingChallenge: rejectPairing, }); expect(yield* discovery.discover).toEqual([]); }).pipe(Effect.provide(NodeServices.layer), TestClock.withLive), ); + +it.effect("pairs by presenting a private nonce and always removes the challenge file", () => + Effect.gen(function* () { + const path = yield* Path.Path; + const { runtimeDirectory, advertisementDirectory } = + yield* makeAdvertisementDirectory("t3-local-pairing-test-"); + yield* writeAdvertisement(advertisementDirectory, makeRecord()); + + const fileSystem = yield* FileSystem.FileSystem; + const observed: Array<{ + readonly httpBaseUrl: string; + readonly challenge: LocalServerPairingChallenge; + readonly contents: string; + readonly mode: number; + }> = []; + + const discovery = yield* make({ + platform: "linux", + xdgRuntimeDirectory: runtimeDirectory, + uid: process.getuid?.(), + probeEnvironment: () => Effect.succeed(descriptor), + postPairingChallenge: ({ httpBaseUrl, challenge }) => + Effect.gen(function* () { + // Read while the request is in flight: the nonce must exist on disk + // with 0600 permissions for the server to be able to verify it. + observed.push({ + httpBaseUrl, + challenge, + contents: yield* fileSystem.readFileString(challenge.challengePath), + mode: (yield* fileSystem.stat(challenge.challengePath)).mode & 0o777, + }); + return { + pairingUrl: "http://127.0.0.1:3773/pair#token=PAIRCODE", + pairingExpiresAt: "2099-01-01T00:00:00.000Z", + }; + }).pipe(Effect.orDie), + }); + + const result = yield* discovery.pairLocalServer("instance-local"); + + expect(result).toEqual({ + pairingUrl: "http://127.0.0.1:3773/pair#token=PAIRCODE", + pairingExpiresAt: "2099-01-01T00:00:00.000Z", + }); + assert.strictEqual(observed.length, 1); + const call = observed[0]; + assert.isDefined(call); + expect(call.httpBaseUrl).toBe("http://127.0.0.1:3773/"); + expect(call.challenge.instanceId).toBe("instance-local"); + expect(call.challenge.nonce).toMatch( + new RegExp(`^[0-9a-f]{${LOCAL_SERVER_CHALLENGE_NONCE_BYTES * 2}}$`), + ); + expect(call.contents).toBe(call.challenge.nonce); + expect(call.mode).toBe(0o600); + // Written inside the user-private challenge directory, never the + // advertisement directory the server owns. + expect(path.dirname(call.challenge.challengePath)).toBe( + path.join(runtimeDirectory, "t3code", "challenges"), + ); + expect((yield* fileSystem.stat(path.dirname(call.challenge.challengePath))).mode & 0o777).toBe( + 0o700, + ); + expect(yield* fileSystem.exists(call.challenge.challengePath)).toBe(false); + }).pipe(Effect.provide(NodeServices.layer), TestClock.withLive), +); + +it.effect("rejects pairing for an instance that is no longer advertising", () => + Effect.gen(function* () { + const { runtimeDirectory, advertisementDirectory } = yield* makeAdvertisementDirectory( + "t3-local-pairing-missing-test-", + ); + yield* writeAdvertisement(advertisementDirectory, makeRecord()); + + const discovery = yield* make({ + platform: "linux", + xdgRuntimeDirectory: runtimeDirectory, + uid: process.getuid?.(), + probeEnvironment: () => Effect.succeed(descriptor), + postPairingChallenge: rejectPairing, + }); + + const error = yield* discovery.pairLocalServer("instance-gone").pipe(Effect.flip); + expect(error.reason).toBe("not_found"); + }).pipe(Effect.provide(NodeServices.layer), TestClock.withLive), +); + +it.effect("removes the challenge file when the pairing request fails", () => + Effect.gen(function* () { + const { runtimeDirectory, advertisementDirectory } = yield* makeAdvertisementDirectory( + "t3-local-pairing-failure-test-", + ); + yield* writeAdvertisement(advertisementDirectory, makeRecord()); + + const fileSystem = yield* FileSystem.FileSystem; + const attempted: Array = []; + const discovery = yield* make({ + platform: "linux", + xdgRuntimeDirectory: runtimeDirectory, + uid: process.getuid?.(), + probeEnvironment: () => Effect.succeed(descriptor), + postPairingChallenge: ({ challenge }) => + Effect.gen(function* () { + attempted.push(challenge.challengePath); + expect(yield* fileSystem.exists(challenge.challengePath).pipe(Effect.orDie)).toBe(true); + return yield* new LocalServerPairingError({ reason: "request_failed", detail: "boom" }); + }), + }); + + const error = yield* discovery.pairLocalServer("instance-local").pipe(Effect.flip); + expect(error.reason).toBe("request_failed"); + assert.strictEqual(attempted.length, 1); + expect(yield* fileSystem.exists(attempted[0] as string)).toBe(false); + }).pipe(Effect.provide(NodeServices.layer), TestClock.withLive), +); diff --git a/apps/desktop/src/app/DesktopLocalServerDiscovery.ts b/apps/desktop/src/app/DesktopLocalServerDiscovery.ts index c2b4c9cfef8..7c9d558122e 100644 --- a/apps/desktop/src/app/DesktopLocalServerDiscovery.ts +++ b/apps/desktop/src/app/DesktopLocalServerDiscovery.ts @@ -1,16 +1,22 @@ +import * as NodeCrypto from "node:crypto"; + import { fetchRemoteEnvironmentDescriptor } from "@t3tools/client-runtime/environment"; import { LocalServerAdvertisement, + LocalServerPairingResult, type LocalServerAdvertisement as LocalServerAdvertisementRecord, + type LocalServerPairingChallenge, + type LocalServerPairingResult as LocalServerPairingResultRecord, type ExecutionEnvironmentDescriptor, } from "@t3tools/contracts"; import { - isValidLocalServerPairingUrl, LOCAL_SERVER_ADVERTISEMENT_DIRECTORY_MODE, LOCAL_SERVER_ADVERTISEMENT_FILE_MODE, LOCAL_SERVER_ADVERTISEMENT_MAX_BYTES, + LOCAL_SERVER_CHALLENGE_NONCE_BYTES, parseCanonicalLoopbackHttpBaseUrl, resolveLocalServerAdvertisementDirectory, + resolveLocalServerChallengeDirectory, } from "@t3tools/shared/localServerDiscovery"; import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Context from "effect/Context"; @@ -22,26 +28,66 @@ import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; const decodeAdvertisement = Schema.decodeUnknownEffect( Schema.fromJsonString(LocalServerAdvertisement), ); +const decodePairingResult = Schema.decodeUnknownEffect(LocalServerPairingResult); + +const LOCAL_SERVER_CHALLENGE_DIRECTORY_MODE = 0o700; +const LOCAL_SERVER_CHALLENGE_FILE_MODE = 0o600; +const LOCAL_SERVER_PAIRING_PATH = "api/auth/local-pair"; +const LOCAL_SERVER_PAIRING_TIMEOUT_MS = 10_000; + +export class LocalServerPairingError extends Schema.TaggedErrorClass()( + "LocalServerPairingError", + { + reason: Schema.Literals(["unavailable", "not_found", "challenge_failed", "request_failed"]), + detail: Schema.String, + cause: Schema.optional(Schema.Defect()), + }, +) { + override get message(): string { + return this.detail; + } +} type ProbeEnvironment = ( httpBaseUrl: string, ) => Effect.Effect; +/** + * Hands the challenge to the advertised server. Injected so tests can observe + * the on-disk challenge without standing up an HTTP server; the real + * implementation lives in {@link layer}. + */ +type PostPairingChallenge = (input: { + readonly httpBaseUrl: string; + readonly challenge: LocalServerPairingChallenge; +}) => Effect.Effect; + export interface DesktopLocalServerDiscoveryOptions { readonly platform: NodeJS.Platform; readonly xdgRuntimeDirectory: string | undefined; readonly uid: number | undefined; readonly probeEnvironment: ProbeEnvironment; + readonly postPairingChallenge: PostPairingChallenge; } export class DesktopLocalServerDiscovery extends Context.Service< DesktopLocalServerDiscovery, { readonly discover: Effect.Effect>; + /** + * Performs the whole pairing handshake in the main process so the pairing + * credential is minted on an explicit user action and never travels + * through the renderer as ambient discovery data. + */ + readonly pairLocalServer: ( + instanceId: string, + ) => Effect.Effect; } >()("@t3tools/desktop/app/DesktopLocalServerDiscovery") {} @@ -58,15 +104,16 @@ function ownedWithMode(input: { ); } -function hasValidTimestamps(record: LocalServerAdvertisementRecord, nowMs: number): boolean { +/** + * Advertisements no longer carry a credential and therefore no longer expire, + * so there is nothing here that can prove liveness. `startedAt` is only sanity + * checked: a record claiming to have started far in the future is malformed. + * A stale record left behind by a dead process is rejected further down by the + * environment-identity probe failing to reach the advertised loopback port. + */ +function hasValidStartedAt(record: LocalServerAdvertisementRecord, nowMs: number): boolean { const startedAtMs = Date.parse(record.startedAt); - const expiresAtMs = Date.parse(record.pairingExpiresAt); - return ( - Number.isFinite(startedAtMs) && - Number.isFinite(expiresAtMs) && - startedAtMs <= nowMs + 60_000 && - expiresAtMs > nowMs - ); + return Number.isFinite(startedAtMs) && startedAtMs <= nowMs + 60_000; } export const make = Effect.fn("desktop.localServerDiscovery.make")(function* ( @@ -79,6 +126,11 @@ export const make = Effect.fn("desktop.localServerDiscovery.make")(function* ( xdgRuntimeDirectory: options.xdgRuntimeDirectory, path, }); + const challengeDirectory = resolveLocalServerChallengeDirectory({ + platform: options.platform, + xdgRuntimeDirectory: options.xdgRuntimeDirectory, + path, + }); const discover = Effect.gen(function* () { if (directory === null) { @@ -141,21 +193,17 @@ export const make = Effect.fn("desktop.localServerDiscovery.make")(function* ( return null; } const decoded = yield* decodeAdvertisement(raw.value).pipe(Effect.option); - if (Option.isNone(decoded) || !hasValidTimestamps(decoded.value, nowMs)) { + if (Option.isNone(decoded) || !hasValidStartedAt(decoded.value, nowMs)) { return null; } - const httpBaseUrl = parseCanonicalLoopbackHttpBaseUrl(decoded.value.httpBaseUrl); - if ( - httpBaseUrl === null || - !isValidLocalServerPairingUrl({ - pairingUrl: decoded.value.pairingUrl, - httpBaseUrl, - }) - ) { + if (parseCanonicalLoopbackHttpBaseUrl(decoded.value.httpBaseUrl) === null) { return null; } + // Liveness check as well as an identity check: a record whose process + // is gone has nothing listening on the advertised port, so the probe + // fails and the advertisement drops out of the list. const descriptor = yield* options.probeEnvironment(decoded.value.httpBaseUrl); if (descriptor === null || descriptor.environmentId !== decoded.value.environmentId) { return null; @@ -176,7 +224,67 @@ export const make = Effect.fn("desktop.localServerDiscovery.make")(function* ( ); }); - return DesktopLocalServerDiscovery.of({ discover }); + const pairLocalServer = Effect.fn("desktop.localServerDiscovery.pair")(function* ( + instanceId: string, + ) { + if (challengeDirectory === null) { + return yield* new LocalServerPairingError({ + reason: "unavailable", + detail: "Local server pairing is not supported on this platform.", + }); + } + + // Re-run discovery so an explicit Pair click acts on a live, identity + // verified advertisement rather than on a stale renderer snapshot. + const advertisements = yield* discover; + const advertisement = advertisements.find((candidate) => candidate.instanceId === instanceId); + if (advertisement === undefined) { + return yield* new LocalServerPairingError({ + reason: "not_found", + detail: "This local server is no longer advertising itself.", + }); + } + + const nonce = NodeCrypto.randomBytes(LOCAL_SERVER_CHALLENGE_NONCE_BYTES).toString("hex"); + const challengePath = path.join( + challengeDirectory, + `${NodeCrypto.randomBytes(16).toString("hex")}.nonce`, + ); + + yield* Effect.gen(function* () { + yield* fileSystem.makeDirectory(challengeDirectory, { + recursive: true, + mode: LOCAL_SERVER_CHALLENGE_DIRECTORY_MODE, + }); + // `makeDirectory` honours the umask and is a no-op when the directory + // already exists, so tighten the mode unconditionally. + yield* fileSystem.chmod(challengeDirectory, LOCAL_SERVER_CHALLENGE_DIRECTORY_MODE); + yield* fileSystem.writeFileString(challengePath, nonce, { + mode: LOCAL_SERVER_CHALLENGE_FILE_MODE, + }); + yield* fileSystem.chmod(challengePath, LOCAL_SERVER_CHALLENGE_FILE_MODE); + }).pipe( + Effect.mapError( + (cause) => + new LocalServerPairingError({ + reason: "challenge_failed", + detail: "Could not write the local pairing challenge.", + cause, + }), + ), + ); + + // The nonce is the whole proof of local-user identity, so it must not + // outlive the request regardless of how that request ends. + return yield* options + .postPairingChallenge({ + httpBaseUrl: advertisement.httpBaseUrl, + challenge: { instanceId, challengePath, nonce }, + }) + .pipe(Effect.ensuring(fileSystem.remove(challengePath).pipe(Effect.ignore))); + }); + + return DesktopLocalServerDiscovery.of({ discover, pairLocalServer }); }); export const layer = Layer.effect( @@ -197,6 +305,28 @@ export const layer = Layer.effect( Effect.provideService(HttpClient.HttpClient, httpClient), Effect.orElseSucceed(() => null), ), + postPairingChallenge: ({ httpBaseUrl, challenge }) => + Effect.gen(function* () { + const url = new URL(LOCAL_SERVER_PAIRING_PATH, httpBaseUrl); + const request = HttpClientRequest.bodyJsonUnsafe( + HttpClientRequest.post(url.toString()), + challenge, + ); + const response = yield* httpClient + .execute(request) + .pipe(Effect.flatMap(HttpClientResponse.filterStatusOk)); + return yield* decodePairingResult(yield* response.json); + }).pipe( + Effect.timeout(LOCAL_SERVER_PAIRING_TIMEOUT_MS), + Effect.mapError( + (cause) => + new LocalServerPairingError({ + reason: "request_failed", + detail: "The local T3 Code server rejected the pairing request.", + cause, + }), + ), + ), }); }), ); diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 24c6ea3e887..290ae3342c8 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -3,7 +3,7 @@ import * as Effect from "effect/Effect"; import * as DesktopIpc from "./DesktopIpc.ts"; import { getClientSettings, setClientSettings } from "./methods/clientSettings.ts"; import { getBackendModeState, setBackendMode } from "./methods/backendMode.ts"; -import { discoverLocalServers } from "./methods/localServerDiscovery.ts"; +import { discoverLocalServers, pairLocalServer } from "./methods/localServerDiscovery.ts"; import { clearConnectionCatalog, getConnectionCatalog, @@ -56,6 +56,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handleSync(getLocalEnvironmentBootstraps); yield* ipc.handle(getLocalEnvironmentBearerToken); yield* ipc.handle(discoverLocalServers); + yield* ipc.handle(pairLocalServer); yield* ipc.handle(setBackendMode); yield* ipc.handle(getClientSettings); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index b78b4b06770..d806de88536 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -19,6 +19,7 @@ export const GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL = "desktop:get-local-envir export const GET_LOCAL_ENVIRONMENT_BEARER_TOKEN_CHANNEL = "desktop:get-local-environment-bearer-token"; export const DISCOVER_LOCAL_SERVERS_CHANNEL = "desktop:discover-local-servers"; +export const PAIR_LOCAL_SERVER_CHANNEL = "desktop:pair-local-server"; export const GET_CLIENT_SETTINGS_CHANNEL = "desktop:get-client-settings"; export const SET_CLIENT_SETTINGS_CHANNEL = "desktop:set-client-settings"; export const GET_CONNECTION_CATALOG_CHANNEL = "desktop:get-connection-catalog"; diff --git a/apps/desktop/src/ipc/methods/localServerDiscovery.ts b/apps/desktop/src/ipc/methods/localServerDiscovery.ts index 0291ed84aa6..498990677bc 100644 --- a/apps/desktop/src/ipc/methods/localServerDiscovery.ts +++ b/apps/desktop/src/ipc/methods/localServerDiscovery.ts @@ -1,4 +1,4 @@ -import { LocalServerAdvertisement } from "@t3tools/contracts"; +import { LocalServerAdvertisement, LocalServerPairingResult } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Schema from "effect/Schema"; @@ -15,3 +15,13 @@ export const discoverLocalServers = DesktopIpc.makeIpcMethod({ return yield* discovery.discover; }), }); + +export const pairLocalServer = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.PAIR_LOCAL_SERVER_CHANNEL, + payload: Schema.String, + result: LocalServerPairingResult, + handler: Effect.fn("desktop.ipc.localServerDiscovery.pair")(function* (instanceId: string) { + const discovery = yield* DesktopLocalServerDiscovery.DesktopLocalServerDiscovery; + return yield* discovery.pairLocalServer(instanceId); + }), +}); diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 6dae0d09f8d..755b4d913f8 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -60,6 +60,8 @@ contextBridge.exposeInMainWorld("desktopBridge", { getLocalEnvironmentBearerToken: () => ipcRenderer.invoke(IpcChannels.GET_LOCAL_ENVIRONMENT_BEARER_TOKEN_CHANNEL), discoverLocalServers: () => ipcRenderer.invoke(IpcChannels.DISCOVER_LOCAL_SERVERS_CHANNEL), + pairLocalServer: (instanceId) => + ipcRenderer.invoke(IpcChannels.PAIR_LOCAL_SERVER_CHANNEL, instanceId), getClientSettings: () => ipcRenderer.invoke(IpcChannels.GET_CLIENT_SETTINGS_CHANNEL), setClientSettings: (settings) => ipcRenderer.invoke(IpcChannels.SET_CLIENT_SETTINGS_CHANNEL, settings), diff --git a/apps/server/src/auth/http.ts b/apps/server/src/auth/http.ts index 780aaabde25..2438a0b44e0 100644 --- a/apps/server/src/auth/http.ts +++ b/apps/server/src/auth/http.ts @@ -37,7 +37,8 @@ import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; import * as EnvironmentAuth from "./EnvironmentAuth.ts"; import * as SessionStore from "./SessionStore.ts"; import { traceAuthenticatedRelayRequest, traceRelayRequest } from "../cloud/traceRelayRequest.ts"; -import { deriveAuthClientMetadata } from "./utils.ts"; +import { makeLocalServerPairingIssuer } from "./localPairing.ts"; +import { deriveAuthClientMetadata, readRequestRemoteAddress } from "./utils.ts"; import { verifyRequestDpopProof } from "./dpop.ts"; const CREDENTIAL_RESPONSE_HEADERS = { @@ -203,6 +204,7 @@ export const authHttpApiLayer = HttpApiBuilder.group( Effect.fnUntraced(function* (handlers) { const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; const sessions = yield* SessionStore.SessionStore; + const issueLocalServerPairing = yield* makeLocalServerPairingIssuer; return handlers .handle( @@ -251,6 +253,30 @@ export const authHttpApiLayer = HttpApiBuilder.group( ), ), ) + .handle( + "localPair", + Effect.fn("environment.auth.localPair")( + function* (args) { + yield* annotateEnvironmentRequest(args.endpoint.name); + // Deliberately no `requireEnvironmentScope`: this endpoint exists to + // bootstrap the caller's first credential. `issueLocalServerPairing` + // authorizes it with filesystem proof instead. + const request = yield* HttpServerRequest.HttpServerRequest; + const result = yield* issueLocalServerPairing({ + challenge: args.payload, + remoteAddress: readRequestRemoteAddress(request), + }); + if (result === null) { + return yield* failEnvironmentAuthInvalid("invalid_credential"); + } + yield* appendCredentialResponseHeaders; + return result; + }, + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => + failEnvironmentInternal("pairing_credential_issuance_failed", error), + ), + ), + ) .handle( "token", Effect.fn("environment.auth.token")( diff --git a/apps/server/src/auth/localPairing.test.ts b/apps/server/src/auth/localPairing.test.ts new file mode 100644 index 00000000000..b8008e06391 --- /dev/null +++ b/apps/server/src/auth/localPairing.test.ts @@ -0,0 +1,210 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import { LOCAL_SERVER_CHALLENGE_MAX_BYTES } from "@t3tools/shared/localServerDiscovery"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; + +import * as EnvironmentAuth from "./EnvironmentAuth.ts"; +import { makeLocalServerPairingIssuer } from "./localPairing.ts"; +import * as LocalServerDiscoveryState from "../localServerDiscoveryState.ts"; + +const INSTANCE_ID = "11111111-2222-3333-4444-555555555555"; +const HTTP_BASE_URL = "http://127.0.0.1:3773/"; +// 64 hex characters: the 32-byte nonce the client is required to present. +const NONCE = "a".repeat(64); + +const authLayer = Layer.mock(EnvironmentAuth.EnvironmentAuth)({ + issueStartupPairingCredential: () => + Effect.succeed({ + id: "local-pair-id", + credential: "LOCALPAIR", + expiresAt: DateTime.makeUnsafe("2099-01-01T00:00:00.000Z"), + }), +}); + +/** + * Stand up a challenge directory plus a valid challenge file, then hand back an + * issuer whose discovery state points at it. Each test mutates one input so the + * happy path stays the single source of "otherwise valid". + */ +const setup = Effect.fn(function* (options: { readonly activate?: boolean } = {}) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const runtimeDirectory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-local-pairing-test-", + }); + const challengeDirectory = path.join(runtimeDirectory, "t3code", "challenges"); + yield* fileSystem.makeDirectory(challengeDirectory, { recursive: true, mode: 0o700 }); + const challengePath = path.join(challengeDirectory, "challenge"); + yield* fileSystem.writeFileString(challengePath, `${NONCE}\n`, { mode: 0o600 }); + yield* fileSystem.chmod(challengePath, 0o600); + + const discoveryState = yield* LocalServerDiscoveryState.LocalServerDiscoveryState; + if (options.activate !== false) { + yield* discoveryState.activate({ + instanceId: INSTANCE_ID, + httpBaseUrl: HTTP_BASE_URL, + platform: "linux", + xdgRuntimeDirectory: runtimeDirectory, + }); + } + const issue = yield* makeLocalServerPairingIssuer; + + return { + fileSystem, + path, + runtimeDirectory, + challengeDirectory, + challengePath, + issue: ( + overrides: { + readonly instanceId?: string; + readonly challengePath?: string; + readonly nonce?: string; + readonly remoteAddress?: string | undefined; + } = {}, + ) => + issue({ + challenge: { + instanceId: overrides.instanceId ?? INSTANCE_ID, + challengePath: overrides.challengePath ?? challengePath, + nonce: overrides.nonce ?? NONCE, + }, + remoteAddress: "remoteAddress" in overrides ? overrides.remoteAddress : "127.0.0.1", + }), + }; +}); + +const provideTestLayers = (effect: Effect.Effect) => + effect.pipe( + Effect.provide(Layer.mergeAll(authLayer, LocalServerDiscoveryState.layer, NodeServices.layer)), + ); + +it.effect("issues a pairing credential when the caller proves filesystem access", () => + provideTestLayers( + Effect.gen(function* () { + const context = yield* setup(); + + const result = yield* context.issue(); + + expect(result).toEqual({ + pairingUrl: "http://127.0.0.1:3773/pair#token=LOCALPAIR", + pairingExpiresAt: "2099-01-01T00:00:00.000Z", + }); + // The challenge file belongs to the client; the server must not delete it. + expect(yield* context.fileSystem.exists(context.challengePath)).toBe(true); + }), + ), +); + +it.effect("rejects every invalid challenge without saying why", () => + provideTestLayers( + Effect.gen(function* () { + const context = yield* setup(); + const { fileSystem, path } = context; + + // Wrong instance: the caller discovered some other server. + expect( + yield* context.issue({ instanceId: "99999999-0000-0000-0000-000000000000" }), + ).toBeNull(); + + // Nonce mismatch. + expect(yield* context.issue({ nonce: "b".repeat(64) })).toBeNull(); + + // Nonce shorter than the required 32 bytes of hex-encoded entropy. + const shortPath = path.join(context.challengeDirectory, "short"); + yield* fileSystem.writeFileString(shortPath, "abcd", { mode: 0o600 }); + yield* fileSystem.chmod(shortPath, 0o600); + expect(yield* context.issue({ challengePath: shortPath, nonce: "abcd" })).toBeNull(); + + // Non-loopback and unknown peers. + expect(yield* context.issue({ remoteAddress: "192.168.1.42" })).toBeNull(); + expect(yield* context.issue({ remoteAddress: undefined })).toBeNull(); + + // Challenge path outside the challenge directory. + const outsidePath = path.join(context.runtimeDirectory, "outside"); + yield* fileSystem.writeFileString(outsidePath, `${NONCE}\n`, { mode: 0o600 }); + yield* fileSystem.chmod(outsidePath, 0o600); + expect(yield* context.issue({ challengePath: outsidePath })).toBeNull(); + + // Nested one level deeper: `isContainedChallengePath` requires the file to + // sit directly in the challenge directory. + const nestedDirectory = path.join(context.challengeDirectory, "nested"); + yield* fileSystem.makeDirectory(nestedDirectory, { recursive: true, mode: 0o700 }); + const nestedPath = path.join(nestedDirectory, "challenge"); + yield* fileSystem.writeFileString(nestedPath, `${NONCE}\n`, { mode: 0o600 }); + yield* fileSystem.chmod(nestedPath, 0o600); + expect(yield* context.issue({ challengePath: nestedPath })).toBeNull(); + + // Symlink inside the directory pointing at a file outside it. This is the + // arbitrary-file-read case, so it must be caught by realPath canonicalization. + const symlinkPath = path.join(context.challengeDirectory, "escape"); + yield* fileSystem.symlink(outsidePath, symlinkPath); + // Confirm the symlink really does resolve out of the directory, otherwise + // this case would pass for the wrong reason (a broken link). + expect(yield* fileSystem.realPath(symlinkPath)).toBe(yield* fileSystem.realPath(outsidePath)); + expect(yield* context.issue({ challengePath: symlinkPath })).toBeNull(); + + // Traversal that lands outside after canonicalization. + expect( + yield* context.issue({ + challengePath: path.join(context.challengeDirectory, "..", "..", "outside"), + }), + ).toBeNull(); + + // Missing file. + expect( + yield* context.issue({ challengePath: path.join(context.challengeDirectory, "absent") }), + ).toBeNull(); + + // A directory rather than a regular file. + expect(yield* context.issue({ challengePath: nestedDirectory })).toBeNull(); + + // World/group readable: another local user could have planted the nonce. + const loosePath = path.join(context.challengeDirectory, "loose"); + yield* fileSystem.writeFileString(loosePath, `${NONCE}\n`, { mode: 0o600 }); + yield* fileSystem.chmod(loosePath, 0o644); + expect(yield* context.issue({ challengePath: loosePath })).toBeNull(); + + // Oversized challenge file. + const hugePath = path.join(context.challengeDirectory, "huge"); + yield* fileSystem.writeFileString( + hugePath, + `${NONCE}\n${"p".repeat(LOCAL_SERVER_CHALLENGE_MAX_BYTES + 1)}`, + { mode: 0o600 }, + ); + yield* fileSystem.chmod(hugePath, 0o600); + expect(yield* context.issue({ challengePath: hugePath })).toBeNull(); + + // Sanity: the otherwise-valid challenge still works, so the rejections + // above are attributable to the mutation and not to a broken fixture. + expect(yield* context.issue()).not.toBeNull(); + }), + ), +); + +it.effect("stays closed when local discovery was never activated", () => + provideTestLayers( + Effect.gen(function* () { + const context = yield* setup({ activate: false }); + + expect(yield* context.issue()).toBeNull(); + }), + ), +); + +it.effect("stays closed once discovery is deactivated", () => + provideTestLayers( + Effect.gen(function* () { + const context = yield* setup(); + const discoveryState = yield* LocalServerDiscoveryState.LocalServerDiscoveryState; + + expect(yield* context.issue()).not.toBeNull(); + yield* discoveryState.deactivate; + expect(yield* context.issue()).toBeNull(); + }), + ), +); diff --git a/apps/server/src/auth/localPairing.ts b/apps/server/src/auth/localPairing.ts new file mode 100644 index 00000000000..adfb00e72e8 --- /dev/null +++ b/apps/server/src/auth/localPairing.ts @@ -0,0 +1,172 @@ +import * as NodeCrypto from "node:crypto"; + +import type { LocalServerPairingChallenge, LocalServerPairingResult } from "@t3tools/contracts"; +import { + isCanonicalLoopbackHostname, + isContainedChallengePath, + LOCAL_SERVER_CHALLENGE_MAX_BYTES, + LOCAL_SERVER_CHALLENGE_NONCE_BYTES, + resolveLocalServerChallengeDirectory, +} from "@t3tools/shared/localServerDiscovery"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; + +import * as EnvironmentAuth from "./EnvironmentAuth.ts"; +import { LocalServerDiscoveryState } from "../localServerDiscoveryState.ts"; +import { buildPairingUrl } from "../startupAccess.ts"; + +/** Hex-encoded, so twice the byte count. */ +const MINIMUM_NONCE_LENGTH = LOCAL_SERVER_CHALLENGE_NONCE_BYTES * 2; +const CHALLENGE_FILE_MODE = 0o600; + +export type LocalPairingRejectionReason = + | "discovery_inactive" + | "remote_not_loopback" + | "instance_mismatch" + | "challenge_directory_unavailable" + | "challenge_path_unresolvable" + | "challenge_path_escapes_directory" + | "challenge_not_regular_file" + | "challenge_owner_mismatch" + | "challenge_mode_mismatch" + | "challenge_too_large" + | "nonce_too_short" + | "challenge_unreadable" + | "nonce_mismatch"; + +function timingSafeEqualUtf8(left: string, right: string): boolean { + const leftBuffer = Buffer.from(left, "utf8"); + const rightBuffer = Buffer.from(right, "utf8"); + // `timingSafeEqual` throws on unequal lengths, so the length comparison has + // to happen first. It leaks only the nonce length, never its contents. + if (leftBuffer.length !== rightBuffer.length) { + return false; + } + return NodeCrypto.timingSafeEqual(leftBuffer, rightBuffer); +} + +/** + * Build the `/api/auth/local-pair` validator. + * + * This is the one credential-issuing path that is not behind + * `requireEnvironmentScope`, because it is how a same-user local client + * bootstraps its very first credential. Authorization is instead proof that the + * caller can create a file inside the server owner's 0700 `XDG_RUNTIME_DIR`, + * which no other local user can do. + * + * Every check fails closed and the issuer returns `null` for all of them so the + * HTTP response cannot be used to probe which one tripped. + */ +export const makeLocalServerPairingIssuer = Effect.gen(function* () { + const discoveryState = yield* LocalServerDiscoveryState; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; + + const reject = (reason: LocalPairingRejectionReason) => + Effect.logDebug("local pairing challenge rejected", { reason }).pipe(Effect.as(null)); + + return Effect.fn("server.localPair.issue")(function* (input: { + readonly challenge: LocalServerPairingChallenge; + readonly remoteAddress: string | undefined; + }) { + // 1. Local discovery must actually be running in this process, and the + // request must have arrived over loopback. + const discovery = yield* discoveryState.current; + if (discovery === null) { + return yield* reject("discovery_inactive"); + } + const remoteAddress = input.remoteAddress?.trim(); + if ( + remoteAddress === undefined || + remoteAddress.length === 0 || + !isCanonicalLoopbackHostname(remoteAddress) + ) { + return yield* reject("remote_not_loopback"); + } + + // 2. The caller must be talking to the instance it discovered. `instanceId` + // is public-to-this-user metadata, so a plain comparison is fine. + if (input.challenge.instanceId !== discovery.instanceId) { + return yield* reject("instance_mismatch"); + } + + // 3. The challenge file must live directly inside our challenge directory. + // Both sides are canonicalized so a symlink cannot escape it. + const challengeDirectory = resolveLocalServerChallengeDirectory({ + platform: discovery.platform, + xdgRuntimeDirectory: discovery.xdgRuntimeDirectory, + path, + }); + if (challengeDirectory === null) { + return yield* reject("challenge_directory_unavailable"); + } + const canonicalChallengeDirectory = yield* fileSystem + .realPath(challengeDirectory) + .pipe(Effect.option); + if (Option.isNone(canonicalChallengeDirectory)) { + return yield* reject("challenge_directory_unavailable"); + } + const canonicalChallengePath = yield* fileSystem + .realPath(input.challenge.challengePath) + .pipe(Effect.option); + if (Option.isNone(canonicalChallengePath)) { + return yield* reject("challenge_path_unresolvable"); + } + if ( + !isContainedChallengePath({ + canonicalChallengePath: canonicalChallengePath.value, + canonicalChallengeDirectory: canonicalChallengeDirectory.value, + path, + }) + ) { + return yield* reject("challenge_path_escapes_directory"); + } + + // 4. It must be a regular file we own, private to us, and small. + const info = yield* fileSystem.stat(canonicalChallengePath.value).pipe(Effect.option); + if (Option.isNone(info)) { + return yield* reject("challenge_path_unresolvable"); + } + if (info.value.type !== "File") { + return yield* reject("challenge_not_regular_file"); + } + const processUid = process.getuid?.(); + const fileUid = Option.getOrUndefined(info.value.uid); + if (processUid === undefined || fileUid === undefined || fileUid !== processUid) { + return yield* reject("challenge_owner_mismatch"); + } + if ((info.value.mode & 0o777) !== CHALLENGE_FILE_MODE) { + return yield* reject("challenge_mode_mismatch"); + } + if (info.value.size > BigInt(LOCAL_SERVER_CHALLENGE_MAX_BYTES)) { + return yield* reject("challenge_too_large"); + } + + // 5. Its contents must equal the presented nonce, compared in constant time. + const nonce = input.challenge.nonce.trim(); + if (nonce.length < MINIMUM_NONCE_LENGTH) { + return yield* reject("nonce_too_short"); + } + const contents = yield* fileSystem + .readFileString(canonicalChallengePath.value) + .pipe(Effect.option); + if (Option.isNone(contents)) { + return yield* reject("challenge_unreadable"); + } + if (!timingSafeEqualUtf8(contents.value.trim(), nonce)) { + return yield* reject("nonce_mismatch"); + } + + // 6. Proven. Issue a fresh pairing credential; nothing was ever on disk. + // The challenge file belongs to the client, so we leave it alone. + const issued = yield* serverAuth.issueStartupPairingCredential(); + return { + pairingUrl: buildPairingUrl(discovery.httpBaseUrl, issued.credential), + pairingExpiresAt: DateTime.formatIso(issued.expiresAt), + } satisfies LocalServerPairingResult; + }); +}); diff --git a/apps/server/src/auth/utils.ts b/apps/server/src/auth/utils.ts index 39f04988ac5..085fc0d5bd0 100644 --- a/apps/server/src/auth/utils.ts +++ b/apps/server/src/auth/utils.ts @@ -120,6 +120,17 @@ function readRemoteAddressFromSource(source: unknown): string | undefined { return normalizeIpAddress(candidate.socket?.remoteAddress ?? candidate.remoteAddress); } +/** + * Peer address of the underlying socket, IPv4-mapped IPv6 unwrapped. Returns + * `undefined` when it cannot be determined so callers that gate on loopback can + * fail closed. + */ +export function readRequestRemoteAddress( + request: HttpServerRequest.HttpServerRequest, +): string | undefined { + return readRemoteAddressFromSource(request.source); +} + export function deriveAuthClientMetadata(input: { readonly request: HttpServerRequest.HttpServerRequest; readonly presented?: AuthClientPresentationMetadata; diff --git a/apps/server/src/localServerAdvertisement.test.ts b/apps/server/src/localServerAdvertisement.test.ts index 2d5695ca518..fd0aec8e0a1 100644 --- a/apps/server/src/localServerAdvertisement.test.ts +++ b/apps/server/src/localServerAdvertisement.test.ts @@ -1,7 +1,6 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import { assert, expect, it } from "@effect/vitest"; import { EnvironmentId, LocalServerAdvertisement } from "@t3tools/contracts"; -import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; @@ -9,104 +8,74 @@ import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; -import * as TestClock from "effect/testing/TestClock"; -import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import * as ServerConfig from "./config.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; -import { - resolveAdvertisementRefreshDelayMs, - startLocalServerAdvertisement, -} from "./localServerAdvertisement.ts"; +import { startLocalServerAdvertisement } from "./localServerAdvertisement.ts"; +import * as LocalServerDiscoveryState from "./localServerDiscoveryState.ts"; const decodeRecord = Schema.decodeUnknownEffect(Schema.fromJsonString(LocalServerAdvertisement)); -it("refreshes one minute before expiry with a bounded minimum delay", () => { - expect( - resolveAdvertisementRefreshDelayMs({ - nowMs: 1_000, - pairingExpiresAt: "1970-01-01T00:05:01.000Z", - }), - ).toBe(240_000); - expect( - resolveAdvertisementRefreshDelayMs({ - nowMs: 1_000, - pairingExpiresAt: "1970-01-01T00:00:02.000Z", - }), - ).toBe(1_000); - expect( - resolveAdvertisementRefreshDelayMs({ - nowMs: 1_000, - pairingExpiresAt: "not-a-date", - }), - ).toBe(1_000); +const testEnvironment = ServerEnvironment.ServerEnvironment.of({ + getEnvironmentId: Effect.succeed(EnvironmentId.make("environment-local")), + getDescriptor: Effect.succeed({ + environmentId: EnvironmentId.make("environment-local"), + label: "Local server", + platform: { os: "linux", arch: "x64" }, + serverVersion: "0.0.28", + capabilities: { repositoryIdentity: true }, + }), }); -it.effect("publishes, rotates, and removes a private loopback advertisement", () => +const makeConfig = Effect.fn(function* ( + runtimeDirectory: string, + overrides: Partial = {}, +) { + const baseConfig = yield* ServerConfig.ServerConfig.pipe( + Effect.provide(ServerConfig.layerTest(runtimeDirectory, runtimeDirectory)), + ); + return ServerConfig.ServerConfig.of({ + ...baseConfig, + host: "127.0.0.1", + startupPresentation: "headless", + ...overrides, + }); +}); + +const runAdvertisement = Effect.fn(function* (input: { + readonly config: ServerConfig.ServerConfig["Service"]; + readonly runtimeDirectory: string; + readonly platform?: NodeJS.Platform; + readonly scope: Scope.Scope; +}) { + const discoveryState = yield* LocalServerDiscoveryState.LocalServerDiscoveryState; + yield* startLocalServerAdvertisement({ + connectionString: "http://127.0.0.1:3773", + platform: input.platform ?? "linux", + xdgRuntimeDirectory: input.runtimeDirectory, + }).pipe( + Effect.provideService(ServerConfig.ServerConfig, input.config), + Effect.provideService(ServerEnvironment.ServerEnvironment, testEnvironment), + Scope.provide(input.scope), + ); + return discoveryState; +}); + +it.effect("publishes a private, credential-free loopback advertisement exactly once", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; const runtimeDirectory = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-server-advertisement-test-", }); - const baseConfig = yield* Effect.gen(function* () { - return yield* ServerConfig.ServerConfig; - }).pipe(Effect.provide(ServerConfig.layerTest(runtimeDirectory, runtimeDirectory))); - const config = ServerConfig.ServerConfig.of({ - ...baseConfig, - host: "127.0.0.1", - startupPresentation: "headless", - }); - const revokedIds: Array = []; - const issuedCredentials = [ - { - id: "discovery-initial-id", - credential: "DISCOVERY_INITIAL", - expiresAt: DateTime.makeUnsafe("1970-01-01T00:02:00.000Z"), - }, - { - id: "rotated-id", - credential: "ROTATED", - expiresAt: DateTime.makeUnsafe("1970-01-01T00:07:00.000Z"), - }, - ]; - const authLayer = Layer.mock(EnvironmentAuth.EnvironmentAuth)({ - issueStartupPairingCredential: () => Effect.sync(() => issuedCredentials.shift()!), - revokePairingLink: (id) => - Effect.sync(() => { - revokedIds.push(id); - return true; - }), - }); - const environment = ServerEnvironment.ServerEnvironment.of({ - getEnvironmentId: Effect.succeed(EnvironmentId.make("environment-local")), - getDescriptor: Effect.succeed({ - environmentId: EnvironmentId.make("environment-local"), - label: "Local server", - platform: { os: "linux", arch: "x64" }, - serverVersion: "0.0.28", - capabilities: { repositoryIdentity: true }, - }), - }); + const config = yield* makeConfig(runtimeDirectory); const advertisementScope = yield* Scope.make(); - yield* startLocalServerAdvertisement({ - terminalAccessInfo: { - pairingCredentialId: "terminal-id", - connectionString: "http://127.0.0.1:3773", - token: "TERMINAL", - pairingUrl: "http://127.0.0.1:3773/pair#token=TERMINAL", - pairingExpiresAt: "1970-01-01T00:05:00.000Z", - }, - platform: "linux", - xdgRuntimeDirectory: runtimeDirectory, - }).pipe( - Effect.provideService(ServerConfig.ServerConfig, config), - Effect.provideService(ServerEnvironment.ServerEnvironment, environment), - Effect.provide(authLayer), - Scope.provide(advertisementScope), - ); - yield* Effect.sleep("20 millis").pipe(TestClock.withLive); + const discoveryState = yield* runAdvertisement({ + config, + runtimeDirectory, + scope: advertisementScope, + }); const directory = path.join(runtimeDirectory, "t3code", "servers"); const entries = yield* fileSystem.readDirectory(directory); @@ -114,93 +83,76 @@ it.effect("publishes, rotates, and removes a private loopback advertisement", () const recordPath = path.join(directory, entries[0]!); expect((yield* fileSystem.stat(directory)).mode & 0o777).toBe(0o700); expect((yield* fileSystem.stat(recordPath)).mode & 0o777).toBe(0o600); - expect( - (yield* decodeRecord(yield* fileSystem.readFileString(recordPath))).pairingUrl, - ).toContain("token=DISCOVERY_INITIAL"); - yield* TestClock.adjust("1 minute"); - yield* Effect.sleep("20 millis").pipe(TestClock.withLive); - expect( - (yield* decodeRecord(yield* fileSystem.readFileString(recordPath))).pairingUrl, - ).toContain("token=ROTATED"); - expect(revokedIds).toContain("discovery-initial-id"); - expect(revokedIds).not.toContain("terminal-id"); + const contents = yield* fileSystem.readFileString(recordPath); + // The record must never carry a credential, so assert on the raw JSON too: + // a schema decode would silently drop an excess property. + expect(contents).not.toContain("pairingUrl"); + expect(contents).not.toContain("token"); + const record = yield* decodeRecord(contents); + expect(record).toMatchObject({ + version: 1, + pid: process.pid, + httpBaseUrl: "http://127.0.0.1:3773/", + environmentId: "environment-local", + label: "Local server", + }); + expect(recordPath).toBe(path.join(directory, `${record.instanceId}.json`)); + + // Discovery is now active for the pairing endpoint. + const active = yield* discoveryState.current; + expect(active).toEqual({ + instanceId: record.instanceId, + httpBaseUrl: "http://127.0.0.1:3773/", + platform: "linux", + xdgRuntimeDirectory: runtimeDirectory, + }); yield* Scope.close(advertisementScope, Exit.void); expect(yield* fileSystem.exists(recordPath)).toBe(false); - expect(revokedIds).toContain("rotated-id"); - expect(revokedIds).not.toContain("terminal-id"); - }).pipe(Effect.provide(NodeServices.layer)), + expect(yield* fileSystem.readDirectory(directory)).toEqual([]); + expect(yield* discoveryState.current).toBeNull(); + }).pipe(Effect.provide(Layer.mergeAll(LocalServerDiscoveryState.layer, NodeServices.layer))), ); -it.effect("captures revoke defects and interruptions during cleanup", () => +it.effect("stays inactive for non-linux, non-headless, and non-loopback servers", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; - const runtimeDirectory = yield* fileSystem.makeTempDirectoryScoped({ - prefix: "t3-server-advertisement-revoke-test-", - }); - const baseConfig = yield* Effect.gen(function* () { - return yield* ServerConfig.ServerConfig; - }).pipe(Effect.provide(ServerConfig.layerTest(runtimeDirectory, runtimeDirectory))); - const config = ServerConfig.ServerConfig.of({ - ...baseConfig, - host: "127.0.0.1", - startupPresentation: "headless", - }); - const environment = ServerEnvironment.ServerEnvironment.of({ - getEnvironmentId: Effect.succeed(EnvironmentId.make("environment-local")), - getDescriptor: Effect.succeed({ - environmentId: EnvironmentId.make("environment-local"), - label: "Local server", - platform: { os: "linux", arch: "x64" }, - serverVersion: "0.0.28", - capabilities: { repositoryIdentity: true }, - }), - }); - const revokeFailures = [ - ["defect", Effect.die("revoke defect")], - ["interruption", Effect.interrupt], - ] as const; + const cases = [ + { name: "darwin platform", platform: "darwin" as NodeJS.Platform, overrides: {} }, + { + name: "browser startup presentation", + platform: "linux" as NodeJS.Platform, + overrides: { startupPresentation: "browser" as const }, + }, + { + name: "wildcard host", + platform: "linux" as NodeJS.Platform, + overrides: { host: "0.0.0.0" }, + }, + ]; - for (const [failureKind, revokeFailure] of revokeFailures) { - const authLayer = Layer.mock(EnvironmentAuth.EnvironmentAuth)({ - issueStartupPairingCredential: () => - Effect.succeed({ - id: `${failureKind}-id`, - credential: failureKind.toUpperCase(), - expiresAt: DateTime.makeUnsafe("2099-01-01T00:00:00.000Z"), - }), - revokePairingLink: () => revokeFailure, + for (const testCase of cases) { + const runtimeDirectory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-server-advertisement-inactive-test-", }); + const config = yield* makeConfig(runtimeDirectory, testCase.overrides); const advertisementScope = yield* Scope.make(); - yield* startLocalServerAdvertisement({ - terminalAccessInfo: { - pairingCredentialId: "terminal-id", - connectionString: "http://127.0.0.1:3773", - token: "TERMINAL", - pairingUrl: "http://127.0.0.1:3773/pair#token=TERMINAL", - pairingExpiresAt: "2099-01-01T00:00:00.000Z", - }, - platform: "linux", - xdgRuntimeDirectory: runtimeDirectory, - }).pipe( - Effect.provideService(ServerConfig.ServerConfig, config), - Effect.provideService(ServerEnvironment.ServerEnvironment, environment), - Effect.provide(authLayer), - Scope.provide(advertisementScope), - ); - yield* Effect.sleep("20 millis").pipe(TestClock.withLive); - - const directory = path.join(runtimeDirectory, "t3code", "servers"); - const entries = yield* fileSystem.readDirectory(directory); - assert.strictEqual(entries.length, 1); - const recordPath = path.join(directory, entries[0]!); - const closeExit = yield* Effect.exit(Scope.close(advertisementScope, Exit.void)); + const discoveryState = yield* runAdvertisement({ + config, + runtimeDirectory, + platform: testCase.platform, + scope: advertisementScope, + }); - expect(Exit.isSuccess(closeExit), failureKind).toBe(true); - expect(yield* fileSystem.exists(recordPath)).toBe(false); + expect( + yield* fileSystem.exists(path.join(runtimeDirectory, "t3code", "servers")), + testCase.name, + ).toBe(false); + expect(yield* discoveryState.current, testCase.name).toBeNull(); + yield* Scope.close(advertisementScope, Exit.void); } - }).pipe(Effect.provide(NodeServices.layer)), + }).pipe(Effect.provide(Layer.mergeAll(LocalServerDiscoveryState.layer, NodeServices.layer))), ); diff --git a/apps/server/src/localServerAdvertisement.ts b/apps/server/src/localServerAdvertisement.ts index e3f7eb29f5a..c56fae5a854 100644 --- a/apps/server/src/localServerAdvertisement.ts +++ b/apps/server/src/localServerAdvertisement.ts @@ -10,41 +10,16 @@ import { import { HostProcessEnvironment, HostProcessPlatform } from "@t3tools/shared/hostProcess"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; -import * as Duration from "effect/Duration"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; -import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; -import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; import * as ServerConfig from "./config.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; -import { - buildPairingUrl, - resolveLocalAdvertisementHttpBaseUrl, - type HeadlessServeAccessInfo, -} from "./startupAccess.ts"; - -const REFRESH_MARGIN_MS = 60_000; -const MINIMUM_REFRESH_DELAY_MS = 1_000; -// A failed rotation leaves `currentAccessInfo` pointing at an already-expiring -// credential, so the next refresh delay collapses to the minimum. Back off on -// an explicit interval instead of retrying once a second for the life of the -// process. -const FAILED_ROTATION_RETRY_DELAY_MS = 30_000; - -export function resolveAdvertisementRefreshDelayMs(input: { - readonly nowMs: number; - readonly pairingExpiresAt: string; -}): number { - const expiresAtMs = Date.parse(input.pairingExpiresAt); - if (!Number.isFinite(expiresAtMs)) { - return MINIMUM_REFRESH_DELAY_MS; - } - return Math.max(MINIMUM_REFRESH_DELAY_MS, expiresAtMs - input.nowMs - REFRESH_MARGIN_MS); -} +import { LocalServerDiscoveryState } from "./localServerDiscoveryState.ts"; +import { resolveLocalAdvertisementHttpBaseUrl } from "./startupAccess.ts"; const encodeAdvertisement = Schema.encodeUnknownEffect( Schema.fromJsonString(LocalServerAdvertisement), @@ -73,9 +48,18 @@ const writeAdvertisement = Effect.fn("server.localAdvertisement.write")(function }).pipe(Effect.ensuring(fileSystem.remove(input.tempPath, { force: true }).pipe(Effect.ignore))); }); +/** + * Publish a credential-free presence record so a desktop client running as the + * same local user can find this `t3 serve` process. + * + * The record is written exactly once and carries no secret, so there is nothing + * to rotate and nothing to revoke. A client that finds it proves it is the same + * local user through `/api/auth/local-pair`, which is only open while this + * function has activated `LocalServerDiscoveryState`. + */ export const startLocalServerAdvertisement = Effect.fn("server.localAdvertisement.start")( function* (input: { - readonly terminalAccessInfo: HeadlessServeAccessInfo; + readonly connectionString: string; readonly platform?: NodeJS.Platform; readonly xdgRuntimeDirectory?: string; }) { @@ -84,7 +68,7 @@ export const startLocalServerAdvertisement = Effect.fn("server.localAdvertisemen const path = yield* Path.Path; const crypto = yield* Crypto.Crypto; const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; - const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; + const discoveryState = yield* LocalServerDiscoveryState; const hostPlatform = yield* HostProcessPlatform; const hostEnvironment = yield* HostProcessEnvironment; const platform = input.platform ?? hostPlatform; @@ -94,7 +78,7 @@ export const startLocalServerAdvertisement = Effect.fn("server.localAdvertisemen xdgRuntimeDirectory, path, }); - const port = Number(new URL(input.terminalAccessInfo.connectionString).port); + const port = Number(new URL(input.connectionString).port); const httpBaseUrl = resolveLocalAdvertisementHttpBaseUrl(serverConfig.host, port); if ( directory === null || @@ -110,10 +94,8 @@ export const startLocalServerAdvertisement = Effect.fn("server.localAdvertisemen const recordPath = path.join(directory, `${instanceId}.json`); const tempPath = path.join(directory, `.${instanceId}.${process.pid}.tmp`); - const publish = Effect.fn("server.localAdvertisement.publish")(function* ( - accessInfo: HeadlessServeAccessInfo, - ) { - yield* writeAdvertisement({ + const publishExit = yield* Effect.exit( + writeAdvertisement({ directory, recordPath, tempPath, @@ -123,104 +105,31 @@ export const startLocalServerAdvertisement = Effect.fn("server.localAdvertisemen pid: process.pid, startedAt, httpBaseUrl, - pairingUrl: buildPairingUrl(httpBaseUrl, accessInfo.token), - pairingExpiresAt: accessInfo.pairingExpiresAt, environmentId: environment.environmentId, label: environment.label, }, - }); - }); - - const revoke = Effect.fn("server.localAdvertisement.revoke")(function* ( - accessInfo: HeadlessServeAccessInfo, - ) { - const revokeExit = yield* Effect.exit( - serverAuth.revokePairingLink(accessInfo.pairingCredentialId), - ); - if (Exit.isFailure(revokeExit)) { - yield* Effect.logWarning("Could not revoke a local discovery pairing credential.", { - pairingCredentialId: accessInfo.pairingCredentialId, - cause: revokeExit.cause, - }); - } - }); - - const issueAccessInfo = serverAuth.issueStartupPairingCredential().pipe( - Effect.map( - (issued): HeadlessServeAccessInfo => ({ - pairingCredentialId: issued.id, - connectionString: input.terminalAccessInfo.connectionString, - token: issued.credential, - pairingUrl: buildPairingUrl(input.terminalAccessInfo.connectionString, issued.credential), - pairingExpiresAt: DateTime.formatIso(issued.expiresAt), - }), - ), + }), ); - - const initialAccessInfoExit = yield* Effect.exit(issueAccessInfo); - if (Exit.isFailure(initialAccessInfoExit)) { + if (Exit.isFailure(publishExit)) { yield* Effect.logWarning("Local T3 Code server discovery is unavailable.", { recordPath, - cause: initialAccessInfoExit.cause, + cause: publishExit.cause, }); return; } - const initialAccessInfo = initialAccessInfoExit.value; - const currentAccessInfo = yield* Ref.make(initialAccessInfo); - const cleanup = Effect.gen(function* () { - yield* fileSystem.remove(recordPath, { force: true }).pipe(Effect.ignore); - yield* fileSystem.remove(tempPath, { force: true }).pipe(Effect.ignore); - yield* revoke(yield* Ref.get(currentAccessInfo)); - }); - - const initialPublish = yield* Effect.exit(publish(initialAccessInfo)); - if (Exit.isFailure(initialPublish)) { - yield* Effect.logWarning("Local T3 Code server discovery is unavailable.", { - recordPath, - cause: initialPublish.cause, - }); - yield* revoke(initialAccessInfo); - return; - } - - const rotate = Effect.forever( + yield* Effect.addFinalizer(() => Effect.gen(function* () { - const current = yield* Ref.get(currentAccessInfo); - const now = yield* DateTime.now; - yield* Effect.sleep( - Duration.millis( - resolveAdvertisementRefreshDelayMs({ - nowMs: DateTime.toEpochMillis(now), - pairingExpiresAt: current.pairingExpiresAt, - }), - ), - ); - const nextExit = yield* Effect.exit(issueAccessInfo); - if (Exit.isFailure(nextExit)) { - yield* Effect.logWarning("Could not rotate a local discovery pairing credential.", { - cause: nextExit.cause, - }); - yield* Effect.sleep(Duration.millis(FAILED_ROTATION_RETRY_DELAY_MS)); - return; - } - - const next = nextExit.value; - const publishExit = yield* Effect.exit(publish(next)); - if (Exit.isFailure(publishExit)) { - yield* Effect.logWarning("Could not refresh the local server discovery record.", { - recordPath, - cause: publishExit.cause, - }); - yield* revoke(next); - yield* Effect.sleep(Duration.millis(FAILED_ROTATION_RETRY_DELAY_MS)); - return; - } - yield* Ref.set(currentAccessInfo, next); - yield* revoke(current); + yield* discoveryState.deactivate; + yield* fileSystem.remove(recordPath, { force: true }).pipe(Effect.ignore); + yield* fileSystem.remove(tempPath, { force: true }).pipe(Effect.ignore); }), ); - - yield* Effect.forkScoped(rotate.pipe(Effect.ensuring(cleanup))); + yield* discoveryState.activate({ + instanceId, + httpBaseUrl, + platform, + xdgRuntimeDirectory, + }); }, ); diff --git a/apps/server/src/localServerDiscoveryState.ts b/apps/server/src/localServerDiscoveryState.ts new file mode 100644 index 00000000000..b92131475fc --- /dev/null +++ b/apps/server/src/localServerDiscoveryState.ts @@ -0,0 +1,43 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; + +/** + * What `startLocalServerAdvertisement` published for this process. It exists so + * `/api/auth/local-pair` can answer two questions without re-deriving them: + * whether local discovery is active at all, and which instance/runtime + * directory the caller must prove against. + * + * `null` (the initial value) means the endpoint is closed. That is the + * fail-closed default for every server that is not a headless, Linux, + * loopback-only `t3 serve`. + */ +export interface LocalServerDiscoveryRecord { + readonly instanceId: string; + /** Canonical loopback base URL, e.g. `http://127.0.0.1:3773/`. */ + readonly httpBaseUrl: string; + readonly platform: NodeJS.Platform; + readonly xdgRuntimeDirectory: string | undefined; +} + +export class LocalServerDiscoveryState extends Context.Service< + LocalServerDiscoveryState, + { + readonly current: Effect.Effect; + readonly activate: (record: LocalServerDiscoveryRecord) => Effect.Effect; + readonly deactivate: Effect.Effect; + } +>()("t3/localServerDiscoveryState") {} + +const make = Effect.gen(function* () { + const state = yield* Ref.make(null); + + return { + current: Ref.get(state), + activate: (record) => Ref.set(state, record), + deactivate: Ref.set(state, null), + } satisfies LocalServerDiscoveryState["Service"]; +}); + +export const layer = Layer.effect(LocalServerDiscoveryState, make); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 871b79eca90..17b801bdcf6 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -72,6 +72,7 @@ import { vi } from "vite-plus/test"; const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); import * as ServerConfig from "./config.ts"; +import * as LocalServerDiscoveryState from "./localServerDiscoveryState.ts"; import { makeRoutesLayer } from "./server.ts"; import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; import * as GitManager from "./git/GitManager.ts"; @@ -635,7 +636,9 @@ const buildAppUnderTest = (options?: { }), }), ), - Layer.provide(gitManagerLayer), + // Merged rather than a separate `Layer.provide` so this chain stays under + // the 20-argument `pipe` overload limit. + Layer.provide(Layer.mergeAll(gitManagerLayer, LocalServerDiscoveryState.layer)), Layer.provide(gitVcsDriverLayer), Layer.provide(gitWorkflowLayer), Layer.provide(reviewLayer), diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 66b9823afb3..e2885642c99 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -16,6 +16,7 @@ import { fixPath } from "./os-jank.ts"; import { websocketRpcRouteLayer } from "./ws.ts"; import * as ExternalLauncher from "./process/externalLauncher.ts"; import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts"; +import * as LocalServerDiscoveryState from "./localServerDiscoveryState.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory.ts"; @@ -342,6 +343,7 @@ const RuntimeDependenciesLive = RuntimeCoreDependenciesLive.pipe( Layer.provideMerge(AnalyticsService.layer), Layer.provideMerge(ExternalLauncher.layer), Layer.provideMerge(ServerLifecycleEvents.layer), + Layer.provideMerge(LocalServerDiscoveryState.layer), Layer.provide(NetService.layer), ); diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index 932eacf77d8..b399173b2ff 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -452,7 +452,7 @@ export const make = Effect.gen(function* () { yield* Effect.logDebug("startup phase: headless access info"); const accessInfo = yield* issueHeadlessServeAccessInfo(); yield* startLocalServerAdvertisement({ - terminalAccessInfo: accessInfo, + connectionString: accessInfo.connectionString, }); yield* runStartupPhase( "headless.output", diff --git a/apps/server/src/startupAccess.test.ts b/apps/server/src/startupAccess.test.ts index f40e602d4a7..8198d93ef17 100644 --- a/apps/server/src/startupAccess.test.ts +++ b/apps/server/src/startupAccess.test.ts @@ -76,11 +76,9 @@ it("renders terminal QR codes as a multi-line unicode block grid", () => { it("formats headless serve output with the connection string, token, pairing url, and qr code", () => { const output = formatHeadlessServeOutput({ - pairingCredentialId: "pairing-id", connectionString: "http://192.168.1.42:3773", token: "PAIRCODE", pairingUrl: "http://192.168.1.42:3773/pair#token=PAIRCODE", - pairingExpiresAt: "2026-01-01T00:05:00.000Z", }); expect(output).toContain("Connection string: http://192.168.1.42:3773"); diff --git a/apps/server/src/startupAccess.ts b/apps/server/src/startupAccess.ts index 1a2c5040e27..7964f50eed7 100644 --- a/apps/server/src/startupAccess.ts +++ b/apps/server/src/startupAccess.ts @@ -1,7 +1,6 @@ import * as NodeOS from "node:os"; import { QrCode } from "@t3tools/shared/qrCode"; -import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; import { HttpServer } from "effect/unstable/http"; @@ -9,11 +8,9 @@ import { ServerConfig } from "./config.ts"; import * as EnvironmentAuth from "./auth/EnvironmentAuth.ts"; export interface HeadlessServeAccessInfo { - readonly pairingCredentialId: string; readonly connectionString: string; readonly token: string; readonly pairingUrl: string; - readonly pairingExpiresAt: string; } type NetworkInterfacesMap = ReturnType; @@ -156,10 +153,8 @@ export const issueHeadlessServeAccessInfo = Effect.fn("issueHeadlessServeAccessI const issued = yield* serverAuth.issueStartupPairingCredential(); return { - pairingCredentialId: issued.id, connectionString, token: issued.credential, pairingUrl: buildPairingUrl(connectionString, issued.credential), - pairingExpiresAt: DateTime.formatIso(issued.expiresAt), } satisfies HeadlessServeAccessInfo; }); diff --git a/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts b/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts index f4428827523..22ae4f19786 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts +++ b/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts @@ -88,8 +88,6 @@ describe("selectLocalServerPairingCandidates", () => { pid: 1234, startedAt: "2026-01-01T00:00:00.000Z", httpBaseUrl: "http://127.0.0.1:3773/", - pairingUrl: "http://127.0.0.1:3773/pair#token=PAIRCODE", - pairingExpiresAt: "2026-01-01T00:05:00.000Z", environmentId: EnvironmentId.make("environment-local"), label: "Local server", } satisfies LocalServerAdvertisement; diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index c8994d4c20a..bb399dac856 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -1790,25 +1790,17 @@ export function ConnectionsSettings() { const handlePairLocalServer = useCallback( async (advertisement: LocalServerAdvertisement, pairAgain: boolean) => { - const discoverLocalServers = desktopBridge?.discoverLocalServers; - if (!discoverLocalServers) return; + const pairLocalServer = desktopBridge?.pairLocalServer; + if (!pairLocalServer) return; setPairingLocalServerInstanceId(advertisement.instanceId); setLocalServerDiscoveryError(null); try { - // Re-read immediately before the explicit pairing action so a rotated - // one-time credential is used instead of a stale UI snapshot. - const currentAdvertisements = await discoverLocalServers(); - setLocalServerAdvertisements(currentAdvertisements); - const current = currentAdvertisements.find( - (candidate) => - candidate.instanceId === advertisement.instanceId && - candidate.environmentId === advertisement.environmentId, - ); - if (!current) { - throw new Error("This local server is no longer advertising a valid pairing link."); - } + // The main process re-discovers, proves same-user access to the server's + // runtime directory, and mints the credential only now, on this explicit + // action. No pairing token exists in the renderer before this point. + const { pairingUrl } = await pairLocalServer(advertisement.instanceId); - const result = await connectPairing({ pairingUrl: current.pairingUrl }); + const result = await connectPairing({ pairingUrl }); if (result._tag === "Failure") { if (isAtomCommandInterrupted(result)) { setPairingLocalServerInstanceId(null); @@ -1824,7 +1816,7 @@ export function ConnectionsSettings() { toastManager.add({ type: "success", title: pairAgain ? "Environment paired again" : "Environment paired", - description: `${current.label} is saved and will reconnect on app startup.`, + description: `${advertisement.label} is saved and will reconnect on app startup.`, }); } catch (error) { const message = diff --git a/apps/web/test/environmentHttpTest.ts b/apps/web/test/environmentHttpTest.ts index ce43faacc40..6d7778dfadf 100644 --- a/apps/web/test/environmentHttpTest.ts +++ b/apps/web/test/environmentHttpTest.ts @@ -111,6 +111,7 @@ export async function installEnvironmentHttpTest(scenario: EnvironmentHttpTestSc ); }), ) + .handle("localPair", () => unexpectedEndpoint("auth.localPair")) .handle("pairingLinks", () => unexpectedEndpoint("auth.pairingLinks")) .handle("revokePairingLink", () => unexpectedEndpoint("auth.revokePairingLink")) .handle("clients", () => unexpectedEndpoint("auth.clients")) diff --git a/packages/contracts/src/environmentHttp.ts b/packages/contracts/src/environmentHttp.ts index 2d40dad60cc..ce928ea3284 100644 --- a/packages/contracts/src/environmentHttp.ts +++ b/packages/contracts/src/environmentHttp.ts @@ -26,6 +26,7 @@ import { } from "./auth.ts"; import { AuthSessionId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; import { ExecutionEnvironmentDescriptor } from "./environment.ts"; +import { LocalServerPairingChallenge, LocalServerPairingResult } from "./localServerDiscovery.ts"; import { ClientOrchestrationCommand, DispatchResult, @@ -392,6 +393,16 @@ export class EnvironmentAuthHttpApi extends HttpApiGroup.make("auth") error: EnvironmentSessionCreationErrors, }), ) + .add( + // Unauthenticated by design: this is how a same-user local client bootstraps + // its first credential. Authorization is filesystem proof (see the handler + // in apps/server/src/auth/localPairing.ts), not a bearer token. + HttpApiEndpoint.post("localPair", "/api/auth/local-pair", { + payload: LocalServerPairingChallenge, + success: LocalServerPairingResult, + error: EnvironmentSessionCreationErrors, + }), + ) .add( HttpApiEndpoint.post("token", "/oauth/token", { headers: OptionalDpopProofHeaders, diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index ee3a83b5751..9c6bcbbc888 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -19,7 +19,7 @@ import type { VcsStatusResult, } from "./git.ts"; import type { ReviewDiffPreviewInput, ReviewDiffPreviewResult } from "./review.ts"; -import type { LocalServerAdvertisement } from "./localServerDiscovery.ts"; +import type { LocalServerAdvertisement, LocalServerPairingResult } from "./localServerDiscovery.ts"; import type { FilesystemBrowseInput, FilesystemBrowseResult } from "./filesystem.ts"; import type { AssetCreateUrlInput, AssetCreateUrlResult } from "./assets.ts"; import type { @@ -997,6 +997,9 @@ export interface DesktopBridge { getLocalEnvironmentBootstraps: () => readonly DesktopEnvironmentBootstrap[]; getLocalEnvironmentBearerToken: () => Promise; discoverLocalServers?: () => Promise; + // Runs the whole local pairing handshake in the main process so the pairing + // credential only reaches the renderer on an explicit user action. + pairLocalServer?: (instanceId: string) => Promise; getClientSettings: () => Promise; setClientSettings: (settings: ClientSettings) => Promise; getConnectionCatalog?: () => Promise; diff --git a/packages/contracts/src/localServerDiscovery.ts b/packages/contracts/src/localServerDiscovery.ts index 4c45e1e7abe..7603778c0c9 100644 --- a/packages/contracts/src/localServerDiscovery.ts +++ b/packages/contracts/src/localServerDiscovery.ts @@ -6,9 +6,14 @@ export const LocalServerAdvertisementVersion = Schema.Literal(1); export type LocalServerAdvertisementVersion = typeof LocalServerAdvertisementVersion.Type; /** - * Private, short-lived handoff record published by a loopback-only `t3 serve` - * process. The pairing URL contains a one-time credential; this is runtime - * discovery state, never a steady-state credential store. + * Presence record published by a loopback-only `t3 serve` process so a desktop + * client running as the same user can find it. + * + * This record deliberately carries NO credential. A client proves it is the + * same local user by writing a nonce into the user-private runtime directory + * and presenting it to `/api/auth/local-pair`; the server issues a pairing + * credential in that response and never writes one to disk. Treat every field + * here as public-to-this-user metadata, not a secret. */ export const LocalServerAdvertisement = Schema.Struct({ version: LocalServerAdvertisementVersion, @@ -16,9 +21,27 @@ export const LocalServerAdvertisement = Schema.Struct({ pid: PositiveInt, startedAt: TrimmedNonEmptyString, httpBaseUrl: TrimmedNonEmptyString, - pairingUrl: TrimmedNonEmptyString, - pairingExpiresAt: TrimmedNonEmptyString, environmentId: EnvironmentId, label: TrimmedNonEmptyString, }); export type LocalServerAdvertisement = typeof LocalServerAdvertisement.Type; + +/** + * Proof that the caller can create a file inside the server owner's private + * runtime directory, which on a 0700 XDG_RUNTIME_DIR means the caller is the + * same local user. `challengePath` must resolve inside the challenge directory + * and be a 0600 regular file owned by the server's uid whose contents equal + * `nonce`. + */ +export const LocalServerPairingChallenge = Schema.Struct({ + instanceId: TrimmedNonEmptyString, + challengePath: TrimmedNonEmptyString, + nonce: TrimmedNonEmptyString, +}); +export type LocalServerPairingChallenge = typeof LocalServerPairingChallenge.Type; + +export const LocalServerPairingResult = Schema.Struct({ + pairingUrl: TrimmedNonEmptyString, + pairingExpiresAt: TrimmedNonEmptyString, +}); +export type LocalServerPairingResult = typeof LocalServerPairingResult.Type; diff --git a/packages/shared/src/localServerDiscovery.ts b/packages/shared/src/localServerDiscovery.ts index 1b778ce55d6..b65bf3e8858 100644 --- a/packages/shared/src/localServerDiscovery.ts +++ b/packages/shared/src/localServerDiscovery.ts @@ -5,11 +5,20 @@ export const LOCAL_SERVER_ADVERTISEMENT_DIRECTORY_MODE = 0o700; export const LOCAL_SERVER_ADVERTISEMENT_FILE_MODE = 0o600; export const LOCAL_SERVER_ADVERTISEMENT_MAX_BYTES = 64 * 1024; -export function resolveLocalServerAdvertisementDirectory(input: { - readonly platform: NodeJS.Platform; - readonly xdgRuntimeDirectory: string | undefined; - readonly path: Path.Path; -}): string | null { +export const LOCAL_SERVER_CHALLENGE_DIRECTORY_PARTS = ["t3code", "challenges"] as const; +export const LOCAL_SERVER_CHALLENGE_MAX_BYTES = 4 * 1024; +// 256 bits of entropy, hex encoded. Long enough that a caller who cannot read +// the challenge file cannot guess its contents. +export const LOCAL_SERVER_CHALLENGE_NONCE_BYTES = 32; + +function resolveRuntimeSubdirectory( + input: { + readonly platform: NodeJS.Platform; + readonly xdgRuntimeDirectory: string | undefined; + readonly path: Path.Path; + }, + parts: readonly string[], +): string | null { if (input.platform !== "linux") { return null; } @@ -17,7 +26,48 @@ export function resolveLocalServerAdvertisementDirectory(input: { if (!runtimeDirectory || !input.path.isAbsolute(runtimeDirectory)) { return null; } - return input.path.join(runtimeDirectory, ...LOCAL_SERVER_ADVERTISEMENT_DIRECTORY_PARTS); + return input.path.join(runtimeDirectory, ...parts); +} + +export function resolveLocalServerAdvertisementDirectory(input: { + readonly platform: NodeJS.Platform; + readonly xdgRuntimeDirectory: string | undefined; + readonly path: Path.Path; +}): string | null { + return resolveRuntimeSubdirectory(input, LOCAL_SERVER_ADVERTISEMENT_DIRECTORY_PARTS); +} + +/** + * Directory a pairing client writes its challenge nonce into. Separate from the + * advertisement directory so the server never reads a caller-named path out of + * the directory it publishes into. + */ +export function resolveLocalServerChallengeDirectory(input: { + readonly platform: NodeJS.Platform; + readonly xdgRuntimeDirectory: string | undefined; + readonly path: Path.Path; +}): string | null { + return resolveRuntimeSubdirectory(input, LOCAL_SERVER_CHALLENGE_DIRECTORY_PARTS); +} + +/** + * Confirm a caller-supplied challenge path resolves inside the challenge + * directory. Callers must pass already-canonicalized paths (realPath on both) + * so a symlink cannot escape the directory. This is the only thing standing + * between `/api/auth/local-pair` and an arbitrary-file read, so it fails closed. + */ +export function isContainedChallengePath(input: { + readonly canonicalChallengePath: string; + readonly canonicalChallengeDirectory: string; + readonly path: Path.Path; +}): boolean { + if ( + !input.path.isAbsolute(input.canonicalChallengePath) || + !input.path.isAbsolute(input.canonicalChallengeDirectory) + ) { + return false; + } + return input.path.dirname(input.canonicalChallengePath) === input.canonicalChallengeDirectory; } export function isCanonicalLoopbackHostname(hostname: string): boolean { From ddf8bc990808d515cc962cd3ae4482f939f9a83c Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Fri, 24 Jul 2026 14:56:07 -0700 Subject: [PATCH 05/10] fix: make local discovery publication atomic --- .../src/localServerAdvertisement.test.ts | 61 ++++++++++++++++++- apps/server/src/localServerAdvertisement.ts | 28 ++++----- .../settings/ConnectionsSettings.tsx | 5 +- 3 files changed, 77 insertions(+), 17 deletions(-) diff --git a/apps/server/src/localServerAdvertisement.test.ts b/apps/server/src/localServerAdvertisement.test.ts index fd0aec8e0a1..3c4f5ea540b 100644 --- a/apps/server/src/localServerAdvertisement.test.ts +++ b/apps/server/src/localServerAdvertisement.test.ts @@ -6,6 +6,7 @@ import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; @@ -47,9 +48,10 @@ const runAdvertisement = Effect.fn(function* (input: { readonly runtimeDirectory: string; readonly platform?: NodeJS.Platform; readonly scope: Scope.Scope; + readonly fileSystem?: FileSystem.FileSystem; }) { const discoveryState = yield* LocalServerDiscoveryState.LocalServerDiscoveryState; - yield* startLocalServerAdvertisement({ + let advertisement = startLocalServerAdvertisement({ connectionString: "http://127.0.0.1:3773", platform: input.platform ?? "linux", xdgRuntimeDirectory: input.runtimeDirectory, @@ -58,6 +60,12 @@ const runAdvertisement = Effect.fn(function* (input: { Effect.provideService(ServerEnvironment.ServerEnvironment, testEnvironment), Scope.provide(input.scope), ); + if (input.fileSystem !== undefined) { + advertisement = advertisement.pipe( + Effect.provideService(FileSystem.FileSystem, input.fileSystem), + ); + } + yield* advertisement; return discoveryState; }); @@ -115,6 +123,57 @@ it.effect("publishes a private, credential-free loopback advertisement exactly o }).pipe(Effect.provide(Layer.mergeAll(LocalServerDiscoveryState.layer, NodeServices.layer))), ); +it.effect("activates before publication and rolls back when publication fails", () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const discoveryState = yield* LocalServerDiscoveryState.LocalServerDiscoveryState; + const runtimeDirectory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-server-advertisement-publication-failure-test-", + }); + const config = yield* makeConfig(runtimeDirectory); + const advertisementScope = yield* Scope.make(); + const publicationFailure = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "chmod", + pathOrDescriptor: runtimeDirectory, + }); + let stateAtPublication: LocalServerDiscoveryState.LocalServerDiscoveryRecord | null = null; + const failingFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + rename: (from, to) => + Effect.gen(function* () { + stateAtPublication = yield* discoveryState.current; + yield* fileSystem.rename(from, to); + }), + chmod: (target, mode) => + String(target).endsWith(".json") + ? Effect.fail(publicationFailure) + : fileSystem.chmod(target, mode), + }); + + yield* runAdvertisement({ + config, + runtimeDirectory, + scope: advertisementScope, + fileSystem: failingFileSystem, + }); + + expect(stateAtPublication).toMatchObject({ + httpBaseUrl: "http://127.0.0.1:3773/", + platform: "linux", + xdgRuntimeDirectory: runtimeDirectory, + }); + expect(yield* discoveryState.current).toBeNull(); + expect( + yield* fileSystem.readDirectory(path.join(runtimeDirectory, "t3code", "servers")), + ).toEqual([]); + + yield* Scope.close(advertisementScope, Exit.void); + }).pipe(Effect.provide(Layer.mergeAll(LocalServerDiscoveryState.layer, NodeServices.layer))), +); + it.effect("stays inactive for non-linux, non-headless, and non-loopback servers", () => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/localServerAdvertisement.ts b/apps/server/src/localServerAdvertisement.ts index c56fae5a854..064a52c3be7 100644 --- a/apps/server/src/localServerAdvertisement.ts +++ b/apps/server/src/localServerAdvertisement.ts @@ -94,6 +94,19 @@ export const startLocalServerAdvertisement = Effect.fn("server.localAdvertisemen const recordPath = path.join(directory, `${instanceId}.json`); const tempPath = path.join(directory, `.${instanceId}.${process.pid}.tmp`); + const cleanup = Effect.gen(function* () { + yield* discoveryState.deactivate; + yield* fileSystem.remove(recordPath, { force: true }).pipe(Effect.ignore); + yield* fileSystem.remove(tempPath, { force: true }).pipe(Effect.ignore); + }); + yield* Effect.addFinalizer(() => cleanup); + yield* discoveryState.activate({ + instanceId, + httpBaseUrl, + platform, + xdgRuntimeDirectory, + }); + const publishExit = yield* Effect.exit( writeAdvertisement({ directory, @@ -111,25 +124,12 @@ export const startLocalServerAdvertisement = Effect.fn("server.localAdvertisemen }), ); if (Exit.isFailure(publishExit)) { + yield* cleanup; yield* Effect.logWarning("Local T3 Code server discovery is unavailable.", { recordPath, cause: publishExit.cause, }); return; } - - yield* Effect.addFinalizer(() => - Effect.gen(function* () { - yield* discoveryState.deactivate; - yield* fileSystem.remove(recordPath, { force: true }).pipe(Effect.ignore); - yield* fileSystem.remove(tempPath, { force: true }).pipe(Effect.ignore); - }), - ); - yield* discoveryState.activate({ - instanceId, - httpBaseUrl, - platform, - xdgRuntimeDirectory, - }); }, ); diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index bb399dac856..4d94d0b4895 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -3553,8 +3553,9 @@ export function ConnectionsSettings() { } >

- These loopback servers advertised a private, short-lived pairing link. Pairing saves the - connection; this desktop will not manage the server process. + These loopback servers published credential-free presence records. Pairing verifies the + server belongs to your local user, then saves the connection; this desktop will not + manage the server process.

{renderLocalServerPairingCandidates()} From bbd9b48c7b2a7105307182945e8b517f9d8a8caf Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Fri, 24 Jul 2026 16:18:38 -0700 Subject: [PATCH 06/10] fix(desktop): roll back mode after relaunch failure --- apps/desktop/src/ipc/methods/backendMode.test.ts | 4 +++- apps/desktop/src/ipc/methods/backendMode.ts | 5 ++++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/ipc/methods/backendMode.test.ts b/apps/desktop/src/ipc/methods/backendMode.test.ts index 350ae040c39..32d875ade0f 100644 --- a/apps/desktop/src/ipc/methods/backendMode.test.ts +++ b/apps/desktop/src/ipc/methods/backendMode.test.ts @@ -16,7 +16,7 @@ const unusedLifecycleRuntimeLayer = Layer.empty as Layer.Layer; describe("backend mode IPC", () => { - it.effect("rejects the update when a packaged relaunch cannot be scheduled", () => { + it.effect("restores the configured mode when a packaged relaunch cannot be scheduled", () => { const relaunchError = new DesktopLifecycle.DesktopLifecycleRelaunchError({ reason: "backendMode=client-only", cause: Cause.die(new Error("relaunch failed")), @@ -36,12 +36,14 @@ describe("backend mode IPC", () => { return Effect.gen(function* () { const launchMode = yield* DesktopBackendMode.DesktopBackendMode; + const settings = yield* DesktopAppSettings.DesktopAppSettings; yield* launchMode.latch("managed"); const error = yield* setBackendMode .handler("client-only") .pipe(Effect.flatMap(decodeBackendModeState), Effect.flip); assert.strictEqual(error, relaunchError); + assert.equal((yield* settings.get).backendMode, "managed"); }).pipe(Effect.provide(layer)); }); }); diff --git a/apps/desktop/src/ipc/methods/backendMode.ts b/apps/desktop/src/ipc/methods/backendMode.ts index d6c9b7acd52..90f6b540de4 100644 --- a/apps/desktop/src/ipc/methods/backendMode.ts +++ b/apps/desktop/src/ipc/methods/backendMode.ts @@ -42,6 +42,7 @@ export const setBackendMode = DesktopIpc.makeIpcMethod({ const launchMode = yield* DesktopBackendMode.DesktopBackendMode; const lifecycle = yield* DesktopLifecycle.DesktopLifecycle; const settings = yield* DesktopAppSettings.DesktopAppSettings; + const previousMode = (yield* settings.get).backendMode; const change = yield* settings.setBackendMode(mode); const launchState = yield* launchMode.get; const state = { @@ -53,7 +54,9 @@ export const setBackendMode = DesktopIpc.makeIpcMethod({ launchState.cliOverride === null && launchState.effectiveMode !== change.settings.backendMode ) { - yield* lifecycle.relaunch(`backendMode=${mode}`); + yield* lifecycle + .relaunch(`backendMode=${mode}`) + .pipe(Effect.tapError(() => settings.setBackendMode(previousMode))); } return state; }), From 16627f60cb3c2dc5192bfc12ea9bcb27fcb5cb0e Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Fri, 24 Jul 2026 16:23:33 -0700 Subject: [PATCH 07/10] fix(web): keep remote environment indicators in client-only mode The sidebar decided a thread or project was remote by comparing its environment against the primary environment id. A client-only desktop (and the hosted static web app) never registers a primary, so that id is always null and every row read as local: no cloud/server icon, and no environment name on the project header. Make the comparison explicit about the two different meanings of a null primary id. `isRemoteEnvironmentId` now takes an `ownsLocalEnvironment` flag: when the app can never serve an environment from its own backend, every environment is remote; when it can, a null primary id still means "the local backend has not registered yet" and nothing is flagged remote. Co-Authored-By: Claude Opus 5 (1M context) --- apps/web/src/components/CommandPalette.tsx | 9 +++++- apps/web/src/components/Sidebar.tsx | 17 ++++++++--- apps/web/src/components/SidebarV2.tsx | 15 ++++++---- .../src/components/ThreadStatusIndicators.tsx | 8 ++--- .../src/components/chat/DraftHeroHeadline.tsx | 9 +++++- apps/web/src/environmentGrouping.test.ts | 27 +++++++++++++++++ apps/web/src/environmentPresence.test.ts | 29 +++++++++++++++++++ apps/web/src/environmentPresence.ts | 28 ++++++++++++++++++ apps/web/src/routes/_chat.tsx | 6 ++-- apps/web/src/sidebarProjectGrouping.ts | 23 ++++++++------- apps/web/src/state/environments.ts | 27 +++++++++++++++++ 11 files changed, 170 insertions(+), 28 deletions(-) create mode 100644 apps/web/src/environmentPresence.test.ts create mode 100644 apps/web/src/environmentPresence.ts diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index aa7547c8ba6..b0f21b281f6 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -56,7 +56,11 @@ import { useEnvironmentQuery } from "../state/query"; import { sourceControlEnvironment } from "../state/sourceControl"; import { useAtomCommand } from "../state/use-atom-command"; import { useAtomQueryRunner } from "../state/use-atom-query-runner"; -import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; +import { + useAppOwnsLocalEnvironment, + useEnvironments, + usePrimaryEnvironmentId, +} from "../state/environments"; import { useProjects, useThreadShells } from "../state/entities"; import { resolveThreadActionProjectRef, startNewThreadFromContext } from "../lib/chatThreadActions"; import { @@ -492,6 +496,7 @@ function OpenCommandPaletteDialog(props: { const { environments } = useEnvironments(); const desktopLocalBootstraps = useDesktopLocalBootstraps(); const primaryEnvironmentId = usePrimaryEnvironmentId(); + const ownsLocalEnvironment = useAppOwnsLocalEnvironment(); const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread } = useHandleNewThread(); const projects = useProjects(); @@ -540,12 +545,14 @@ function OpenCommandPaletteDialog(props: { projects: clientSettings.sidebarProjectSortOrder === "manual" ? orderedProjects : projects, settings: projectGroupingSettings, primaryEnvironmentId, + ownsLocalEnvironment, resolveEnvironmentLabel: (environmentId) => environmentLabelById.get(environmentId) ?? null, }), [ clientSettings.sidebarProjectSortOrder, environmentLabelById, orderedProjects, + ownsLocalEnvironment, primaryEnvironmentId, projectGroupingSettings, projects, diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index a1d95eaa734..b814edce7fa 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -71,6 +71,7 @@ import { type SidebarThreadSortOrder, } from "@t3tools/contracts/settings"; import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; +import { isRemoteEnvironmentId } from "../environmentPresence"; import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstraps"; import { isElectron } from "../env"; import { useOpenPrLink } from "../lib/openPullRequestLink"; @@ -114,7 +115,13 @@ import { projectEnvironment } from "../state/projects"; import { useEnvironmentQuery } from "../state/query"; import { threadEnvironment, useEnvironmentThread } from "../state/threads"; import { vcsEnvironment } from "../state/vcs"; -import { useEnvironment, useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; +import { + useAppOwnsLocalEnvironment, + useEnvironment, + useEnvironmentPresenceScope, + useEnvironments, + usePrimaryEnvironmentId, +} from "../state/environments"; import { buildThreadRouteParams, resolveActiveThreadRouteRef, @@ -382,9 +389,8 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr reportFailure: false, }); const environment = useEnvironment(thread.environmentId); - const primaryEnvironmentId = usePrimaryEnvironmentId(); - const isRemoteThread = - primaryEnvironmentId !== null && thread.environmentId !== primaryEnvironmentId; + const presenceScope = useEnvironmentPresenceScope(); + const isRemoteThread = isRemoteEnvironmentId(thread.environmentId, presenceScope); const remoteEnvLabel = environment?.label ?? null; // A desktop-local secondary backend (e.g. the WSL backend) shows up as a // bearer environment whose connection id is prefixed "local:". It runs on the @@ -3038,6 +3044,7 @@ export default function Sidebar() { const shortcutModifiers = useShortcutModifierState(); const { environments } = useEnvironments(); const primaryEnvironmentId = usePrimaryEnvironmentId(); + const ownsLocalEnvironment = useAppOwnsLocalEnvironment(); const environmentLabelById = useMemo( () => new Map( @@ -3092,6 +3099,7 @@ export default function Sidebar() { projects: orderedProjects, settings: projectGroupingSettings, primaryEnvironmentId, + ownsLocalEnvironment, resolveEnvironmentLabel: (environmentId) => environmentLabelById.get(environmentId) ?? null, isDesktopLocalEnvironment: (environmentId) => desktopLocalEnvironmentIds.has(environmentId), }); @@ -3099,6 +3107,7 @@ export default function Sidebar() { environmentLabelById, desktopLocalEnvironmentIds, orderedProjects, + ownsLocalEnvironment, projectGroupingSettings, primaryEnvironmentId, ]); diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 8c5891ebe7e..c312a9533fa 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -88,7 +88,8 @@ import { startNewThreadFromContext } from "../lib/chatThreadActions"; import { useClientSettings, useUpdateClientSettings } from "../hooks/useSettings"; import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; import { useNowMinute } from "../hooks/useNowMinute"; -import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; +import { useEnvironmentPresenceScope, useEnvironments } from "../state/environments"; +import { isRemoteEnvironmentId, type EnvironmentPresenceScope } from "../environmentPresence"; import { useProjects, useThreadShells } from "../state/entities"; import { environmentServerConfigsAtom, primaryServerKeybindingsAtom } from "../state/server"; import { vcsEnvironment } from "../state/vcs"; @@ -370,7 +371,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { wokeAt: string | null; isActive: boolean; jumpLabel: string | null; - currentEnvironmentId: string | null; + presenceScope: EnvironmentPresenceScope; environmentLabel: string | null; projectCwd: string | null; projectTitle: string | null; @@ -523,8 +524,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { ? getTriggerDisplayModelLabel(selectedModel) : thread.modelSelection.model; - const isRemote = - props.currentEnvironmentId !== null && thread.environmentId !== props.currentEnvironmentId; + const isRemote = isRemoteEnvironmentId(thread.environmentId, props.presenceScope); const detailsTooltip = ( s.clearSelection); const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor); const toggleThreadSelection = useThreadSelectionStore((s) => s.toggleThread); @@ -1092,11 +1093,13 @@ export default function SidebarV2() { projects: sidebarProjectSortOrder === "manual" ? orderedProjects : projects, settings: projectGroupingSettings, primaryEnvironmentId, + ownsLocalEnvironment, resolveEnvironmentLabel: (environmentId) => environmentLabelById.get(environmentId) ?? null, }), [ environmentLabelById, orderedProjects, + ownsLocalEnvironment, primaryEnvironmentId, projectGroupingSettings, projects, @@ -2421,7 +2424,7 @@ export default function SidebarV2() { wokeAt={threadWokeAt(thread, { now: snoozeNow })} isActive={routeThreadKey === threadKey} jumpLabel={showJumpHints ? (jumpLabelByKey.get(threadKey) ?? null) : null} - currentEnvironmentId={primaryEnvironmentId} + presenceScope={presenceScope} environmentLabel={environmentLabelById.get(thread.environmentId) ?? null} projectCwd={ projectCwdByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? null diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index 0d3291a9e28..ecbb6d6c4a0 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -6,7 +6,8 @@ import { import type { VcsStatusResult } from "@t3tools/contracts"; import { CloudIcon, FolderGit2Icon, GitPullRequestIcon, TerminalIcon } from "lucide-react"; import { useMemo } from "react"; -import { useEnvironment, usePrimaryEnvironmentId } from "../state/environments"; +import { isRemoteEnvironmentId } from "../environmentPresence"; +import { useEnvironment, useEnvironmentPresenceScope } from "../state/environments"; import { useProject } from "../state/entities"; import { useEnvironmentQuery } from "../state/query"; import { useThreadRunningTerminalIds } from "../state/terminalSessions"; @@ -307,9 +308,8 @@ export function ThreadRowTrailingStatus({ thread }: { thread: SidebarThreadSumma threadId: thread.id, }); const environment = useEnvironment(thread.environmentId); - const primaryEnvironmentId = usePrimaryEnvironmentId(); - const isRemoteThread = - primaryEnvironmentId !== null && thread.environmentId !== primaryEnvironmentId; + const presenceScope = useEnvironmentPresenceScope(); + const isRemoteThread = isRemoteEnvironmentId(thread.environmentId, presenceScope); const remoteEnvLabel = environment?.label ?? null; const threadEnvironmentLabel = isRemoteThread ? (remoteEnvLabel ?? "Remote") : null; const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); diff --git a/apps/web/src/components/chat/DraftHeroHeadline.tsx b/apps/web/src/components/chat/DraftHeroHeadline.tsx index 98091f9aab6..6f1dd2a7be4 100644 --- a/apps/web/src/components/chat/DraftHeroHeadline.tsx +++ b/apps/web/src/components/chat/DraftHeroHeadline.tsx @@ -12,7 +12,11 @@ import { buildSidebarProjectSnapshots, } from "~/sidebarProjectGrouping"; import { useProjects, useThreadShells } from "~/state/entities"; -import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments"; +import { + useAppOwnsLocalEnvironment, + useEnvironments, + usePrimaryEnvironmentId, +} from "~/state/environments"; import { sortLogicalProjectsForSidebar } from "../Sidebar.logic"; import { Menu, @@ -37,6 +41,7 @@ export function DraftHeroHeadline({ const threads = useThreadShells(); const { environments } = useEnvironments(); const primaryEnvironmentId = usePrimaryEnvironmentId(); + const ownsLocalEnvironment = useAppOwnsLocalEnvironment(); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const projectSortOrder = useClientSettings((settings) => settings.sidebarProjectSortOrder); const handleNewThread = useNewThreadHandler(); @@ -56,6 +61,7 @@ export function DraftHeroHeadline({ projects, settings: projectGroupingSettings, primaryEnvironmentId, + ownsLocalEnvironment, resolveEnvironmentLabel: (environmentId) => environmentLabelById.get(environmentId) ?? null, }), @@ -64,6 +70,7 @@ export function DraftHeroHeadline({ ), [ environmentLabelById, + ownsLocalEnvironment, primaryEnvironmentId, projectGroupingSettings, projectSortOrder, diff --git a/apps/web/src/environmentGrouping.test.ts b/apps/web/src/environmentGrouping.test.ts index 17d86ca0912..c7968ee0eee 100644 --- a/apps/web/src/environmentGrouping.test.ts +++ b/apps/web/src/environmentGrouping.test.ts @@ -81,6 +81,33 @@ describe("environment grouping", () => { expect(projectGroupCount).toBe(1); }); + it("marks every project remote for an app with no local backend of its own", () => { + const remote = makeProject({ + id: ProjectId.make("project-remote"), + environmentId: remoteEnvironmentId, + }); + + const [clientOnly] = buildSidebarProjectSnapshots({ + projects: [remote], + settings: defaultGroupingSettings, + primaryEnvironmentId: null, + ownsLocalEnvironment: false, + resolveEnvironmentLabel: () => "remote", + }); + + expect(clientOnly?.environmentPresence).toBe("remote-only"); + expect(clientOnly?.remoteEnvironmentLabels).toEqual(["remote"]); + + const [managed] = buildSidebarProjectSnapshots({ + projects: [remote], + settings: defaultGroupingSettings, + primaryEnvironmentId: null, + resolveEnvironmentLabel: () => "remote", + }); + + expect(managed?.environmentPresence).toBe("local-only"); + }); + it("keeps projects without repository identity physically scoped", () => { const primary = makeProject(); const remote = makeProject({ diff --git a/apps/web/src/environmentPresence.test.ts b/apps/web/src/environmentPresence.test.ts new file mode 100644 index 00000000000..7e6e9daf47a --- /dev/null +++ b/apps/web/src/environmentPresence.test.ts @@ -0,0 +1,29 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { isRemoteEnvironmentId } from "./environmentPresence"; + +const primaryEnvironmentId = EnvironmentId.make("env-primary"); +const remoteEnvironmentId = EnvironmentId.make("env-remote"); + +describe("isRemoteEnvironmentId", () => { + it("treats the primary environment as local and everything else as remote", () => { + const scope = { primaryEnvironmentId, ownsLocalEnvironment: true }; + + expect(isRemoteEnvironmentId(primaryEnvironmentId, scope)).toBe(false); + expect(isRemoteEnvironmentId(remoteEnvironmentId, scope)).toBe(true); + }); + + it("treats every environment as remote when the app owns no local backend", () => { + const scope = { primaryEnvironmentId: null, ownsLocalEnvironment: false }; + + expect(isRemoteEnvironmentId(remoteEnvironmentId, scope)).toBe(true); + expect(isRemoteEnvironmentId(primaryEnvironmentId, scope)).toBe(true); + }); + + it("treats nothing as remote while a managed app's primary is still registering", () => { + const scope = { primaryEnvironmentId: null, ownsLocalEnvironment: true }; + + expect(isRemoteEnvironmentId(remoteEnvironmentId, scope)).toBe(false); + }); +}); diff --git a/apps/web/src/environmentPresence.ts b/apps/web/src/environmentPresence.ts new file mode 100644 index 00000000000..109f0cd8a8c --- /dev/null +++ b/apps/web/src/environmentPresence.ts @@ -0,0 +1,28 @@ +import type { EnvironmentId } from "@t3tools/contracts"; + +/** + * The comparison the UI uses to decide whether an environment is "remote" — + * i.e. somewhere other than the machine this app runs its own backend on. + */ +export interface EnvironmentPresenceScope { + /** The environment served by the app's own backend, when it has one. */ + readonly primaryEnvironmentId: EnvironmentId | null; + /** + * False when this app can never own a local backend: a client-only desktop + * and the hosted static web app only ever talk to backends they paired with, + * so every environment they reach is remote. This is deliberately separate + * from a null `primaryEnvironmentId`, which a managed app also reports while + * its local backend is still registering — there, nothing is remote yet. + */ + readonly ownsLocalEnvironment: boolean; +} + +export function isRemoteEnvironmentId( + environmentId: EnvironmentId, + scope: EnvironmentPresenceScope, +): boolean { + if (!scope.ownsLocalEnvironment) { + return true; + } + return scope.primaryEnvironmentId !== null && environmentId !== scope.primaryEnvironmentId; +} diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index d3cf003d99c..d38085bdbd8 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -6,7 +6,7 @@ import { isCommandPaletteOpen } from "../commandPaletteBus"; import { useClientSettings } from "../hooks/useSettings"; import { openCommandPalette } from "../commandPaletteBus"; import { useProjects } from "../state/entities"; -import { usePrimaryEnvironmentId } from "../state/environments"; +import { useAppOwnsLocalEnvironment, usePrimaryEnvironmentId } from "../state/environments"; import { selectProjectGroupingSettings } from "../logicalProject"; import { buildSidebarProjectSnapshots } from "../sidebarProjectGrouping"; import { dispatchPreviewAction } from "../components/preview/previewActionBus"; @@ -32,15 +32,17 @@ function ChatRouteGlobalShortcuts() { const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const projects = useProjects(); const primaryEnvironmentId = usePrimaryEnvironmentId(); + const ownsLocalEnvironment = useAppOwnsLocalEnvironment(); const projectGroupCount = useMemo( () => buildSidebarProjectSnapshots({ projects, settings: projectGroupingSettings, primaryEnvironmentId, + ownsLocalEnvironment, resolveEnvironmentLabel: () => null, }).length, - [primaryEnvironmentId, projectGroupingSettings, projects], + [ownsLocalEnvironment, primaryEnvironmentId, projectGroupingSettings, projects], ); const terminalOpen = useTerminalUiStateStore((state) => routeThreadRef diff --git a/apps/web/src/sidebarProjectGrouping.ts b/apps/web/src/sidebarProjectGrouping.ts index 32299b565f4..36859f37eea 100644 --- a/apps/web/src/sidebarProjectGrouping.ts +++ b/apps/web/src/sidebarProjectGrouping.ts @@ -1,5 +1,6 @@ import { scopeProjectRef } from "@t3tools/client-runtime/environment"; import type { EnvironmentId, ScopedProjectRef } from "@t3tools/contracts"; +import { isRemoteEnvironmentId, type EnvironmentPresenceScope } from "./environmentPresence"; import { deriveLogicalProjectKeyFromSettings, derivePhysicalProjectKey, @@ -116,6 +117,10 @@ export function buildSidebarProjectSnapshots(input: { projects: ReadonlyArray; settings: ProjectGroupingSettings; primaryEnvironmentId: EnvironmentId | null; + // False when the app has no backend of its own (a client-only desktop or the + // hosted static web app), which makes every member of a group remote. + // Defaults to true so callers that always own a local backend are unchanged. + ownsLocalEnvironment?: boolean; resolveEnvironmentLabel: (environmentId: EnvironmentId) => string | null; // Returns true when an env id maps to a desktopLocal saved-env // record (today: the WSL backend). Defaults to "false for every @@ -159,6 +164,10 @@ export function buildSidebarProjectSnapshots(input: { } } + const presenceScope: EnvironmentPresenceScope = { + primaryEnvironmentId: input.primaryEnvironmentId, + ownsLocalEnvironment: input.ownsLocalEnvironment ?? true, + }; const result: SidebarProjectSnapshot[] = []; const seen = new Set(); for (const project of input.projects) { @@ -177,17 +186,11 @@ export function buildSidebarProjectSnapshots(input: { continue; } - const hasLocal = - input.primaryEnvironmentId !== null && - members.some((member) => member.environmentId === input.primaryEnvironmentId); - const hasRemote = - input.primaryEnvironmentId !== null - ? members.some((member) => member.environmentId !== input.primaryEnvironmentId) - : false; - const remoteMembers = members.filter( - (member) => - input.primaryEnvironmentId !== null && member.environmentId !== input.primaryEnvironmentId, + const remoteMembers = members.filter((member) => + isRemoteEnvironmentId(member.environmentId, presenceScope), ); + const hasRemote = remoteMembers.length > 0; + const hasLocal = remoteMembers.length < members.length; const remoteEnvironmentLabels = remoteMembers .flatMap((member) => (member.environmentLabel ? [member.environmentLabel] : [])) .filter((label, index, labels) => labels.indexOf(label) === index); diff --git a/apps/web/src/state/environments.ts b/apps/web/src/state/environments.ts index 443e99b84cd..b761dfd37a8 100644 --- a/apps/web/src/state/environments.ts +++ b/apps/web/src/state/environments.ts @@ -9,6 +9,9 @@ import * as Option from "effect/Option"; import { useMemo } from "react"; import { environmentCatalog } from "../connection/catalog"; +import type { EnvironmentPresenceScope } from "../environmentPresence"; +import { isDesktopClientOnlyMode } from "../environments/primary"; +import { isHostedStaticApp } from "../hostedPairing"; import { environmentPresentations, useEnvironmentPresentation } from "./presentation"; import { primaryEnvironmentIdAtom } from "./primaryEnvironment"; import { useEnvironmentQuery } from "./query"; @@ -60,6 +63,30 @@ export function usePrimaryEnvironmentId(): EnvironmentId | null { return useAtomValue(primaryEnvironmentIdAtom); } +/** + * Whether this app can ever serve an environment from its own backend. A + * client-only desktop and the hosted static web app cannot, so they have no + * local environment for threads and projects to belong to. + */ +export function appOwnsLocalEnvironment(): boolean { + return !isHostedStaticApp() && !isDesktopClientOnlyMode(); +} + +export function useAppOwnsLocalEnvironment(): boolean { + // The answer is fixed for the app's lifetime: the hosted-static check is + // origin/build derived, and changing the desktop backend mode relaunches. + return useMemo(appOwnsLocalEnvironment, []); +} + +export function useEnvironmentPresenceScope(): EnvironmentPresenceScope { + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const ownsLocalEnvironment = useAppOwnsLocalEnvironment(); + return useMemo( + () => ({ primaryEnvironmentId, ownsLocalEnvironment }), + [primaryEnvironmentId, ownsLocalEnvironment], + ); +} + export function useEnvironment( environmentId: EnvironmentId | null, ): EnvironmentPresentation | null { From 2f70bbe3b90b5a91b8b60238c734881827b7c3c1 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Fri, 24 Jul 2026 17:10:23 -0700 Subject: [PATCH 08/10] fix(desktop): validate local pairing transitions --- .../app/DesktopLocalServerDiscovery.test.ts | 26 ++++++++++++++ .../src/app/DesktopLocalServerDiscovery.ts | 14 +++++++- .../src/ipc/methods/backendMode.test.ts | 34 +++++++++++++++++++ apps/desktop/src/ipc/methods/backendMode.ts | 21 +++++++----- .../src/localServerDiscovery.test.ts | 16 +++++++++ .../contracts/src/localServerDiscovery.ts | 4 ++- 6 files changed, 105 insertions(+), 10 deletions(-) create mode 100644 packages/contracts/src/localServerDiscovery.test.ts diff --git a/apps/desktop/src/app/DesktopLocalServerDiscovery.test.ts b/apps/desktop/src/app/DesktopLocalServerDiscovery.test.ts index c654f7322e7..1f2b83e2636 100644 --- a/apps/desktop/src/app/DesktopLocalServerDiscovery.test.ts +++ b/apps/desktop/src/app/DesktopLocalServerDiscovery.test.ts @@ -241,3 +241,29 @@ it.effect("removes the challenge file when the pairing request fails", () => expect(yield* fileSystem.exists(attempted[0] as string)).toBe(false); }).pipe(Effect.provide(NodeServices.layer), TestClock.withLive), ); + +it.effect("rejects a pairing URL that can retarget the minted local credential", () => + Effect.gen(function* () { + const { runtimeDirectory, advertisementDirectory } = yield* makeAdvertisementDirectory( + "t3-local-pairing-url-test-", + ); + yield* writeAdvertisement(advertisementDirectory, makeRecord()); + + const discovery = yield* make({ + platform: "linux", + xdgRuntimeDirectory: runtimeDirectory, + uid: process.getuid?.(), + probeEnvironment: () => Effect.succeed(descriptor), + postPairingChallenge: () => + Effect.succeed({ + pairingUrl: + "http://127.0.0.1:3773/pair?host=https%3A%2F%2Fattacker.example#token=PAIRCODE", + pairingExpiresAt: "2099-01-01T00:00:00.000Z", + }), + }); + + const error = yield* discovery.pairLocalServer("instance-local").pipe(Effect.flip); + expect(error.reason).toBe("request_failed"); + expect(error.detail).toContain("invalid pairing link"); + }).pipe(Effect.provide(NodeServices.layer), TestClock.withLive), +); diff --git a/apps/desktop/src/app/DesktopLocalServerDiscovery.ts b/apps/desktop/src/app/DesktopLocalServerDiscovery.ts index 7c9d558122e..2f03a7765f2 100644 --- a/apps/desktop/src/app/DesktopLocalServerDiscovery.ts +++ b/apps/desktop/src/app/DesktopLocalServerDiscovery.ts @@ -14,6 +14,7 @@ import { LOCAL_SERVER_ADVERTISEMENT_FILE_MODE, LOCAL_SERVER_ADVERTISEMENT_MAX_BYTES, LOCAL_SERVER_CHALLENGE_NONCE_BYTES, + isValidLocalServerPairingUrl, parseCanonicalLoopbackHttpBaseUrl, resolveLocalServerAdvertisementDirectory, resolveLocalServerChallengeDirectory, @@ -276,12 +277,23 @@ export const make = Effect.fn("desktop.localServerDiscovery.make")(function* ( // The nonce is the whole proof of local-user identity, so it must not // outlive the request regardless of how that request ends. - return yield* options + const result = yield* options .postPairingChallenge({ httpBaseUrl: advertisement.httpBaseUrl, challenge: { instanceId, challengePath, nonce }, }) .pipe(Effect.ensuring(fileSystem.remove(challengePath).pipe(Effect.ignore))); + const httpBaseUrl = parseCanonicalLoopbackHttpBaseUrl(advertisement.httpBaseUrl); + if ( + httpBaseUrl === null || + !isValidLocalServerPairingUrl({ pairingUrl: result.pairingUrl, httpBaseUrl }) + ) { + return yield* new LocalServerPairingError({ + reason: "request_failed", + detail: "The local T3 Code server returned an invalid pairing link.", + }); + } + return result; }); return DesktopLocalServerDiscovery.of({ discover, pairLocalServer }); diff --git a/apps/desktop/src/ipc/methods/backendMode.test.ts b/apps/desktop/src/ipc/methods/backendMode.test.ts index 32d875ade0f..659f555d6ab 100644 --- a/apps/desktop/src/ipc/methods/backendMode.test.ts +++ b/apps/desktop/src/ipc/methods/backendMode.test.ts @@ -16,6 +16,40 @@ const unusedLifecycleRuntimeLayer = Layer.empty as Layer.Layer; describe("backend mode IPC", () => { + it.effect("reports the active mode coherently while a successful relaunch is pending", () => { + const relaunchReasons: Array = []; + const layer = Layer.mergeAll( + DesktopBackendMode.layerTest(), + DesktopAppSettings.layerTest(), + Layer.succeed( + DesktopLifecycle.DesktopLifecycle, + DesktopLifecycle.DesktopLifecycle.of({ + relaunch: (reason) => Effect.sync(() => relaunchReasons.push(reason)), + register: Effect.void, + }), + ), + unusedLifecycleRuntimeLayer, + ); + + return Effect.gen(function* () { + const launchMode = yield* DesktopBackendMode.DesktopBackendMode; + const settings = yield* DesktopAppSettings.DesktopAppSettings; + yield* launchMode.latch("managed"); + + const state = yield* setBackendMode + .handler("client-only") + .pipe(Effect.flatMap(decodeBackendModeState)); + + assert.deepEqual(state, { + effectiveMode: "managed", + configuredMode: "managed", + cliOverride: null, + }); + assert.deepEqual(relaunchReasons, ["backendMode=client-only"]); + assert.equal((yield* settings.get).backendMode, "client-only"); + }).pipe(Effect.provide(layer)); + }); + it.effect("restores the configured mode when a packaged relaunch cannot be scheduled", () => { const relaunchError = new DesktopLifecycle.DesktopLifecycleRelaunchError({ reason: "backendMode=client-only", diff --git a/apps/desktop/src/ipc/methods/backendMode.ts b/apps/desktop/src/ipc/methods/backendMode.ts index 90f6b540de4..37c7ac795ad 100644 --- a/apps/desktop/src/ipc/methods/backendMode.ts +++ b/apps/desktop/src/ipc/methods/backendMode.ts @@ -45,19 +45,24 @@ export const setBackendMode = DesktopIpc.makeIpcMethod({ const previousMode = (yield* settings.get).backendMode; const change = yield* settings.setBackendMode(mode); const launchState = yield* launchMode.get; - const state = { - ...launchState, - configuredMode: change.settings.backendMode, - }; - if ( + const relaunchRequired = change.changed && launchState.cliOverride === null && - launchState.effectiveMode !== change.settings.backendMode - ) { + launchState.effectiveMode !== change.settings.backendMode; + if (relaunchRequired) { yield* lifecycle .relaunch(`backendMode=${mode}`) .pipe(Effect.tapError(() => settings.setBackendMode(previousMode))); + // The current process is still running in its latched launch mode until + // Electron exits. Keep both fields coherent during that brief interval. + return { + ...launchState, + configuredMode: launchState.effectiveMode, + }; } - return state; + return { + ...launchState, + configuredMode: change.settings.backendMode, + }; }), }); diff --git a/packages/contracts/src/localServerDiscovery.test.ts b/packages/contracts/src/localServerDiscovery.test.ts new file mode 100644 index 00000000000..6b42e2cbae4 --- /dev/null +++ b/packages/contracts/src/localServerDiscovery.test.ts @@ -0,0 +1,16 @@ +import { expect, it } from "vite-plus/test"; +import * as Schema from "effect/Schema"; + +import { LocalServerPairingChallenge } from "./localServerDiscovery.ts"; + +const decodePairingChallenge = Schema.decodeUnknownSync(LocalServerPairingChallenge); + +it("preserves filesystem-significant whitespace in pairing challenge paths", () => { + const challenge = decodePairingChallenge({ + instanceId: "instance-local", + challengePath: " /tmp/t3code challenge ", + nonce: "nonce", + }); + + expect(challenge.challengePath).toBe(" /tmp/t3code challenge "); +}); diff --git a/packages/contracts/src/localServerDiscovery.ts b/packages/contracts/src/localServerDiscovery.ts index 7603778c0c9..1973647a84c 100644 --- a/packages/contracts/src/localServerDiscovery.ts +++ b/packages/contracts/src/localServerDiscovery.ts @@ -35,7 +35,9 @@ export type LocalServerAdvertisement = typeof LocalServerAdvertisement.Type; */ export const LocalServerPairingChallenge = Schema.Struct({ instanceId: TrimmedNonEmptyString, - challengePath: TrimmedNonEmptyString, + // Filesystem paths are opaque bytes represented as strings. Do not trim: + // leading or trailing whitespace can be part of a legitimate filename. + challengePath: Schema.String.check(Schema.isMinLength(1)), nonce: TrimmedNonEmptyString, }); export type LocalServerPairingChallenge = typeof LocalServerPairingChallenge.Type; From f1d36b1772b0631489409ceb0eaafa5b079f197e Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Fri, 24 Jul 2026 17:45:11 -0700 Subject: [PATCH 09/10] fix(web): retain reconnecting local pairings --- .../src/components/settings/ConnectionsSettings.logic.test.ts | 4 ++-- apps/web/src/components/settings/ConnectionsSettings.logic.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts b/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts index 22ae4f19786..3a4699536d8 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts +++ b/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts @@ -98,7 +98,7 @@ describe("selectLocalServerPairingCandidates", () => { ]); }); - it("suppresses usable saved environments and offers Pair again for failed credentials", () => { + it("suppresses connected saved environments and offers Pair again while reconnecting", () => { expect( selectLocalServerPairingCandidates( [advertisement], @@ -116,7 +116,7 @@ describe("selectLocalServerPairingCandidates", () => { [ { environmentId: advertisement.environmentId, - connection: { phase: "error" }, + connection: { phase: "reconnecting" }, }, ], ), diff --git a/apps/web/src/components/settings/ConnectionsSettings.logic.ts b/apps/web/src/components/settings/ConnectionsSettings.logic.ts index da9b782b015..eec3ff06aeb 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.logic.ts +++ b/apps/web/src/components/settings/ConnectionsSettings.logic.ts @@ -23,7 +23,7 @@ export function selectLocalServerPairingCandidates( const savedEnvironment = environments.find( (environment) => environment.environmentId === advertisement.environmentId, ); - if (savedEnvironment && savedEnvironment.connection.phase !== "error") { + if (savedEnvironment?.connection.phase === "connected") { return []; } return [ From ec578bf9a0c8753031e51f3afe02cd0acf1f3f9d Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Fri, 24 Jul 2026 17:42:53 -0700 Subject: [PATCH 10/10] fix(desktop): surface client-only startup failures --- apps/desktop/src/app/DesktopApp.ts | 32 ++++++--- apps/desktop/src/app/DesktopAppErrors.test.ts | 68 +++++++++++++++++++ 2 files changed, 91 insertions(+), 9 deletions(-) diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index 904120558e1..5aec848e169 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -6,6 +6,7 @@ import * as Path from "effect/Path"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; +import type { DesktopBackendMode as DesktopBackendModeValue } from "@t3tools/contracts"; import * as NetService from "@t3tools/shared/Net"; import * as Crypto from "effect/Crypto"; import * as ElectronApp from "../electron/ElectronApp.ts"; @@ -150,6 +151,26 @@ const handleFatalStartupError = Effect.fn("desktop.startup.handleFatalStartupErr const fatalStartupCause = (stage: string, cause: Cause.Cause) => handleFatalStartupError(stage, Cause.pretty(cause)).pipe(Effect.andThen(Effect.failCause(cause))); +export const latchDesktopBackendModeForStartup = Effect.fn( + "desktop.startup.latchDesktopBackendMode", +)(function* (configuredMode: DesktopBackendModeValue) { + const backendMode = yield* DesktopBackendMode.DesktopBackendMode; + return yield* backendMode + .latch(configuredMode) + .pipe(Effect.catchCause((cause) => fatalStartupCause("backendMode", cause))); +}); + +export const handleClientOnlyRendererReady = ( + rendererReady: Effect.Effect, +): Effect.Effect => + rendererReady.pipe( + Effect.tapError((error) => + logBootstrapWarning("failed to open main window after renderer readiness", { + error: error.message, + }), + ), + ); + const resolvePackagedClientRoot = Effect.fn("desktop.bootstrap.resolvePackagedClientRoot")( function* (candidates: readonly string[]) { const fileSystem = yield* FileSystem.FileSystem; @@ -199,13 +220,7 @@ const bootstrap = Effect.gen(function* () { yield* installDesktopIpcHandlers(); yield* logBootstrapInfo("bootstrap ipc handlers registered"); if (!(yield* Ref.get(state.quitting))) { - yield* desktopWindow.handleRendererReady.pipe( - Effect.catch((error) => - logBootstrapWarning("failed to open main window after renderer readiness", { - error: error.message, - }), - ), - ); + yield* handleClientOnlyRendererReady(desktopWindow.handleRendererReady); } return; } @@ -289,7 +304,6 @@ const startup = Effect.gen(function* () { const clerk = yield* DesktopClerk.DesktopClerk; const shellEnvironment = yield* DesktopShellEnvironment.DesktopShellEnvironment; const desktopSettings = yield* DesktopAppSettings.DesktopAppSettings; - const backendMode = yield* DesktopBackendMode.DesktopBackendMode; const updates = yield* DesktopUpdates.DesktopUpdates; const environment = yield* DesktopEnvironment.DesktopEnvironment; @@ -298,7 +312,7 @@ const startup = Effect.gen(function* () { yield* electronApp.setPath("userData", userDataPath); yield* logStartupInfo("runtime logging configured", { logDir: environment.logDir }); const settings = yield* desktopSettings.load; - const launchMode = yield* backendMode.latch(settings.backendMode); + const launchMode = yield* latchDesktopBackendModeForStartup(settings.backendMode); yield* logStartupInfo("desktop backend mode selected", { effectiveMode: launchMode.effectiveMode, configuredMode: launchMode.configuredMode, diff --git a/apps/desktop/src/app/DesktopAppErrors.test.ts b/apps/desktop/src/app/DesktopAppErrors.test.ts index 666c36d391d..eba9723c703 100644 --- a/apps/desktop/src/app/DesktopAppErrors.test.ts +++ b/apps/desktop/src/app/DesktopAppErrors.test.ts @@ -1,9 +1,22 @@ import { assert, describe, it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as ElectronApp from "../electron/ElectronApp.ts"; +import * as ElectronDialog from "../electron/ElectronDialog.ts"; import { DesktopBackendPortUnavailableError, DesktopDevelopmentBackendPortRequiredError, + handleClientOnlyRendererReady, + latchDesktopBackendModeForStartup, } from "./DesktopApp.ts"; +import * as DesktopBackendMode from "./DesktopBackendMode.ts"; +import * as DesktopShutdown from "./DesktopShutdown.ts"; +import * as DesktopState from "./DesktopState.ts"; describe("DesktopApp errors", () => { it("preserves unavailable backend port context", () => { @@ -27,4 +40,59 @@ describe("DesktopApp errors", () => { assert.equal(error.message, "T3CODE_PORT is required in desktop development."); }); + + it.effect("propagates client-only window creation failures", () => + Effect.gen(function* () { + const error = new Error("window creation failed"); + + const exit = yield* Effect.exit(handleClientOnlyRendererReady(Effect.fail(error))); + assert(Exit.isFailure(exit)); + const failure = Cause.findErrorOption(exit.cause); + assert(Option.isSome(failure)); + assert.strictEqual(failure.value, error); + }), + ); + + it.effect("reports invalid backend-mode launch arguments as fatal startup errors", () => + Effect.gen(function* () { + const quitCount = yield* Ref.make(0); + const shownErrors = yield* Ref.make([]); + + const layer = Layer.mergeAll( + DesktopBackendMode.layerTest(["electron", "--backend-mode=invalid"]), + DesktopShutdown.layer, + DesktopState.layer, + Layer.mock(ElectronApp.ElectronApp)({ + quit: Ref.update(quitCount, (count) => count + 1), + }), + Layer.mock(ElectronDialog.ElectronDialog)({ + showErrorBox: (title, content) => + Ref.update(shownErrors, (errors) => [...errors, { title, content }]), + }), + ); + + yield* Effect.gen(function* () { + const exit = yield* Effect.exit(latchDesktopBackendModeForStartup("managed")); + assert(Exit.isFailure(exit)); + const failure = Cause.findErrorOption(exit.cause); + assert(Option.isSome(failure)); + assert( + DesktopBackendMode.isDesktopBackendModeArgumentError(failure.value), + "expected the original backend mode argument error", + ); + + const errors = yield* Ref.get(shownErrors); + assert.equal(errors.length, 1); + assert.equal(errors[0]?.title, "T3 Code failed to start"); + assert.include(errors[0]?.content ?? "", "Stage: backendMode"); + assert.include(errors[0]?.content ?? "", 'Invalid --backend-mode value "invalid"'); + assert.equal(yield* Ref.get(quitCount), 1); + + const state = yield* DesktopState.DesktopState; + assert.isTrue(yield* Ref.get(state.quitting)); + const shutdown = yield* DesktopShutdown.DesktopShutdown; + yield* shutdown.awaitRequest; + }).pipe(Effect.provide(layer)); + }), + ); });