From f805420fa749c2fd33baecd175f561209e7ad375 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Fri, 24 Jul 2026 12:46:47 -0700 Subject: [PATCH 01/17] 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 fd86c5f05d25..1f98c7ec6995 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 000000000000..de9394da9f92 --- /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 000000000000..81819b41005f --- /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 15d23f8e1522..b1eeacfff813 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 c991f5b39d6b..cb7afe599860 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 e3a4de661ac0..497d00a5a54b 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 fa0811d5df7b..21a276020154 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 dcfee93778d1..3d7b0e6a2f1c 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 92e300004277..2d35913e3552 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 03c7ef64fd73..d65e563585d9 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 e478d0c6effd..d72a2f6226ab 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 5988b1e42f96..eb67c8b59fa4 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 000000000000..d6c9b7acd52b --- /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 13e6e8d39563..3d0d23003ef0 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 a4e98aaabadd..784c5f6223c2 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 9795f04e8ae6..282a69c4c0ed 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 9f01baeed902..3fe4e1104244 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 3878b0e36ad0..f7835e6069d5 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 466c9a9b5f8a..25befef96fb3 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 32224c7a5ca0..85757a468cf0 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 168846466ed7..f8071f634aed 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 3cd06cfcf32e..1942fa10eec0 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"), @@ -438,6 +439,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 40788009d3b3..213a6c405c77 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 @@ -728,6 +731,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, @@ -755,10 +763,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 52b895301279..03f95707581e 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 56d25fa142eb..699711ffcc30 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 e200ecf2ff87..6ee612f54c15 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 f85c91c1536e..ed01813c67d2 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 cc002419fe78..f3fed57afeb3 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 346991d114de..ed943e7e59ca 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 f6f28262a7c9..d13a2c112127 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; @@ -977,6 +991,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 eeb8441d95038ebda21a1f0f58af1ff327a579ac Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Fri, 24 Jul 2026 13:12:02 -0700 Subject: [PATCH 02/17] 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 1f98c7ec6995..904120558e1e 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 f8e05915718e..b087bc632092 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 000000000000..350ae040c397 --- /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 3fe4e1104244..ad2af3ab4d79 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 6702e38abce1aa11b56b178c1954514491c55b50 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Fri, 24 Jul 2026 12:47:55 -0700 Subject: [PATCH 03/17] 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 000000000000..fe32f2c6f7c3 --- /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 000000000000..c2b4c9cfef88 --- /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 d72a2f6226ab..24c6ea3e8877 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 eb67c8b59fa4..f68ebe96459f 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 000000000000..0291ed84aa61 --- /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 282a69c4c0ed..ab7148597a11 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 ad2af3ab4d79..7e15e07c8b41 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 000000000000..2d5695ca518d --- /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 000000000000..e3f7eb29f5a1 --- /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 b52b577c5b5a..932eacf77d8e 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 03c01170f158..f40e602d4a73 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 7df131669bab..1a2c5040e27d 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 c8f039e64282..f44288275230 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 362a24dd3ff2..da9b782b015f 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 03f95707581e..c8994d4c20a9 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 5936cb0fc17c..61764a8ae27a 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 d13a2c112127..c53efb11ec2d 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 { @@ -998,6 +999,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 000000000000..4c45e1e7abef --- /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 8a45591fd369..cee8cb7dd9b0 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -202,6 +202,10 @@ "./devProxy": { "types": "./src/devProxy.ts", "import": "./src/devProxy.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 000000000000..2460ed52f28d --- /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 000000000000..1b778ce55d6f --- /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 851449b8da0c08e139a42f9a9a4ce909400a7aca Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Fri, 24 Jul 2026 13:25:34 -0700 Subject: [PATCH 04/17] 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 fe32f2c6f7c3..c654f7322e7c 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 c2b4c9cfef88..7c9d558122e8 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 24c6ea3e8877..290ae3342c8e 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 f68ebe96459f..91ecb1b2b686 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 0291ed84aa61..498990677bc1 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 7e15e07c8b41..c7825a38b263 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 780aaabde251..2438a0b44e04 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 000000000000..b8008e063919 --- /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 000000000000..adfb00e72e8d --- /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 32a6799b01f4..460c45390d1c 100644 --- a/apps/server/src/auth/utils.ts +++ b/apps/server/src/auth/utils.ts @@ -165,6 +165,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 2d5695ca518d..fd0aec8e0a1b 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 e3f7eb29f5a1..c56fae5a8548 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 000000000000..b92131475fc4 --- /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 a3b2e3715fd6..9db5e07af223 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 { resolveAvailableEditorsForConfig } from "./ws.ts"; import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; @@ -637,7 +638,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 0412d910b8cb..9f109347dce1 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"; @@ -346,6 +347,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 932eacf77d8e..b399173b2ffb 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 f40e602d4a73..8198d93ef17d 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 1a2c5040e27d..7964f50eed7b 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 f44288275230..22ae4f19786b 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 c8994d4c20a9..bb399dac8564 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 ce43faacc40f..6d7778dfadf2 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 2d40dad60cc4..ce928ea32841 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 c53efb11ec2d..d9daa18a8968 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 { @@ -1000,6 +1000,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 4c45e1e7abef..7603778c0c93 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 1b778ce55d6f..b65bf3e88588 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 66d5d1b973607899f7039dda9fc1ef6ddd31927d Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Fri, 24 Jul 2026 14:56:07 -0700 Subject: [PATCH 05/17] 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 fd0aec8e0a1b..3c4f5ea540be 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 c56fae5a8548..064a52c3be7f 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 bb399dac8564..4d94d0b48957 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 9e8a1b34ec0e940780275e8ee329768d014f5860 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Fri, 24 Jul 2026 16:18:38 -0700 Subject: [PATCH 06/17] 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 350ae040c397..32d875ade0fc 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 d6c9b7acd52b..90f6b540de42 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 9d9a745f95cceab49bf19142f03b5b93055f79e0 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Fri, 24 Jul 2026 16:23:33 -0700 Subject: [PATCH 07/17] 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 aa7547c8ba60..b0f21b281f62 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 b05b3a39d904..3d9558eda553 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 @@ -3037,6 +3043,7 @@ export default function Sidebar() { const shortcutModifiers = useShortcutModifierState(); const { environments } = useEnvironments(); const primaryEnvironmentId = usePrimaryEnvironmentId(); + const ownsLocalEnvironment = useAppOwnsLocalEnvironment(); const environmentLabelById = useMemo( () => new Map( @@ -3091,6 +3098,7 @@ export default function Sidebar() { projects: orderedProjects, settings: projectGroupingSettings, primaryEnvironmentId, + ownsLocalEnvironment, resolveEnvironmentLabel: (environmentId) => environmentLabelById.get(environmentId) ?? null, isDesktopLocalEnvironment: (environmentId) => desktopLocalEnvironmentIds.has(environmentId), }); @@ -3098,6 +3106,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 ed9eadd907b7..ebfc21e44440 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"; @@ -371,7 +372,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); @@ -1098,11 +1099,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, @@ -2427,7 +2430,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 af53d1a78b20..8e57a5f4c37e 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"; @@ -301,9 +302,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 98091f9aab69..6f1dd2a7be41 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 17d86ca09128..c7968ee0eee4 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 000000000000..7e6e9daf47a8 --- /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 000000000000..109f0cd8a8c2 --- /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 d3cf003d99c0..d38085bdbd8c 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 32299b565f4b..36859f37eea6 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 443e99b84cdc..b761dfd37a81 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 8416192ed3c6eed21d54ce14bf6a04ad1334d545 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Fri, 24 Jul 2026 17:10:23 -0700 Subject: [PATCH 08/17] 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 c654f7322e7c..1f2b83e2636d 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 7c9d558122e8..2f03a7765f22 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 32d875ade0fc..659f555d6ab9 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 90f6b540de42..37c7ac795ad8 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 000000000000..6b42e2cbae43 --- /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 7603778c0c93..1973647a84c8 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 81e26f317247a7e71b7ec55fec1e895d2c4f4638 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Fri, 24 Jul 2026 17:45:11 -0700 Subject: [PATCH 09/17] 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 22ae4f19786b..3a4699536d89 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 da9b782b015f..eec3ff06aeb6 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 1f16d4e569b1d2dfdf8ea2f7c308dcfdba85c251 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Fri, 24 Jul 2026 17:42:53 -0700 Subject: [PATCH 10/17] 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 904120558e1e..5aec848e169a 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 666c36d391de..eba9723c7031 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)); + }), + ); }); From cd14c83ab0ba9d941f88677260ee5eb2606d0ed7 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Mon, 27 Jul 2026 18:20:48 -0700 Subject: [PATCH 11/17] fix(desktop): tolerate client-only window failures --- apps/desktop/src/app/DesktopApp.ts | 4 ++-- apps/desktop/src/app/DesktopAppErrors.test.ts | 7 ++----- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index 5aec848e169a..fd32b29772ce 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -162,9 +162,9 @@ export const latchDesktopBackendModeForStartup = Effect.fn( export const handleClientOnlyRendererReady = ( rendererReady: Effect.Effect, -): Effect.Effect => +): Effect.Effect => rendererReady.pipe( - Effect.tapError((error) => + Effect.catch((error) => logBootstrapWarning("failed to open main window after renderer readiness", { error: error.message, }), diff --git a/apps/desktop/src/app/DesktopAppErrors.test.ts b/apps/desktop/src/app/DesktopAppErrors.test.ts index eba9723c7031..f50411317065 100644 --- a/apps/desktop/src/app/DesktopAppErrors.test.ts +++ b/apps/desktop/src/app/DesktopAppErrors.test.ts @@ -41,15 +41,12 @@ describe("DesktopApp errors", () => { assert.equal(error.message, "T3CODE_PORT is required in desktop development."); }); - it.effect("propagates client-only window creation failures", () => + it.effect("swallows client-only window creation failures after logging", () => 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); + assert(Exit.isSuccess(exit)); }), ); From 5e3277fdf5e9c2ec15e333d8c749972ed2f6bbeb Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Mon, 27 Jul 2026 19:15:26 -0700 Subject: [PATCH 12/17] fix: preserve client-only startup and listener failures --- apps/desktop/src/app/DesktopApp.ts | 4 ++-- apps/desktop/src/app/DesktopAppErrors.test.ts | 7 +++++-- apps/server/src/localServerAdvertisement.test.ts | 7 ++++++- apps/server/src/localServerAdvertisement.ts | 13 ++++++++++--- apps/server/src/serverRuntimeStartup.ts | 4 +++- apps/server/src/startupAccess.test.ts | 6 ++++++ apps/server/src/startupAccess.ts | 12 ++++++++++-- 7 files changed, 42 insertions(+), 11 deletions(-) diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index fd32b29772ce..5aec848e169a 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -162,9 +162,9 @@ export const latchDesktopBackendModeForStartup = Effect.fn( export const handleClientOnlyRendererReady = ( rendererReady: Effect.Effect, -): Effect.Effect => +): Effect.Effect => rendererReady.pipe( - Effect.catch((error) => + Effect.tapError((error) => logBootstrapWarning("failed to open main window after renderer readiness", { error: error.message, }), diff --git a/apps/desktop/src/app/DesktopAppErrors.test.ts b/apps/desktop/src/app/DesktopAppErrors.test.ts index f50411317065..14c2f0435e5a 100644 --- a/apps/desktop/src/app/DesktopAppErrors.test.ts +++ b/apps/desktop/src/app/DesktopAppErrors.test.ts @@ -41,12 +41,15 @@ describe("DesktopApp errors", () => { assert.equal(error.message, "T3CODE_PORT is required in desktop development."); }); - it.effect("swallows client-only window creation failures after logging", () => + it.effect("preserves client-only window creation failures after logging", () => Effect.gen(function* () { const error = new Error("window creation failed"); const exit = yield* Effect.exit(handleClientOnlyRendererReady(Effect.fail(error))); - assert(Exit.isSuccess(exit)); + assert(Exit.isFailure(exit)); + const failure = Cause.findErrorOption(exit.cause); + assert(Option.isSome(failure)); + assert.strictEqual(failure.value, error); }), ); diff --git a/apps/server/src/localServerAdvertisement.test.ts b/apps/server/src/localServerAdvertisement.test.ts index 3c4f5ea540be..e3a1a2da8d24 100644 --- a/apps/server/src/localServerAdvertisement.test.ts +++ b/apps/server/src/localServerAdvertisement.test.ts @@ -9,6 +9,7 @@ import * as Path from "effect/Path"; import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; +import { HttpServer } from "effect/unstable/http"; import * as ServerConfig from "./config.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; @@ -52,7 +53,11 @@ const runAdvertisement = Effect.fn(function* (input: { }) { const discoveryState = yield* LocalServerDiscoveryState.LocalServerDiscoveryState; let advertisement = startLocalServerAdvertisement({ - connectionString: "http://127.0.0.1:3773", + listeningAddress: { + _tag: "TcpAddress", + hostname: "127.0.0.1", + port: 3773, + } satisfies HttpServer.Address, platform: input.platform ?? "linux", xdgRuntimeDirectory: input.runtimeDirectory, }).pipe( diff --git a/apps/server/src/localServerAdvertisement.ts b/apps/server/src/localServerAdvertisement.ts index 064a52c3be7f..37b756046686 100644 --- a/apps/server/src/localServerAdvertisement.ts +++ b/apps/server/src/localServerAdvertisement.ts @@ -15,6 +15,7 @@ import * as Exit from "effect/Exit"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; +import { HttpServer } from "effect/unstable/http"; import * as ServerConfig from "./config.ts"; import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; @@ -59,7 +60,7 @@ const writeAdvertisement = Effect.fn("server.localAdvertisement.write")(function */ export const startLocalServerAdvertisement = Effect.fn("server.localAdvertisement.start")( function* (input: { - readonly connectionString: string; + readonly listeningAddress: HttpServer.Address; readonly platform?: NodeJS.Platform; readonly xdgRuntimeDirectory?: string; }) { @@ -78,8 +79,14 @@ export const startLocalServerAdvertisement = Effect.fn("server.localAdvertisemen xdgRuntimeDirectory, path, }); - const port = Number(new URL(input.connectionString).port); - const httpBaseUrl = resolveLocalAdvertisementHttpBaseUrl(serverConfig.host, port); + const httpBaseUrl = + input.listeningAddress._tag === "TcpAddress" + ? resolveLocalAdvertisementHttpBaseUrl( + serverConfig.host, + input.listeningAddress.port, + input.listeningAddress.hostname, + ) + : null; if ( directory === null || httpBaseUrl === null || diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index b399173b2ffb..0a0feef2321d 100644 --- a/apps/server/src/serverRuntimeStartup.ts +++ b/apps/server/src/serverRuntimeStartup.ts @@ -21,6 +21,7 @@ import * as Queue from "effect/Queue"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; +import { HttpServer } from "effect/unstable/http"; import * as ServerConfig from "./config.ts"; import * as Keybindings from "./keybindings.ts"; @@ -451,8 +452,9 @@ export const make = Effect.gen(function* () { if (serverConfig.startupPresentation === "headless") { yield* Effect.logDebug("startup phase: headless access info"); const accessInfo = yield* issueHeadlessServeAccessInfo(); + const httpServer = yield* HttpServer.HttpServer; yield* startLocalServerAdvertisement({ - connectionString: accessInfo.connectionString, + listeningAddress: httpServer.address, }); yield* runStartupPhase( "headless.output", diff --git a/apps/server/src/startupAccess.test.ts b/apps/server/src/startupAccess.test.ts index 8198d93ef17d..0a560ef9a927 100644 --- a/apps/server/src/startupAccess.test.ts +++ b/apps/server/src/startupAccess.test.ts @@ -62,6 +62,12 @@ 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("localhost", 3773, "::1")).toBe( + "http://[::1]:3773/", + ); + expect(resolveLocalAdvertisementHttpBaseUrl("localhost", 3773, "127.0.0.1")).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(); diff --git a/apps/server/src/startupAccess.ts b/apps/server/src/startupAccess.ts index 7964f50eed7b..8c56a6fff40f 100644 --- a/apps/server/src/startupAccess.ts +++ b/apps/server/src/startupAccess.ts @@ -100,12 +100,20 @@ export const buildPairingUrl = (connectionString: string, token: string): string export const resolveLocalAdvertisementHttpBaseUrl = ( host: string | undefined, port: number, + listeningHost?: string, ): 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); + const normalizedListeningHost = listeningHost ? normalizeHost(listeningHost) : null; + if (normalizedListeningHost !== null && !isLoopbackHost(normalizedListeningHost)) { + return null; + } + const normalized = normalizedListeningHost ?? (host ? normalizeHost(host) : "127.0.0.1"); + const canonicalHost = + listeningHost === undefined && normalized === "localhost" + ? "127.0.0.1" + : formatHostForUrl(normalized); return `http://${canonicalHost}:${port}/`; }; From 29c4fab7fdd43a9aeb79853e5becc2e3283c5719 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Mon, 27 Jul 2026 19:26:08 -0700 Subject: [PATCH 13/17] fix(web): surface local discovery failures --- .../settings/ConnectionsSettings.tsx | 45 ++++++++++++------- 1 file changed, 29 insertions(+), 16 deletions(-) diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 4d94d0b48957..d2eba1cf3c85 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -1761,6 +1761,8 @@ export function ConnectionsSettings() { () => selectLocalServerPairingCandidates(localServerAdvertisements, environments), [environments, localServerAdvertisements], ); + const hasLocalServerDiscoveryContent = + localServerPairingCandidates.length > 0 || localServerDiscoveryError !== null; const refreshLocalServerAdvertisements = useCallback(async () => { const discoverLocalServers = desktopBridge?.discoverLocalServers; if (!discoverLocalServers) { @@ -3533,7 +3535,7 @@ export function ConnectionsSettings() { )} - {localServerPairingCandidates.length > 0 ? ( + {hasLocalServerDiscoveryContent ? ( } > -

- 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. -

+ {localServerPairingCandidates.length > 0 ? ( +

+ 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. +

+ ) : null} {renderLocalServerPairingCandidates()}
) : null} @@ -3603,16 +3607,25 @@ export function ConnectionsSettings() {
- {localServerPairingCandidates.length > 0 ? ( -
-
-

- A local T3 Code server is running -

-

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

-
+ {hasLocalServerDiscoveryContent ? ( +
0 + ? "border-primary/20 bg-primary/5" + : "border-destructive/30 bg-destructive/5", + )} + > + {localServerPairingCandidates.length > 0 ? ( +
+

+ A local T3 Code server is running +

+

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

+
+ ) : null} {renderLocalServerPairingCandidates()}
) : null} From b9a3a9727864ae12d0cd0d53c9719586fcc83b03 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Mon, 27 Jul 2026 19:31:50 -0700 Subject: [PATCH 14/17] fix(server): canonicalize advertised localhost listeners --- apps/server/src/startupAccess.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/server/src/startupAccess.ts b/apps/server/src/startupAccess.ts index 8c56a6fff40f..30d8961093c6 100644 --- a/apps/server/src/startupAccess.ts +++ b/apps/server/src/startupAccess.ts @@ -111,9 +111,7 @@ export const resolveLocalAdvertisementHttpBaseUrl = ( } const normalized = normalizedListeningHost ?? (host ? normalizeHost(host) : "127.0.0.1"); const canonicalHost = - listeningHost === undefined && normalized === "localhost" - ? "127.0.0.1" - : formatHostForUrl(normalized); + normalized === "localhost" ? "127.0.0.1" : formatHostForUrl(normalized); return `http://${canonicalHost}:${port}/`; }; From bf484ddf23d64c679197ae73f0258089eff4e86d Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Mon, 27 Jul 2026 19:37:19 -0700 Subject: [PATCH 15/17] fix(web): keep local discovery visible while scanning --- .../components/settings/ConnectionsSettings.tsx | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index d2eba1cf3c85..6377a32ff0c9 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -1762,7 +1762,9 @@ export function ConnectionsSettings() { [environments, localServerAdvertisements], ); const hasLocalServerDiscoveryContent = - localServerPairingCandidates.length > 0 || localServerDiscoveryError !== null; + isDiscoveringLocalServers || + localServerPairingCandidates.length > 0 || + localServerDiscoveryError !== null; const refreshLocalServerAdvertisements = useCallback(async () => { const discoverLocalServers = desktopBridge?.discoverLocalServers; if (!discoverLocalServers) { @@ -1770,10 +1772,10 @@ export function ConnectionsSettings() { return EMPTY_LOCAL_SERVER_ADVERTISEMENTS; } setIsDiscoveringLocalServers(true); - setLocalServerDiscoveryError(null); try { const discovered = await discoverLocalServers(); setLocalServerAdvertisements(discovered); + setLocalServerDiscoveryError(null); setIsDiscoveringLocalServers(false); return discovered; } catch (error) { @@ -2538,6 +2540,12 @@ export function ConnectionsSettings() { ); const renderLocalServerPairingCandidates = () => (
+ {isDiscoveringLocalServers && localServerPairingCandidates.length === 0 ? ( +

+ + Scanning for local T3 Code servers… +

+ ) : null} {localServerPairingCandidates.map(({ advertisement, pairAgain }) => (
0 ? "border-primary/20 bg-primary/5" - : "border-destructive/30 bg-destructive/5", + : localServerDiscoveryError + ? "border-destructive/30 bg-destructive/5" + : "border-border/70 bg-muted/20", )} > {localServerPairingCandidates.length > 0 ? ( From 00de753dcce98f11875b9baf0cb08d908e53dd42 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Mon, 27 Jul 2026 19:41:31 -0700 Subject: [PATCH 16/17] fix(web): track overlapping local discovery scans --- apps/web/src/components/settings/ConnectionsSettings.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 6377a32ff0c9..d4899a6043a2 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -1725,7 +1725,8 @@ export function ConnectionsSettings() { const [localServerAdvertisements, setLocalServerAdvertisements] = useState< ReadonlyArray >(EMPTY_LOCAL_SERVER_ADVERTISEMENTS); - const [isDiscoveringLocalServers, setIsDiscoveringLocalServers] = useState(false); + const [activeLocalServerDiscoveryCount, setActiveLocalServerDiscoveryCount] = useState(0); + const isDiscoveringLocalServers = activeLocalServerDiscoveryCount > 0; const [pairingLocalServerInstanceId, setPairingLocalServerInstanceId] = useState( null, ); @@ -1771,20 +1772,20 @@ export function ConnectionsSettings() { setLocalServerAdvertisements(EMPTY_LOCAL_SERVER_ADVERTISEMENTS); return EMPTY_LOCAL_SERVER_ADVERTISEMENTS; } - setIsDiscoveringLocalServers(true); + setActiveLocalServerDiscoveryCount((count) => count + 1); try { const discovered = await discoverLocalServers(); setLocalServerAdvertisements(discovered); setLocalServerDiscoveryError(null); - 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; + } finally { + setActiveLocalServerDiscoveryCount((count) => Math.max(0, count - 1)); } }, [desktopBridge]); From 4624c4bbff39549df95da35adab838b0294dc912 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Mon, 27 Jul 2026 20:01:35 -0700 Subject: [PATCH 17/17] fix(desktop): preserve pending mode and challenge cleanup --- .../app/DesktopLocalServerDiscovery.test.ts | 47 +++++++++++++++++++ .../src/app/DesktopLocalServerDiscovery.ts | 1 + .../src/ipc/methods/backendMode.test.ts | 4 +- apps/desktop/src/ipc/methods/backendMode.ts | 4 +- 4 files changed, 51 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/app/DesktopLocalServerDiscovery.test.ts b/apps/desktop/src/app/DesktopLocalServerDiscovery.test.ts index 1f2b83e2636d..bd61aedcb982 100644 --- a/apps/desktop/src/app/DesktopLocalServerDiscovery.test.ts +++ b/apps/desktop/src/app/DesktopLocalServerDiscovery.test.ts @@ -9,6 +9,7 @@ import { LOCAL_SERVER_CHALLENGE_NONCE_BYTES } from "@t3tools/shared/localServerD import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Path from "effect/Path"; +import * as PlatformError from "effect/PlatformError"; import * as Schema from "effect/Schema"; import * as TestClock from "effect/testing/TestClock"; @@ -242,6 +243,52 @@ it.effect("removes the challenge file when the pairing request fails", () => }).pipe(Effect.provide(NodeServices.layer), TestClock.withLive), ); +it.effect("removes the challenge file when tightening its permissions fails", () => + Effect.gen(function* () { + const { runtimeDirectory, advertisementDirectory } = yield* makeAdvertisementDirectory( + "t3-local-pairing-chmod-failure-test-", + ); + yield* writeAdvertisement(advertisementDirectory, makeRecord()); + + const fileSystem = yield* FileSystem.FileSystem; + let challengePath: string | null = null; + const chmodFailure = PlatformError.systemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "chmod", + pathOrDescriptor: runtimeDirectory, + }); + const failingFileSystem = FileSystem.FileSystem.of({ + ...fileSystem, + writeFileString: (target, contents, options) => + fileSystem.writeFileString(target, contents, options).pipe( + Effect.tap(() => + Effect.sync(() => { + if (target.endsWith(".nonce")) challengePath = target; + }), + ), + ), + chmod: (target, mode) => + target.endsWith(".nonce") + ? Effect.fail(chmodFailure) + : fileSystem.chmod(target, mode), + }); + const discovery = yield* make({ + platform: "linux", + xdgRuntimeDirectory: runtimeDirectory, + uid: process.getuid?.(), + probeEnvironment: () => Effect.succeed(descriptor), + postPairingChallenge: rejectPairing, + }).pipe(Effect.provideService(FileSystem.FileSystem, failingFileSystem)); + + const error = yield* discovery.pairLocalServer("instance-local").pipe(Effect.flip); + + expect(error.reason).toBe("challenge_failed"); + assert.isNotNull(challengePath); + expect(yield* fileSystem.exists(challengePath)).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( diff --git a/apps/desktop/src/app/DesktopLocalServerDiscovery.ts b/apps/desktop/src/app/DesktopLocalServerDiscovery.ts index 2f03a7765f22..1a0bbd405a3d 100644 --- a/apps/desktop/src/app/DesktopLocalServerDiscovery.ts +++ b/apps/desktop/src/app/DesktopLocalServerDiscovery.ts @@ -265,6 +265,7 @@ export const make = Effect.fn("desktop.localServerDiscovery.make")(function* ( }); yield* fileSystem.chmod(challengePath, LOCAL_SERVER_CHALLENGE_FILE_MODE); }).pipe( + Effect.tapError(() => fileSystem.remove(challengePath).pipe(Effect.ignore)), Effect.mapError( (cause) => new LocalServerPairingError({ diff --git a/apps/desktop/src/ipc/methods/backendMode.test.ts b/apps/desktop/src/ipc/methods/backendMode.test.ts index 659f555d6ab9..a3cd9eb88e4c 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("reports the active mode coherently while a successful relaunch is pending", () => { + it.effect("reports the saved mode while a successful relaunch is pending", () => { const relaunchReasons: Array = []; const layer = Layer.mergeAll( DesktopBackendMode.layerTest(), @@ -42,7 +42,7 @@ describe("backend mode IPC", () => { assert.deepEqual(state, { effectiveMode: "managed", - configuredMode: "managed", + configuredMode: "client-only", cliOverride: null, }); assert.deepEqual(relaunchReasons, ["backendMode=client-only"]); diff --git a/apps/desktop/src/ipc/methods/backendMode.ts b/apps/desktop/src/ipc/methods/backendMode.ts index 37c7ac795ad8..6f4b0a9301de 100644 --- a/apps/desktop/src/ipc/methods/backendMode.ts +++ b/apps/desktop/src/ipc/methods/backendMode.ts @@ -53,11 +53,9 @@ export const setBackendMode = DesktopIpc.makeIpcMethod({ 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, + configuredMode: change.settings.backendMode, }; } return {