diff --git a/apps/desktop/src/app/DesktopApp.ts b/apps/desktop/src/app/DesktopApp.ts index fd86c5f05d2..5aec848e169 100644 --- a/apps/desktop/src/app/DesktopApp.ts +++ b/apps/desktop/src/app/DesktopApp.ts @@ -1,9 +1,12 @@ 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"; +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"; @@ -11,6 +14,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 +61,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 +151,88 @@ 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; + 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* handleClientOnlyRendererReady(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 +253,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", { @@ -230,7 +311,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* latchDesktopBackendModeForStartup(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 +348,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/DesktopAppErrors.test.ts b/apps/desktop/src/app/DesktopAppErrors.test.ts index 666c36d391d..eba9723c703 100644 --- a/apps/desktop/src/app/DesktopAppErrors.test.ts +++ b/apps/desktop/src/app/DesktopAppErrors.test.ts @@ -1,9 +1,22 @@ import { assert, describe, it } from "@effect/vitest"; +import * as Cause from "effect/Cause"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as ElectronApp from "../electron/ElectronApp.ts"; +import * as ElectronDialog from "../electron/ElectronDialog.ts"; import { DesktopBackendPortUnavailableError, DesktopDevelopmentBackendPortRequiredError, + handleClientOnlyRendererReady, + latchDesktopBackendModeForStartup, } from "./DesktopApp.ts"; +import * as DesktopBackendMode from "./DesktopBackendMode.ts"; +import * as DesktopShutdown from "./DesktopShutdown.ts"; +import * as DesktopState from "./DesktopState.ts"; describe("DesktopApp errors", () => { it("preserves unavailable backend port context", () => { @@ -27,4 +40,59 @@ describe("DesktopApp errors", () => { assert.equal(error.message, "T3CODE_PORT is required in desktop development."); }); + + it.effect("propagates client-only window creation failures", () => + Effect.gen(function* () { + const error = new Error("window creation failed"); + + const exit = yield* Effect.exit(handleClientOnlyRendererReady(Effect.fail(error))); + assert(Exit.isFailure(exit)); + const failure = Cause.findErrorOption(exit.cause); + assert(Option.isSome(failure)); + assert.strictEqual(failure.value, error); + }), + ); + + it.effect("reports invalid backend-mode launch arguments as fatal startup errors", () => + Effect.gen(function* () { + const quitCount = yield* Ref.make(0); + const shownErrors = yield* Ref.make([]); + + const layer = Layer.mergeAll( + DesktopBackendMode.layerTest(["electron", "--backend-mode=invalid"]), + DesktopShutdown.layer, + DesktopState.layer, + Layer.mock(ElectronApp.ElectronApp)({ + quit: Ref.update(quitCount, (count) => count + 1), + }), + Layer.mock(ElectronDialog.ElectronDialog)({ + showErrorBox: (title, content) => + Ref.update(shownErrors, (errors) => [...errors, { title, content }]), + }), + ); + + yield* Effect.gen(function* () { + const exit = yield* Effect.exit(latchDesktopBackendModeForStartup("managed")); + assert(Exit.isFailure(exit)); + const failure = Cause.findErrorOption(exit.cause); + assert(Option.isSome(failure)); + assert( + DesktopBackendMode.isDesktopBackendModeArgumentError(failure.value), + "expected the original backend mode argument error", + ); + + const errors = yield* Ref.get(shownErrors); + assert.equal(errors.length, 1); + assert.equal(errors[0]?.title, "T3 Code failed to start"); + assert.include(errors[0]?.content ?? "", "Stage: backendMode"); + assert.include(errors[0]?.content ?? "", 'Invalid --backend-mode value "invalid"'); + assert.equal(yield* Ref.get(quitCount), 1); + + const state = yield* DesktopState.DesktopState; + assert.isTrue(yield* Ref.get(state.quitting)); + const shutdown = yield* DesktopShutdown.DesktopShutdown; + yield* shutdown.awaitRequest; + }).pipe(Effect.provide(layer)); + }), + ); }); diff --git a/apps/desktop/src/app/DesktopBackendMode.test.ts b/apps/desktop/src/app/DesktopBackendMode.test.ts new file mode 100644 index 00000000000..de9394da9f9 --- /dev/null +++ b/apps/desktop/src/app/DesktopBackendMode.test.ts @@ -0,0 +1,70 @@ +import { assert, describe, it } from "@effect/vitest"; + +import * as DesktopBackendMode from "./DesktopBackendMode.ts"; + +describe("DesktopBackendMode", () => { + const captureThrown = (run: () => unknown): unknown => { + try { + run(); + } catch (error) { + return error; + } + throw new Error("Expected the operation to throw."); + }; + + it("uses the persisted mode when no CLI override is present", () => { + assert.deepEqual(DesktopBackendMode.resolveDesktopBackendModeState([], "client-only"), { + effectiveMode: "client-only", + configuredMode: "client-only", + cliOverride: null, + }); + }); + + it("gives the CLI override precedence without changing the configured mode", () => { + assert.deepEqual( + DesktopBackendMode.resolveDesktopBackendModeState( + ["electron", "main.cjs", "--backend-mode=client-only"], + "managed", + ), + { + effectiveMode: "client-only", + configuredMode: "managed", + cliOverride: "client-only", + }, + ); + }); + + it("accepts a separate flag value", () => { + assert.equal( + DesktopBackendMode.parseDesktopBackendModeOverride(["electron", "--backend-mode", "managed"]), + "managed", + ); + }); + + it.each([ + ["--backend-mode=other", "invalid-value"], + ["--backend-mode=", "missing-value"], + ["--backend-mode", "missing-value"], + ])("rejects invalid launch argument %s", (argument, reason) => { + const error = captureThrown(() => + DesktopBackendMode.parseDesktopBackendModeOverride(["electron", argument]), + ); + assert.isTrue(DesktopBackendMode.isDesktopBackendModeArgumentError(error)); + if (DesktopBackendMode.isDesktopBackendModeArgumentError(error)) { + assert.equal(error.reason, reason); + } + }); + + it("rejects repeated overrides", () => { + const error = captureThrown(() => + DesktopBackendMode.parseDesktopBackendModeOverride([ + "--backend-mode=managed", + "--backend-mode=client-only", + ]), + ); + assert.isTrue(DesktopBackendMode.isDesktopBackendModeArgumentError(error)); + if (DesktopBackendMode.isDesktopBackendModeArgumentError(error)) { + assert.equal(error.reason, "repeated"); + } + }); +}); diff --git a/apps/desktop/src/app/DesktopBackendMode.ts b/apps/desktop/src/app/DesktopBackendMode.ts new file mode 100644 index 00000000000..81819b41005 --- /dev/null +++ b/apps/desktop/src/app/DesktopBackendMode.ts @@ -0,0 +1,128 @@ +import { + type DesktopBackendMode as DesktopBackendModeValue, + type DesktopBackendModeState, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; + +const BACKEND_MODE_FLAG = "--backend-mode"; + +export class DesktopBackendModeArgumentError extends Schema.TaggedErrorClass()( + "DesktopBackendModeArgumentError", + { + value: Schema.NullOr(Schema.String), + reason: Schema.Literals(["missing-value", "invalid-value", "repeated"]), + }, +) { + override get message(): string { + if (this.reason === "missing-value") { + return `${BACKEND_MODE_FLAG} requires either "managed" or "client-only".`; + } + if (this.reason === "repeated") { + return `${BACKEND_MODE_FLAG} may only be specified once.`; + } + return `Invalid ${BACKEND_MODE_FLAG} value ${JSON.stringify(this.value)}. Expected "managed" or "client-only".`; + } +} + +export const isDesktopBackendModeArgumentError = Schema.is(DesktopBackendModeArgumentError); + +function parseBackendMode(value: string | undefined): DesktopBackendModeValue { + if (value === undefined || value.length === 0) { + throw new DesktopBackendModeArgumentError({ + value: value ?? null, + reason: "missing-value", + }); + } + if (value === "managed" || value === "client-only") { + return value; + } + throw new DesktopBackendModeArgumentError({ + value, + reason: "invalid-value", + }); +} + +export function parseDesktopBackendModeOverride( + argv: readonly string[], +): DesktopBackendModeValue | null { + let override: DesktopBackendModeValue | null = null; + + for (let index = 0; index < argv.length; index += 1) { + const argument = argv[index]; + if (argument === undefined) continue; + + let value: string | undefined; + if (argument === BACKEND_MODE_FLAG) { + value = argv[index + 1]; + index += 1; + } else if (argument.startsWith(`${BACKEND_MODE_FLAG}=`)) { + value = argument.slice(BACKEND_MODE_FLAG.length + 1); + } else { + continue; + } + + if (override !== null) { + throw new DesktopBackendModeArgumentError({ + value: value ?? null, + reason: "repeated", + }); + } + override = parseBackendMode(value); + } + + return override; +} + +export function resolveDesktopBackendModeState( + argv: readonly string[], + configuredMode: DesktopBackendModeValue, +): DesktopBackendModeState { + const cliOverride = parseDesktopBackendModeOverride(argv); + return { + effectiveMode: cliOverride ?? configuredMode, + configuredMode, + cliOverride, + }; +} + +export class DesktopBackendMode extends Context.Service< + DesktopBackendMode, + { + readonly latch: ( + configuredMode: DesktopBackendModeValue, + ) => Effect.Effect; + readonly get: Effect.Effect; + } +>()("@t3tools/desktop/app/DesktopBackendMode") {} + +export const make = Effect.fn("desktop.backendMode.make")(function* (argv: readonly string[]) { + const stateRef = yield* Ref.make({ + effectiveMode: "managed", + configuredMode: "managed", + cliOverride: null, + }); + + return DesktopBackendMode.of({ + latch: (configuredMode) => + Effect.try({ + try: () => resolveDesktopBackendModeState(argv, configuredMode), + catch: (cause) => + isDesktopBackendModeArgumentError(cause) + ? cause + : new DesktopBackendModeArgumentError({ + value: null, + reason: "invalid-value", + }), + }).pipe(Effect.tap((state) => Ref.set(stateRef, state))), + get: Ref.get(stateRef), + }); +}); + +export const layer = Layer.effect(DesktopBackendMode, make(process.argv)); + +export const layerTest = (argv: readonly string[] = []) => + Layer.effect(DesktopBackendMode, make(argv)); diff --git a/apps/desktop/src/app/DesktopEnvironment.test.ts b/apps/desktop/src/app/DesktopEnvironment.test.ts index 15d23f8e152..b1eeacfff81 100644 --- a/apps/desktop/src/app/DesktopEnvironment.test.ts +++ b/apps/desktop/src/app/DesktopEnvironment.test.ts @@ -67,6 +67,10 @@ describe("DesktopEnvironment", () => { assert.equal(environment.appRoot, "/repo"); assert.equal(environment.backendEntryPath, "/repo/apps/server/dist/bin.mjs"); assert.equal(environment.backendCwd, "/repo"); + assert.deepEqual(environment.packagedClientRootCandidates, [ + "/Applications/T3 Code.app/Contents/Resources/app.asar/apps/server/dist/client", + "/Applications/T3 Code.app/Contents/Resources/app.asar.unpacked/apps/server/dist/client", + ]); assert.equal(environment.appUserModelId, "com.t3tools.t3code.dev"); assert.equal(environment.linuxWmClass, "t3code-dev"); assert.deepEqual( diff --git a/apps/desktop/src/app/DesktopEnvironment.ts b/apps/desktop/src/app/DesktopEnvironment.ts index c991f5b39d6..cb7afe59986 100644 --- a/apps/desktop/src/app/DesktopEnvironment.ts +++ b/apps/desktop/src/app/DesktopEnvironment.ts @@ -53,6 +53,7 @@ export class DesktopEnvironment extends Context.Service< readonly appRoot: string; readonly backendEntryPath: string; readonly backendCwd: string; + readonly packagedClientRootCandidates: readonly string[]; readonly preloadPath: string; readonly appUpdateYmlPath: string; readonly devServerUrl: Option.Option; @@ -188,6 +189,10 @@ const make = Effect.fn("desktop.environment.make")(function* ( appRoot, backendEntryPath: path.join(appRoot, "apps/server/dist/bin.mjs"), backendCwd: input.isPackaged ? homeDirectory : appRoot, + packagedClientRootCandidates: [ + path.join(input.appPath, "apps/server/dist/client"), + path.join(resourcesPath, "app.asar.unpacked/apps/server/dist/client"), + ], preloadPath: path.join(input.dirname, "preload.cjs"), appUpdateYmlPath: input.isPackaged ? path.join(resourcesPath, "app-update.yml") diff --git a/apps/desktop/src/app/DesktopLifecycle.ts b/apps/desktop/src/app/DesktopLifecycle.ts index f8e05915718..b087bc63209 100644 --- a/apps/desktop/src/app/DesktopLifecycle.ts +++ b/apps/desktop/src/app/DesktopLifecycle.ts @@ -43,7 +43,7 @@ export class DesktopLifecycle extends Context.Service< { readonly relaunch: ( reason: string, - ) => Effect.Effect; + ) => Effect.Effect; readonly register: Effect.Effect; } >()("@t3tools/desktop/app/DesktopLifecycle") {} @@ -146,6 +146,18 @@ export const make = DesktopLifecycle.of({ const environment = yield* DesktopEnvironment.DesktopEnvironment; const state = yield* DesktopState.DesktopState; yield* logLifecycleInfo("desktop relaunch requested", { reason }); + if (!environment.isDevelopment) { + yield* electronApp + .relaunch({ + execPath: process.execPath, + args: process.argv.slice(1), + }) + .pipe( + Effect.catchCause((cause) => + Effect.fail(new DesktopLifecycleRelaunchError({ reason, cause })), + ), + ); + } yield* Effect.gen(function* () { yield* Effect.yieldNow; yield* Ref.set(state.quitting, true); @@ -154,10 +166,6 @@ export const make = DesktopLifecycle.of({ yield* electronApp.exit(75); return; } - yield* electronApp.relaunch({ - execPath: process.execPath, - args: process.argv.slice(1), - }); yield* electronApp.exit(0); }).pipe( Effect.catchCause((cause) => { diff --git a/apps/desktop/src/app/DesktopLocalServerDiscovery.test.ts b/apps/desktop/src/app/DesktopLocalServerDiscovery.test.ts new file mode 100644 index 00000000000..1f2b83e2636 --- /dev/null +++ b/apps/desktop/src/app/DesktopLocalServerDiscovery.test.ts @@ -0,0 +1,269 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, expect, it } from "@effect/vitest"; +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 { LocalServerPairingError, 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/", + 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 { 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, dead, and identity-mismatched advertisements", () => + Effect.gen(function* () { + const { runtimeDirectory, advertisementDirectory } = yield* makeAdvertisementDirectory( + "t3-local-discovery-rejection-test-", + ); + + const records = [ + makeRecord({ + instanceId: "non-loopback", + httpBaseUrl: "http://192.168.1.20:3773/", + }), + // 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) { + yield* writeAdvertisement(advertisementDirectory, record); + } + + 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(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), +); + +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 new file mode 100644 index 00000000000..2f03a7765f2 --- /dev/null +++ b/apps/desktop/src/app/DesktopLocalServerDiscovery.ts @@ -0,0 +1,344 @@ +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 { + LOCAL_SERVER_ADVERTISEMENT_DIRECTORY_MODE, + LOCAL_SERVER_ADVERTISEMENT_FILE_MODE, + LOCAL_SERVER_ADVERTISEMENT_MAX_BYTES, + LOCAL_SERVER_CHALLENGE_NONCE_BYTES, + isValidLocalServerPairingUrl, + parseCanonicalLoopbackHttpBaseUrl, + resolveLocalServerAdvertisementDirectory, + resolveLocalServerChallengeDirectory, +} 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"; +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") {} + +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 + ); +} + +/** + * 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); + return Number.isFinite(startedAtMs) && startedAtMs <= nowMs + 60_000; +} + +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 challengeDirectory = resolveLocalServerChallengeDirectory({ + 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) || !hasValidStartedAt(decoded.value, nowMs)) { + return null; + } + + 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; + } + 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), + ); + }); + + 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. + 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 }); +}); + +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), + ), + 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/backend/DesktopBackendManager.ts b/apps/desktop/src/backend/DesktopBackendManager.ts index e3a4de661ac..497d00a5a54 100644 --- a/apps/desktop/src/backend/DesktopBackendManager.ts +++ b/apps/desktop/src/backend/DesktopBackendManager.ts @@ -403,6 +403,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; const httpClient = yield* HttpClient.HttpClient; const state = yield* Ref.make(initialState); + const startRequestedRef = yield* Ref.make(false); const mutex = yield* Semaphore.make(1); const { logWarning: logInstanceWarning, logError: logInstanceError } = @@ -442,6 +443,7 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( const start: Effect.Effect = Effect.suspend(() => mutex.withPermits(1)( Effect.gen(function* () { + yield* Ref.set(startRequestedRef, true); const current = yield* Ref.get(state); if (Option.isSome(current.active)) { return; @@ -805,7 +807,11 @@ export const makeBackendInstance = Effect.fn("makeBackendInstance")(function* ( Effect.map(Option.getOrElse(() => false)), ); - yield* Effect.addFinalizer(() => stop()); + yield* Effect.addFinalizer(() => + Ref.get(startRequestedRef).pipe( + Effect.flatMap((startRequested) => (startRequested ? stop() : Effect.void)), + ), + ); return { id: spec.id, diff --git a/apps/desktop/src/backend/DesktopBackendPool.test.ts b/apps/desktop/src/backend/DesktopBackendPool.test.ts index fa0811d5df7..21a27602015 100644 --- a/apps/desktop/src/backend/DesktopBackendPool.test.ts +++ b/apps/desktop/src/backend/DesktopBackendPool.test.ts @@ -74,6 +74,7 @@ function makePoolLayer( activate: Effect.die("unexpected window activate"), createMainIfBackendReady: Effect.die("unexpected window create"), showConnectingSplash: Effect.void, + handleRendererReady: Effect.void, handleBackendReady: () => Effect.void, handleBackendNotReady: Effect.void, flushMainWindowBounds: Effect.void, diff --git a/apps/desktop/src/backend/DesktopServerExposure.test.ts b/apps/desktop/src/backend/DesktopServerExposure.test.ts index dcfee93778d..3d7b0e6a2f1 100644 --- a/apps/desktop/src/backend/DesktopServerExposure.test.ts +++ b/apps/desktop/src/backend/DesktopServerExposure.test.ts @@ -251,6 +251,7 @@ describe("DesktopServerExposure", () => { get: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS), load: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS), setMainWindowBounds: () => Effect.die("unexpected main window bounds update"), + setBackendMode: () => Effect.die("unexpected backend mode update"), setServerExposureMode: () => Effect.fail(settingsFailure), setTailscaleServe: () => Effect.fail(settingsFailure), setUpdateChannel: () => Effect.die("unexpected update channel change"), diff --git a/apps/desktop/src/electron/ElectronProtocol.test.ts b/apps/desktop/src/electron/ElectronProtocol.test.ts index 92e30000427..2d35913e355 100644 --- a/apps/desktop/src/electron/ElectronProtocol.test.ts +++ b/apps/desktop/src/electron/ElectronProtocol.test.ts @@ -36,8 +36,8 @@ describe("ElectronProtocol", () => { const protocol = yield* ElectronProtocol.ElectronProtocol; yield* protocol.registerDesktopProtocol({ scheme: "t3code-dev", + source: "proxy", targetOrigin: new URL("http://127.0.0.1:3773/"), - backendOrigin: new URL("http://127.0.0.1:3774/"), clerkFrontendApiHostname: "clerk.t3.codes", }); assert.isDefined(handler); @@ -100,8 +100,8 @@ describe("ElectronProtocol", () => { const protocol = yield* ElectronProtocol.ElectronProtocol; yield* protocol.registerDesktopProtocol({ scheme: "t3code", + source: "proxy", targetOrigin: new URL("http://127.0.0.1:3773/"), - backendOrigin: new URL("http://127.0.0.1:3773/"), clerkFrontendApiHostname: undefined, }); return yield* Effect.promise(() => handler!(new Request("t3code://other/"))); @@ -128,8 +128,8 @@ describe("ElectronProtocol", () => { const protocol = yield* ElectronProtocol.ElectronProtocol; yield* protocol.registerDesktopProtocol({ scheme: "t3code-dev", + source: "proxy", targetOrigin: new URL("http://127.0.0.1:5733/"), - backendOrigin: new URL("http://127.0.0.1:3773/"), clerkFrontendApiHostname: undefined, }); return yield* Effect.promise(() => handler!(new Request("t3code-dev://app/"))); @@ -152,8 +152,8 @@ describe("ElectronProtocol", () => { const error = yield* Effect.scoped( protocol.registerDesktopProtocol({ scheme: "t3code-dev", + source: "proxy", targetOrigin: new URL("http://127.0.0.1:3773/"), - backendOrigin: new URL("http://127.0.0.1:3774/"), clerkFrontendApiHostname: undefined, }), ).pipe(Effect.flip); @@ -177,8 +177,8 @@ describe("ElectronProtocol", () => { Effect.scoped( protocol.registerDesktopProtocol({ scheme: "t3code", + source: "proxy", targetOrigin: new URL("http://127.0.0.1:3773/"), - backendOrigin: new URL("http://127.0.0.1:3773/"), clerkFrontendApiHostname: undefined, }), ), @@ -198,8 +198,8 @@ describe("ElectronProtocol", () => { it("keeps executable sources host-restricted while allowing runtime network resources", () => { const policy = ElectronProtocol.makeDesktopContentSecurityPolicy({ scheme: "t3code", + source: "proxy", targetOrigin: new URL("http://127.0.0.1:3773/"), - backendOrigin: new URL("http://127.0.0.1:3773/"), clerkFrontendApiHostname: "clerk.t3.codes", }); const directives = Object.fromEntries( @@ -226,4 +226,117 @@ describe("ElectronProtocol", () => { ]); assert.deepEqual(directives["font-src"], ["'self'", "t3code:", "data:"]); }); + + it.effect("serves packaged assets, SPA routes, HEAD requests, and CSP", () => + Effect.gen(function* () { + let handler: ((request: Request) => Promise) | undefined; + handleMock.mockImplementation((_scheme, nextHandler) => { + handler = nextHandler; + }); + netFetchMock.mockImplementation(async (url: string) => { + if (url.endsWith("/index.html")) { + return new Response("
T3 Code
", { status: 200 }); + } + if (url.endsWith("/assets/app-123.js")) { + return new Response("export {};", { status: 200 }); + } + return new Response(null, { status: 404 }); + }); + + yield* Effect.scoped( + Effect.gen(function* () { + const protocol = yield* ElectronProtocol.ElectronProtocol; + yield* protocol.registerDesktopProtocol({ + scheme: "t3code", + source: "static", + staticRoot: "/opt/t3/apps/server/dist/client", + clerkFrontendApiHostname: undefined, + }); + assert.isDefined(handler); + + const root = yield* Effect.promise(() => + handler!( + new Request("t3code://app/", { + headers: { accept: "text/html" }, + }), + ), + ); + assert.equal(root.status, 200); + assert.equal(root.headers.get("content-type"), "text/html; charset=utf-8"); + assert.include(root.headers.get("content-security-policy") ?? "", "default-src 'self'"); + assert.equal(yield* Effect.promise(() => root.text()), "
T3 Code
"); + + const asset = yield* Effect.promise(() => + handler!(new Request("t3code://app/assets/app-123.js")), + ); + assert.equal(asset.headers.get("content-type"), "text/javascript; charset=utf-8"); + assert.equal(yield* Effect.promise(() => asset.text()), "export {};"); + + const route = yield* Effect.promise(() => + handler!( + new Request("t3code://app/settings/connections", { + headers: { accept: "text/html" }, + }), + ), + ); + assert.equal(route.status, 200); + assert.equal(yield* Effect.promise(() => route.text()), "
T3 Code
"); + + const head = yield* Effect.promise(() => + handler!( + new Request("t3code://app/assets/app-123.js", { + method: "HEAD", + }), + ), + ); + assert.equal(head.status, 200); + assert.equal(yield* Effect.promise(() => head.text()), ""); + }), + ); + }).pipe(Effect.provide(ElectronProtocol.layer)), + ); + + it.effect("does not fall back missing assets and rejects unsafe static requests", () => + Effect.gen(function* () { + let handler: ((request: Request) => Promise) | undefined; + handleMock.mockImplementation((_scheme, nextHandler) => { + handler = nextHandler; + }); + netFetchMock.mockResolvedValue(new Response(null, { status: 404 })); + + yield* Effect.scoped( + Effect.gen(function* () { + const protocol = yield* ElectronProtocol.ElectronProtocol; + yield* protocol.registerDesktopProtocol({ + scheme: "t3code", + source: "static", + staticRoot: "/opt/t3/apps/server/dist/client", + clerkFrontendApiHostname: undefined, + }); + assert.isDefined(handler); + + const missingAsset = yield* Effect.promise(() => + handler!( + new Request("t3code://app/assets/missing.js", { + headers: { accept: "text/html" }, + }), + ), + ); + assert.equal(missingAsset.status, 404); + assert.equal(netFetchMock.mock.calls.length, 1); + + const traversal = yield* Effect.promise(() => + handler!(new Request("t3code://app/%2e%2e%2fsecret.txt")), + ); + assert.equal(traversal.status, 403); + + const unsupported = yield* Effect.promise(() => + handler!(new Request("t3code://app/", { method: "POST" })), + ); + assert.equal(unsupported.status, 405); + assert.equal(unsupported.headers.get("allow"), "GET, HEAD"); + }), + ); + }).pipe(Effect.provide(ElectronProtocol.layer)), + ); }); diff --git a/apps/desktop/src/electron/ElectronProtocol.ts b/apps/desktop/src/electron/ElectronProtocol.ts index 03c7ef64fd7..d65e563585d 100644 --- a/apps/desktop/src/electron/ElectronProtocol.ts +++ b/apps/desktop/src/electron/ElectronProtocol.ts @@ -1,3 +1,4 @@ +// @effect-diagnostics nodeBuiltinImport:off - Electron static protocol handlers require synchronous platform path validation. import * as Context from "effect/Context"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; @@ -5,6 +6,8 @@ import * as NodeTimersPromises from "node:timers/promises"; import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; +import * as NodePath from "node:path"; +import * as NodeURL from "node:url"; import * as Electron from "electron"; @@ -48,13 +51,23 @@ export class ElectronProtocolUnregistrationError extends Schema.TaggedErrorClass } } -export interface DesktopProtocolRegistrationInput { +interface DesktopProtocolRegistrationBase { readonly scheme: string; - readonly targetOrigin: URL; - readonly backendOrigin: URL; readonly clerkFrontendApiHostname: string | undefined; } +export type DesktopProtocolRegistrationInput = DesktopProtocolRegistrationBase & + ( + | { + readonly source: "proxy"; + readonly targetOrigin: URL; + } + | { + readonly source: "static"; + readonly staticRoot: string; + } + ); + export class ElectronProtocol extends Context.Service< ElectronProtocol, { @@ -104,6 +117,143 @@ function withContentSecurityPolicy(response: Response, policy: string): Response }); } +const STATIC_CONTENT_TYPES: Readonly> = { + ".css": "text/css; charset=utf-8", + ".gif": "image/gif", + ".html": "text/html; charset=utf-8", + ".ico": "image/x-icon", + ".jpeg": "image/jpeg", + ".jpg": "image/jpeg", + ".js": "text/javascript; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".map": "application/json; charset=utf-8", + ".mjs": "text/javascript; charset=utf-8", + ".png": "image/png", + ".svg": "image/svg+xml", + ".webp": "image/webp", + ".woff": "font/woff", + ".woff2": "font/woff2", +}; + +type StaticPathResolution = + | { readonly _tag: "Invalid"; readonly status: 400 | 403 } + | { readonly _tag: "Resolved"; readonly path: string; readonly relativePath: string }; + +export function resolveDesktopStaticPath( + staticRoot: string, + encodedPathname: string, +): StaticPathResolution { + let decodedPathname: string; + try { + decodedPathname = decodeURIComponent(encodedPathname); + } catch { + return { _tag: "Invalid", status: 400 }; + } + + if ( + decodedPathname.includes("\0") || + decodedPathname.includes("\\") || + /^[a-zA-Z]:/u.test(decodedPathname.replace(/^\/+/u, "")) + ) { + return { _tag: "Invalid", status: 403 }; + } + + const segments = decodedPathname.split("/").filter((segment) => segment.length > 0); + if (segments.some((segment) => segment === "." || segment === "..")) { + return { _tag: "Invalid", status: 403 }; + } + + const relativePath = segments.length === 0 ? "index.html" : segments.join("/"); + const normalizedRoot = NodePath.resolve(staticRoot); + const resolvedPath = NodePath.resolve(normalizedRoot, relativePath); + const relativeToRoot = NodePath.relative(normalizedRoot, resolvedPath); + if ( + relativeToRoot === ".." || + relativeToRoot.startsWith(`..${NodePath.sep}`) || + NodePath.isAbsolute(relativeToRoot) + ) { + return { _tag: "Invalid", status: 403 }; + } + + return { + _tag: "Resolved", + path: resolvedPath, + relativePath, + }; +} + +function shouldUseSpaFallback(request: Request, relativePath: string): boolean { + if (NodePath.extname(relativePath) !== "") { + return false; + } + const accept = request.headers.get("accept") ?? ""; + const mode = request.headers.get("sec-fetch-mode") ?? ""; + return mode === "navigate" || accept.includes("text/html"); +} + +async function fetchStaticFile(path: string): Promise { + try { + return await Electron.net.fetch(NodeURL.pathToFileURL(path).href, { method: "GET" }); + } catch { + return new Response(null, { status: 404 }); + } +} + +function withStaticResponseHeaders(response: Response, path: string, headOnly: boolean): Response { + const headers = new Headers(response.headers); + const contentType = STATIC_CONTENT_TYPES[NodePath.extname(path).toLowerCase()]; + if (contentType !== undefined) { + headers.set("Content-Type", contentType); + } + return new Response(headOnly ? null : response.body, { + status: response.status, + statusText: response.statusText, + headers, + }); +} + +export async function serveDesktopStaticRequest( + request: Request, + staticRoot: string, + contentSecurityPolicy: string, +): Promise { + const requestUrl = new URL(request.url); + if (requestUrl.host !== DESKTOP_HOST) { + return withContentSecurityPolicy(new Response(null, { status: 404 }), contentSecurityPolicy); + } + if (request.method !== "GET" && request.method !== "HEAD") { + return withContentSecurityPolicy( + new Response(null, { + status: 405, + headers: { Allow: "GET, HEAD" }, + }), + contentSecurityPolicy, + ); + } + + const resolution = resolveDesktopStaticPath(staticRoot, requestUrl.pathname); + if (resolution._tag === "Invalid") { + return withContentSecurityPolicy( + new Response(null, { status: resolution.status }), + contentSecurityPolicy, + ); + } + + let response = await fetchStaticFile(resolution.path); + let responsePath = resolution.path; + if (response.status === 404 && shouldUseSpaFallback(request, resolution.relativePath)) { + // Resolve the shell through the same normalization as asset paths so a + // relative or non-normalized staticRoot still falls back correctly. + responsePath = NodePath.resolve(staticRoot, "index.html"); + response = await fetchStaticFile(responsePath); + } + + return withContentSecurityPolicy( + withStaticResponseHeaders(response, responsePath, request.method === "HEAD"), + contentSecurityPolicy, + ); +} + async function proxyRequest( request: Request, targetOrigin: URL, @@ -181,9 +331,12 @@ export const make = Effect.gen(function* () { yield* Effect.acquireRelease( Effect.try({ try: () => { - Electron.protocol.handle(input.scheme, (request) => - proxyRequest(request, input.targetOrigin, contentSecurityPolicy), - ); + Electron.protocol.handle(input.scheme, (request) => { + if (input.source === "static") { + return serveDesktopStaticRequest(request, input.staticRoot, contentSecurityPolicy); + } + return proxyRequest(request, input.targetOrigin, contentSecurityPolicy); + }); }, catch: (cause) => new ElectronProtocolRegistrationError({ scheme: input.scheme, cause }), }).pipe(Effect.andThen(Ref.set(registered, true))), diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index e478d0c6eff..290ae3342c8 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -2,6 +2,8 @@ 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, pairLocalServer } from "./methods/localServerDiscovery.ts"; import { clearConnectionCatalog, getConnectionCatalog, @@ -49,9 +51,13 @@ 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(discoverLocalServers); + yield* ipc.handle(pairLocalServer); + yield* ipc.handle(setBackendMode); yield* ipc.handle(getClientSettings); yield* ipc.handle(setClientSettings); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 95c725130e5..d806de88536 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -13,9 +13,13 @@ 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"; +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/backendMode.test.ts b/apps/desktop/src/ipc/methods/backendMode.test.ts new file mode 100644 index 00000000000..659f555d6ab --- /dev/null +++ b/apps/desktop/src/ipc/methods/backendMode.test.ts @@ -0,0 +1,83 @@ +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("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", + 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; + 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 new file mode 100644 index 00000000000..37c7ac795ad --- /dev/null +++ b/apps/desktop/src/ipc/methods/backendMode.ts @@ -0,0 +1,68 @@ +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 previousMode = (yield* settings.get).backendMode; + const change = yield* settings.setBackendMode(mode); + const launchState = yield* launchMode.get; + const relaunchRequired = + change.changed && + launchState.cliOverride === null && + 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 { + ...launchState, + configuredMode: change.settings.backendMode, + }; + }), +}); diff --git a/apps/desktop/src/ipc/methods/localServerDiscovery.ts b/apps/desktop/src/ipc/methods/localServerDiscovery.ts new file mode 100644 index 00000000000..498990677bc --- /dev/null +++ b/apps/desktop/src/ipc/methods/localServerDiscovery.ts @@ -0,0 +1,27 @@ +import { LocalServerAdvertisement, LocalServerPairingResult } 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; + }), +}); + +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/ipc/methods/window.test.ts b/apps/desktop/src/ipc/methods/window.test.ts index 13e6e8d3956..3d0d23003ef 100644 --- a/apps/desktop/src/ipc/methods/window.test.ts +++ b/apps/desktop/src/ipc/methods/window.test.ts @@ -7,6 +7,7 @@ import type * as Electron from "electron"; import * as DesktopBackendManager from "../../backend/DesktopBackendManager.ts"; import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts"; +import * as DesktopBackendMode from "../../app/DesktopBackendMode.ts"; import * as ElectronWindow from "../../electron/ElectronWindow.ts"; import { getLocalEnvironmentBootstraps, getWindowFullscreenState } from "./window.ts"; @@ -64,7 +65,14 @@ describe("getLocalEnvironmentBootstraps", () => { bootstrapToken: "bootstrap-token", }, ]); - }).pipe(Effect.provide(DesktopBackendPool.layerTest([defaultWslInstance]))), + }).pipe( + Effect.provide( + Layer.mergeAll( + DesktopBackendPool.layerTest([defaultWslInstance]), + DesktopBackendMode.layerTest(), + ), + ), + ), ); it.effect("publishes a pending bootstrap only while a transient retry is scheduled", () => { @@ -99,7 +107,14 @@ describe("getLocalEnvironmentBootstraps", () => { wsBaseUrl: null, }, ]); - }).pipe(Effect.provide(DesktopBackendPool.layerTest([retryingInstance]))); + }).pipe( + Effect.provide( + Layer.mergeAll( + DesktopBackendPool.layerTest([retryingInstance]), + DesktopBackendMode.layerTest(), + ), + ), + ); }); it.effect("omits a bounded transient bootstrap after retries stop", () => { @@ -127,8 +142,30 @@ describe("getLocalEnvironmentBootstraps", () => { return Effect.gen(function* () { const result = yield* getLocalEnvironmentBootstraps.handler(); assert.deepEqual(result, []); - }).pipe(Effect.provide(DesktopBackendPool.layerTest([stoppedInstance]))); + }).pipe( + Effect.provide( + Layer.mergeAll( + DesktopBackendPool.layerTest([stoppedInstance]), + DesktopBackendMode.layerTest(), + ), + ), + ); }); + + it.effect("returns no local bootstraps in client-only mode", () => + Effect.gen(function* () { + const mode = yield* DesktopBackendMode.DesktopBackendMode; + yield* mode.latch("client-only"); + assert.deepEqual(yield* getLocalEnvironmentBootstraps.handler(), []); + }).pipe( + Effect.provide( + Layer.mergeAll( + DesktopBackendPool.layerTest([defaultWslInstance]), + DesktopBackendMode.layerTest(), + ), + ), + ), + ); }); describe("getWindowFullscreenState", () => { diff --git a/apps/desktop/src/ipc/methods/window.ts b/apps/desktop/src/ipc/methods/window.ts index a4e98aaabad..784c5f6223c 100644 --- a/apps/desktop/src/ipc/methods/window.ts +++ b/apps/desktop/src/ipc/methods/window.ts @@ -12,6 +12,7 @@ import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import * as DesktopBackendPool from "../../backend/DesktopBackendPool.ts"; +import * as DesktopBackendMode from "../../app/DesktopBackendMode.ts"; import * as DesktopLocalEnvironmentAuth from "../../backend/DesktopLocalEnvironmentAuth.ts"; import * as DesktopEnvironment from "../../app/DesktopEnvironment.ts"; import * as DesktopAppSettings from "../../settings/DesktopAppSettings.ts"; @@ -69,6 +70,10 @@ export const getLocalEnvironmentBootstraps = DesktopIpc.makeSyncIpcMethod({ channel: IpcChannels.GET_LOCAL_ENVIRONMENT_BOOTSTRAPS_CHANNEL, result: Schema.Array(DesktopEnvironmentBootstrapSchema), handler: Effect.fn("desktop.ipc.window.getLocalEnvironmentBootstraps")(function* () { + const launchMode = yield* DesktopBackendMode.DesktopBackendMode; + if ((yield* launchMode.get).effectiveMode === "client-only") { + return []; + } const pool = yield* DesktopBackendPool.DesktopBackendPool; const instances = yield* pool.list; const bootstraps: DesktopEnvironmentBootstrap[] = []; diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 9795f04e8ae..ab7148597a1 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -31,8 +31,10 @@ 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 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"; @@ -125,9 +127,11 @@ 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)), + 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 228114fd1d1..755b4d913f8 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -35,6 +35,21 @@ contextBridge.exposeInMainWorld("desktopBridge", { } return result as ReturnType; }, + 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: "client-only", + configuredMode: "client-only", + 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)) { @@ -44,6 +59,9 @@ 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/desktop/src/settings/DesktopAppSettings.test.ts b/apps/desktop/src/settings/DesktopAppSettings.test.ts index 3878b0e36ad..f7835e6069d 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.test.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.test.ts @@ -11,6 +11,7 @@ import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; import * as DesktopAppSettings from "./DesktopAppSettings.ts"; const DesktopSettingsPatch = Schema.Struct({ + backendMode: Schema.optionalKey(Schema.Literals(["managed", "client-only"])), mainWindowBounds: Schema.optionalKey( Schema.NullOr( Schema.Struct({ @@ -102,6 +103,7 @@ describe("DesktopSettings", () => { assert.deepEqual( DesktopAppSettings.resolveDefaultDesktopSettings("0.0.17-nightly.20260415.1"), { + backendMode: "managed", mainWindowBounds: null, mainWindowMaximized: false, serverExposureMode: "local-only", @@ -121,6 +123,7 @@ describe("DesktopSettings", () => { Effect.gen(function* () { const settings = yield* DesktopAppSettings.DesktopAppSettings; yield* writeSettingsPatch({ + backendMode: "client-only", serverExposureMode: "network-accessible", tailscaleServeEnabled: true, tailscaleServePort: 8443, @@ -129,6 +132,7 @@ describe("DesktopSettings", () => { }); assert.deepEqual(yield* settings.load, { + backendMode: "client-only", mainWindowBounds: null, mainWindowMaximized: false, serverExposureMode: "network-accessible", @@ -156,6 +160,10 @@ describe("DesktopSettings", () => { assert.isTrue(updateChannel.changed); assert.equal(updateChannel.settings.updateChannel, "nightly"); assert.equal(updateChannel.settings.updateChannelConfiguredByUser, true); + + const backendMode = yield* settings.setBackendMode("managed"); + assert.isTrue(backendMode.changed); + assert.equal(backendMode.settings.backendMode, "managed"); }), ), ); @@ -235,6 +243,7 @@ describe("DesktopSettings", () => { ); assert.deepEqual(yield* settings.load, { + backendMode: "managed", mainWindowBounds: { x: 120, y: 80, width: 1280, height: 900 }, mainWindowMaximized: false, serverExposureMode: "network-accessible", @@ -277,11 +286,13 @@ describe("DesktopSettings", () => { yield* settings.setMainWindowBounds({ x: -1200, y: 40, width: 1440, height: 960 }, true); yield* settings.setServerExposureMode("network-accessible"); + yield* settings.setBackendMode("client-only"); const persisted = yield* decodeDesktopSettingsPatch( yield* fileSystem.readFileString(environment.desktopSettingsPath), ); assert.deepEqual(persisted, { + backendMode: "client-only", mainWindowBounds: { x: -1200, y: 40, width: 1440, height: 960 }, mainWindowMaximized: true, serverExposureMode: "network-accessible", @@ -300,6 +311,7 @@ describe("DesktopSettings", () => { }); assert.deepEqual(yield* settings.load, { + backendMode: "managed", mainWindowBounds: null, mainWindowMaximized: false, serverExposureMode: "local-only", @@ -327,6 +339,7 @@ describe("DesktopSettings", () => { }); assert.deepEqual(yield* settings.load, { + backendMode: "managed", mainWindowBounds: null, mainWindowMaximized: false, serverExposureMode: "local-only", @@ -353,6 +366,7 @@ describe("DesktopSettings", () => { }); assert.deepEqual(yield* settings.load, { + backendMode: "managed", mainWindowBounds: null, mainWindowMaximized: false, serverExposureMode: "local-only", diff --git a/apps/desktop/src/settings/DesktopAppSettings.ts b/apps/desktop/src/settings/DesktopAppSettings.ts index 466c9a9b5f8..25befef96fb 100644 --- a/apps/desktop/src/settings/DesktopAppSettings.ts +++ b/apps/desktop/src/settings/DesktopAppSettings.ts @@ -1,6 +1,8 @@ import { DesktopServerExposureModeSchema, + DesktopBackendModeSchema, DesktopUpdateChannelSchema, + type DesktopBackendMode, type DesktopServerExposureMode, type DesktopUpdateChannel, } from "@t3tools/contracts"; @@ -20,6 +22,7 @@ import { resolveDefaultDesktopUpdateChannel } from "../updates/updateChannels.ts import { isValidDistroName } from "../wsl/wslPathParsing.ts"; export interface DesktopSettings { + readonly backendMode: DesktopBackendMode; readonly mainWindowBounds: DesktopWindowBounds | null; readonly mainWindowMaximized: boolean; readonly serverExposureMode: DesktopServerExposureMode; @@ -67,6 +70,7 @@ export const DEFAULT_MAIN_WINDOW_SIZE = { } as const; export const DEFAULT_DESKTOP_SETTINGS: DesktopSettings = { + backendMode: "managed", mainWindowBounds: null, mainWindowMaximized: false, serverExposureMode: "local-only", @@ -87,6 +91,7 @@ const DesktopWindowBoundsDocument = Schema.Struct({ }); const DesktopSettingsDocument = Schema.Struct({ + backendMode: Schema.optionalKey(DesktopBackendModeSchema), mainWindowBounds: Schema.optionalKey(Schema.NullOr(DesktopWindowBoundsDocument)), mainWindowMaximized: Schema.optionalKey(Schema.Boolean), serverExposureMode: Schema.optionalKey(DesktopServerExposureModeSchema), @@ -148,6 +153,9 @@ export class DesktopAppSettings extends Context.Service< bounds: DesktopWindowBounds, isMaximized: boolean, ) => Effect.Effect; + readonly setBackendMode: ( + mode: DesktopBackendMode, + ) => Effect.Effect; readonly setServerExposureMode: ( mode: DesktopServerExposureMode, ) => Effect.Effect; @@ -216,6 +224,7 @@ function normalizeDesktopSettingsDocument( (parsed.wslBackendEnabled === undefined && parsed.wslMode === "wsl"); return { + backendMode: parsed.backendMode === "client-only" ? "client-only" : "managed", mainWindowBounds, mainWindowMaximized: mainWindowBounds !== null && parsed.mainWindowMaximized === true, serverExposureMode: @@ -238,6 +247,9 @@ function toDesktopSettingsDocument( ): DesktopSettingsDocument { const document: Mutable = {}; + if (settings.backendMode !== defaults.backendMode) { + document.backendMode = settings.backendMode; + } if (settings.mainWindowBounds !== null) { document.mainWindowBounds = settings.mainWindowBounds; } @@ -284,6 +296,18 @@ function setServerExposureMode( }; } +function setBackendMode( + settings: DesktopSettings, + requestedMode: DesktopBackendMode, +): DesktopSettings { + return settings.backendMode === requestedMode + ? settings + : { + ...settings, + backendMode: requestedMode, + }; +} + function setMainWindowBounds( settings: DesktopSettings, bounds: DesktopWindowBounds, @@ -506,6 +530,10 @@ export const make = Effect.gen(function* () { }, }), ), + setBackendMode: (mode) => + persist((settings) => setBackendMode(settings, mode)).pipe( + Effect.withSpan("desktop.settings.setBackendMode", { attributes: { mode } }), + ), setServerExposureMode: (mode) => persist((settings) => setServerExposureMode(settings, mode)).pipe( Effect.withSpan("desktop.settings.setServerExposureMode", { attributes: { mode } }), @@ -565,6 +593,7 @@ export const layerTest = (initialSettings: DesktopSettings = DEFAULT_DESKTOP_SET load: SynchronizedRef.get(settingsRef), setMainWindowBounds: (bounds, isMaximized) => update((settings) => setMainWindowBounds(settings, bounds, isMaximized)), + setBackendMode: (mode) => update((settings) => setBackendMode(settings, mode)), setServerExposureMode: (mode) => update((settings) => setServerExposureMode(settings, mode)), setTailscaleServe: (input) => update((settings) => setTailscaleServe(settings, input)), diff --git a/apps/desktop/src/updates/DesktopUpdates.test.ts b/apps/desktop/src/updates/DesktopUpdates.test.ts index 32224c7a5ca..85757a468cf 100644 --- a/apps/desktop/src/updates/DesktopUpdates.test.ts +++ b/apps/desktop/src/updates/DesktopUpdates.test.ts @@ -159,6 +159,7 @@ function makeHarness(options: UpdatesHarnessOptions = {}) { get: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS), load: Effect.succeed(DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS), setMainWindowBounds: () => Effect.die("unexpected main window bounds update"), + setBackendMode: () => Effect.die("unexpected backend mode update"), setServerExposureMode: () => Effect.die("unexpected server exposure update"), setTailscaleServe: () => Effect.die("unexpected Tailscale Serve update"), setUpdateChannel: () => Effect.fail(setUpdateChannelError), diff --git a/apps/desktop/src/window/DesktopApplicationMenu.test.ts b/apps/desktop/src/window/DesktopApplicationMenu.test.ts index 168846466ed..f8071f634ae 100644 --- a/apps/desktop/src/window/DesktopApplicationMenu.test.ts +++ b/apps/desktop/src/window/DesktopApplicationMenu.test.ts @@ -74,6 +74,7 @@ const makeDesktopWindowLayer = (selectedAction: Deferred.Deferred) => activate: Effect.void, createMainIfBackendReady: Effect.void, showConnectingSplash: Effect.void, + handleRendererReady: Effect.void, handleBackendReady: () => Effect.void, handleBackendNotReady: Effect.void, flushMainWindowBounds: Effect.void, diff --git a/apps/desktop/src/window/DesktopWindow.test.ts b/apps/desktop/src/window/DesktopWindow.test.ts index 587da8d4431..8cb74dffc05 100644 --- a/apps/desktop/src/window/DesktopWindow.test.ts +++ b/apps/desktop/src/window/DesktopWindow.test.ts @@ -211,6 +211,7 @@ function makeTestLayer(input: { } return { settings: desktopSettings, changed }; }), + setBackendMode: () => Effect.die("unexpected backend mode update"), setServerExposureMode: () => Effect.die("unexpected server exposure update"), setTailscaleServe: () => Effect.die("unexpected Tailscale Serve update"), setUpdateChannel: () => Effect.die("unexpected update channel change"), @@ -436,6 +437,26 @@ describe("DesktopWindow", () => { }), ); + it.effect("opens once the renderer is ready without a backend callback", () => + Effect.gen(function* () { + const fakeWindow = makeFakeBrowserWindow(); + const createCount = yield* Ref.make(0); + const mainWindow = yield* Ref.make>(Option.none()); + const layer = makeTestLayer({ + window: fakeWindow.window, + createCount, + mainWindow, + }); + + yield* Effect.gen(function* () { + const desktopWindow = yield* DesktopWindow.DesktopWindow; + yield* desktopWindow.handleRendererReady; + assert.equal(yield* Ref.get(createCount), 1); + assert.deepEqual(fakeWindow.loadURL.mock.calls[0], ["t3code-dev://app/"]); + }).pipe(Effect.provide(layer)); + }), + ); + it.effect("uses the persisted main window bounds when opening the window", () => Effect.gen(function* () { const fakeWindow = makeFakeBrowserWindow(); diff --git a/apps/desktop/src/window/DesktopWindow.ts b/apps/desktop/src/window/DesktopWindow.ts index db4b698434d..baaf1580765 100644 --- a/apps/desktop/src/window/DesktopWindow.ts +++ b/apps/desktop/src/window/DesktopWindow.ts @@ -66,6 +66,9 @@ export class DesktopWindow extends Context.Service< // mode), before the WSL backend that serves the renderer is ready. It is // dismissed automatically once the real main window reveals. readonly showConnectingSplash: Effect.Effect; + // Marks the packaged/Vite renderer as loadable independently of whether + // this desktop process owns a backend. + readonly handleRendererReady: Effect.Effect; // Marks the primary backend as ready so `createMainIfBackendReady` and the // macOS "activate without windows" path may open the real main window. The // renderer now always loads the local client URL (getDesktopUrl) and connects @@ -727,6 +730,11 @@ export const make = Effect.gen(function* () { Effect.withSpan("desktop.window.showConnectingSplash"), ); + const handleRendererReady = Ref.set(backendReadyRef, true).pipe( + Effect.andThen(createMainIfBackendReady), + Effect.withSpan("desktop.window.handleRendererReady"), + ); + return DesktopWindow.of({ createMain, ensureMain, @@ -754,10 +762,10 @@ export const make = Effect.gen(function* () { }).pipe(Effect.withSpan("desktop.window.activate")), createMainIfBackendReady, showConnectingSplash, + handleRendererReady, handleBackendReady: Effect.fn("desktop.window.handleBackendReady")(function* (httpBaseUrl) { - yield* Ref.set(backendReadyRef, true); yield* logWindowInfo("backend ready", { source: "http", url: httpBaseUrl.href }); - yield* createMainIfBackendReady; + yield* handleRendererReady; }), handleBackendNotReady: Ref.set(backendReadyRef, false).pipe( Effect.withSpan("desktop.window.handleBackendNotReady"), diff --git a/apps/server/src/auth/http.ts b/apps/server/src/auth/http.ts index 780aaabde25..2438a0b44e0 100644 --- a/apps/server/src/auth/http.ts +++ b/apps/server/src/auth/http.ts @@ -37,7 +37,8 @@ import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; import * as EnvironmentAuth from "./EnvironmentAuth.ts"; import * as SessionStore from "./SessionStore.ts"; import { traceAuthenticatedRelayRequest, traceRelayRequest } from "../cloud/traceRelayRequest.ts"; -import { deriveAuthClientMetadata } from "./utils.ts"; +import { makeLocalServerPairingIssuer } from "./localPairing.ts"; +import { deriveAuthClientMetadata, readRequestRemoteAddress } from "./utils.ts"; import { verifyRequestDpopProof } from "./dpop.ts"; const CREDENTIAL_RESPONSE_HEADERS = { @@ -203,6 +204,7 @@ export const authHttpApiLayer = HttpApiBuilder.group( Effect.fnUntraced(function* (handlers) { const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; const sessions = yield* SessionStore.SessionStore; + const issueLocalServerPairing = yield* makeLocalServerPairingIssuer; return handlers .handle( @@ -251,6 +253,30 @@ export const authHttpApiLayer = HttpApiBuilder.group( ), ), ) + .handle( + "localPair", + Effect.fn("environment.auth.localPair")( + function* (args) { + yield* annotateEnvironmentRequest(args.endpoint.name); + // Deliberately no `requireEnvironmentScope`: this endpoint exists to + // bootstrap the caller's first credential. `issueLocalServerPairing` + // authorizes it with filesystem proof instead. + const request = yield* HttpServerRequest.HttpServerRequest; + const result = yield* issueLocalServerPairing({ + challenge: args.payload, + remoteAddress: readRequestRemoteAddress(request), + }); + if (result === null) { + return yield* failEnvironmentAuthInvalid("invalid_credential"); + } + yield* appendCredentialResponseHeaders; + return result; + }, + Effect.catchIf(EnvironmentAuth.isServerAuthInternalError, (error) => + failEnvironmentInternal("pairing_credential_issuance_failed", error), + ), + ), + ) .handle( "token", Effect.fn("environment.auth.token")( diff --git a/apps/server/src/auth/localPairing.test.ts b/apps/server/src/auth/localPairing.test.ts new file mode 100644 index 00000000000..b8008e06391 --- /dev/null +++ b/apps/server/src/auth/localPairing.test.ts @@ -0,0 +1,210 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import { LOCAL_SERVER_CHALLENGE_MAX_BYTES } from "@t3tools/shared/localServerDiscovery"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; + +import * as EnvironmentAuth from "./EnvironmentAuth.ts"; +import { makeLocalServerPairingIssuer } from "./localPairing.ts"; +import * as LocalServerDiscoveryState from "../localServerDiscoveryState.ts"; + +const INSTANCE_ID = "11111111-2222-3333-4444-555555555555"; +const HTTP_BASE_URL = "http://127.0.0.1:3773/"; +// 64 hex characters: the 32-byte nonce the client is required to present. +const NONCE = "a".repeat(64); + +const authLayer = Layer.mock(EnvironmentAuth.EnvironmentAuth)({ + issueStartupPairingCredential: () => + Effect.succeed({ + id: "local-pair-id", + credential: "LOCALPAIR", + expiresAt: DateTime.makeUnsafe("2099-01-01T00:00:00.000Z"), + }), +}); + +/** + * Stand up a challenge directory plus a valid challenge file, then hand back an + * issuer whose discovery state points at it. Each test mutates one input so the + * happy path stays the single source of "otherwise valid". + */ +const setup = Effect.fn(function* (options: { readonly activate?: boolean } = {}) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const runtimeDirectory = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-local-pairing-test-", + }); + const challengeDirectory = path.join(runtimeDirectory, "t3code", "challenges"); + yield* fileSystem.makeDirectory(challengeDirectory, { recursive: true, mode: 0o700 }); + const challengePath = path.join(challengeDirectory, "challenge"); + yield* fileSystem.writeFileString(challengePath, `${NONCE}\n`, { mode: 0o600 }); + yield* fileSystem.chmod(challengePath, 0o600); + + const discoveryState = yield* LocalServerDiscoveryState.LocalServerDiscoveryState; + if (options.activate !== false) { + yield* discoveryState.activate({ + instanceId: INSTANCE_ID, + httpBaseUrl: HTTP_BASE_URL, + platform: "linux", + xdgRuntimeDirectory: runtimeDirectory, + }); + } + const issue = yield* makeLocalServerPairingIssuer; + + return { + fileSystem, + path, + runtimeDirectory, + challengeDirectory, + challengePath, + issue: ( + overrides: { + readonly instanceId?: string; + readonly challengePath?: string; + readonly nonce?: string; + readonly remoteAddress?: string | undefined; + } = {}, + ) => + issue({ + challenge: { + instanceId: overrides.instanceId ?? INSTANCE_ID, + challengePath: overrides.challengePath ?? challengePath, + nonce: overrides.nonce ?? NONCE, + }, + remoteAddress: "remoteAddress" in overrides ? overrides.remoteAddress : "127.0.0.1", + }), + }; +}); + +const provideTestLayers = (effect: Effect.Effect) => + effect.pipe( + Effect.provide(Layer.mergeAll(authLayer, LocalServerDiscoveryState.layer, NodeServices.layer)), + ); + +it.effect("issues a pairing credential when the caller proves filesystem access", () => + provideTestLayers( + Effect.gen(function* () { + const context = yield* setup(); + + const result = yield* context.issue(); + + expect(result).toEqual({ + pairingUrl: "http://127.0.0.1:3773/pair#token=LOCALPAIR", + pairingExpiresAt: "2099-01-01T00:00:00.000Z", + }); + // The challenge file belongs to the client; the server must not delete it. + expect(yield* context.fileSystem.exists(context.challengePath)).toBe(true); + }), + ), +); + +it.effect("rejects every invalid challenge without saying why", () => + provideTestLayers( + Effect.gen(function* () { + const context = yield* setup(); + const { fileSystem, path } = context; + + // Wrong instance: the caller discovered some other server. + expect( + yield* context.issue({ instanceId: "99999999-0000-0000-0000-000000000000" }), + ).toBeNull(); + + // Nonce mismatch. + expect(yield* context.issue({ nonce: "b".repeat(64) })).toBeNull(); + + // Nonce shorter than the required 32 bytes of hex-encoded entropy. + const shortPath = path.join(context.challengeDirectory, "short"); + yield* fileSystem.writeFileString(shortPath, "abcd", { mode: 0o600 }); + yield* fileSystem.chmod(shortPath, 0o600); + expect(yield* context.issue({ challengePath: shortPath, nonce: "abcd" })).toBeNull(); + + // Non-loopback and unknown peers. + expect(yield* context.issue({ remoteAddress: "192.168.1.42" })).toBeNull(); + expect(yield* context.issue({ remoteAddress: undefined })).toBeNull(); + + // Challenge path outside the challenge directory. + const outsidePath = path.join(context.runtimeDirectory, "outside"); + yield* fileSystem.writeFileString(outsidePath, `${NONCE}\n`, { mode: 0o600 }); + yield* fileSystem.chmod(outsidePath, 0o600); + expect(yield* context.issue({ challengePath: outsidePath })).toBeNull(); + + // Nested one level deeper: `isContainedChallengePath` requires the file to + // sit directly in the challenge directory. + const nestedDirectory = path.join(context.challengeDirectory, "nested"); + yield* fileSystem.makeDirectory(nestedDirectory, { recursive: true, mode: 0o700 }); + const nestedPath = path.join(nestedDirectory, "challenge"); + yield* fileSystem.writeFileString(nestedPath, `${NONCE}\n`, { mode: 0o600 }); + yield* fileSystem.chmod(nestedPath, 0o600); + expect(yield* context.issue({ challengePath: nestedPath })).toBeNull(); + + // Symlink inside the directory pointing at a file outside it. This is the + // arbitrary-file-read case, so it must be caught by realPath canonicalization. + const symlinkPath = path.join(context.challengeDirectory, "escape"); + yield* fileSystem.symlink(outsidePath, symlinkPath); + // Confirm the symlink really does resolve out of the directory, otherwise + // this case would pass for the wrong reason (a broken link). + expect(yield* fileSystem.realPath(symlinkPath)).toBe(yield* fileSystem.realPath(outsidePath)); + expect(yield* context.issue({ challengePath: symlinkPath })).toBeNull(); + + // Traversal that lands outside after canonicalization. + expect( + yield* context.issue({ + challengePath: path.join(context.challengeDirectory, "..", "..", "outside"), + }), + ).toBeNull(); + + // Missing file. + expect( + yield* context.issue({ challengePath: path.join(context.challengeDirectory, "absent") }), + ).toBeNull(); + + // A directory rather than a regular file. + expect(yield* context.issue({ challengePath: nestedDirectory })).toBeNull(); + + // World/group readable: another local user could have planted the nonce. + const loosePath = path.join(context.challengeDirectory, "loose"); + yield* fileSystem.writeFileString(loosePath, `${NONCE}\n`, { mode: 0o600 }); + yield* fileSystem.chmod(loosePath, 0o644); + expect(yield* context.issue({ challengePath: loosePath })).toBeNull(); + + // Oversized challenge file. + const hugePath = path.join(context.challengeDirectory, "huge"); + yield* fileSystem.writeFileString( + hugePath, + `${NONCE}\n${"p".repeat(LOCAL_SERVER_CHALLENGE_MAX_BYTES + 1)}`, + { mode: 0o600 }, + ); + yield* fileSystem.chmod(hugePath, 0o600); + expect(yield* context.issue({ challengePath: hugePath })).toBeNull(); + + // Sanity: the otherwise-valid challenge still works, so the rejections + // above are attributable to the mutation and not to a broken fixture. + expect(yield* context.issue()).not.toBeNull(); + }), + ), +); + +it.effect("stays closed when local discovery was never activated", () => + provideTestLayers( + Effect.gen(function* () { + const context = yield* setup({ activate: false }); + + expect(yield* context.issue()).toBeNull(); + }), + ), +); + +it.effect("stays closed once discovery is deactivated", () => + provideTestLayers( + Effect.gen(function* () { + const context = yield* setup(); + const discoveryState = yield* LocalServerDiscoveryState.LocalServerDiscoveryState; + + expect(yield* context.issue()).not.toBeNull(); + yield* discoveryState.deactivate; + expect(yield* context.issue()).toBeNull(); + }), + ), +); diff --git a/apps/server/src/auth/localPairing.ts b/apps/server/src/auth/localPairing.ts new file mode 100644 index 00000000000..adfb00e72e8 --- /dev/null +++ b/apps/server/src/auth/localPairing.ts @@ -0,0 +1,172 @@ +import * as NodeCrypto from "node:crypto"; + +import type { LocalServerPairingChallenge, LocalServerPairingResult } from "@t3tools/contracts"; +import { + isCanonicalLoopbackHostname, + isContainedChallengePath, + LOCAL_SERVER_CHALLENGE_MAX_BYTES, + LOCAL_SERVER_CHALLENGE_NONCE_BYTES, + resolveLocalServerChallengeDirectory, +} from "@t3tools/shared/localServerDiscovery"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as FileSystem from "effect/FileSystem"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; + +import * as EnvironmentAuth from "./EnvironmentAuth.ts"; +import { LocalServerDiscoveryState } from "../localServerDiscoveryState.ts"; +import { buildPairingUrl } from "../startupAccess.ts"; + +/** Hex-encoded, so twice the byte count. */ +const MINIMUM_NONCE_LENGTH = LOCAL_SERVER_CHALLENGE_NONCE_BYTES * 2; +const CHALLENGE_FILE_MODE = 0o600; + +export type LocalPairingRejectionReason = + | "discovery_inactive" + | "remote_not_loopback" + | "instance_mismatch" + | "challenge_directory_unavailable" + | "challenge_path_unresolvable" + | "challenge_path_escapes_directory" + | "challenge_not_regular_file" + | "challenge_owner_mismatch" + | "challenge_mode_mismatch" + | "challenge_too_large" + | "nonce_too_short" + | "challenge_unreadable" + | "nonce_mismatch"; + +function timingSafeEqualUtf8(left: string, right: string): boolean { + const leftBuffer = Buffer.from(left, "utf8"); + const rightBuffer = Buffer.from(right, "utf8"); + // `timingSafeEqual` throws on unequal lengths, so the length comparison has + // to happen first. It leaks only the nonce length, never its contents. + if (leftBuffer.length !== rightBuffer.length) { + return false; + } + return NodeCrypto.timingSafeEqual(leftBuffer, rightBuffer); +} + +/** + * Build the `/api/auth/local-pair` validator. + * + * This is the one credential-issuing path that is not behind + * `requireEnvironmentScope`, because it is how a same-user local client + * bootstraps its very first credential. Authorization is instead proof that the + * caller can create a file inside the server owner's 0700 `XDG_RUNTIME_DIR`, + * which no other local user can do. + * + * Every check fails closed and the issuer returns `null` for all of them so the + * HTTP response cannot be used to probe which one tripped. + */ +export const makeLocalServerPairingIssuer = Effect.gen(function* () { + const discoveryState = yield* LocalServerDiscoveryState; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; + + const reject = (reason: LocalPairingRejectionReason) => + Effect.logDebug("local pairing challenge rejected", { reason }).pipe(Effect.as(null)); + + return Effect.fn("server.localPair.issue")(function* (input: { + readonly challenge: LocalServerPairingChallenge; + readonly remoteAddress: string | undefined; + }) { + // 1. Local discovery must actually be running in this process, and the + // request must have arrived over loopback. + const discovery = yield* discoveryState.current; + if (discovery === null) { + return yield* reject("discovery_inactive"); + } + const remoteAddress = input.remoteAddress?.trim(); + if ( + remoteAddress === undefined || + remoteAddress.length === 0 || + !isCanonicalLoopbackHostname(remoteAddress) + ) { + return yield* reject("remote_not_loopback"); + } + + // 2. The caller must be talking to the instance it discovered. `instanceId` + // is public-to-this-user metadata, so a plain comparison is fine. + if (input.challenge.instanceId !== discovery.instanceId) { + return yield* reject("instance_mismatch"); + } + + // 3. The challenge file must live directly inside our challenge directory. + // Both sides are canonicalized so a symlink cannot escape it. + const challengeDirectory = resolveLocalServerChallengeDirectory({ + platform: discovery.platform, + xdgRuntimeDirectory: discovery.xdgRuntimeDirectory, + path, + }); + if (challengeDirectory === null) { + return yield* reject("challenge_directory_unavailable"); + } + const canonicalChallengeDirectory = yield* fileSystem + .realPath(challengeDirectory) + .pipe(Effect.option); + if (Option.isNone(canonicalChallengeDirectory)) { + return yield* reject("challenge_directory_unavailable"); + } + const canonicalChallengePath = yield* fileSystem + .realPath(input.challenge.challengePath) + .pipe(Effect.option); + if (Option.isNone(canonicalChallengePath)) { + return yield* reject("challenge_path_unresolvable"); + } + if ( + !isContainedChallengePath({ + canonicalChallengePath: canonicalChallengePath.value, + canonicalChallengeDirectory: canonicalChallengeDirectory.value, + path, + }) + ) { + return yield* reject("challenge_path_escapes_directory"); + } + + // 4. It must be a regular file we own, private to us, and small. + const info = yield* fileSystem.stat(canonicalChallengePath.value).pipe(Effect.option); + if (Option.isNone(info)) { + return yield* reject("challenge_path_unresolvable"); + } + if (info.value.type !== "File") { + return yield* reject("challenge_not_regular_file"); + } + const processUid = process.getuid?.(); + const fileUid = Option.getOrUndefined(info.value.uid); + if (processUid === undefined || fileUid === undefined || fileUid !== processUid) { + return yield* reject("challenge_owner_mismatch"); + } + if ((info.value.mode & 0o777) !== CHALLENGE_FILE_MODE) { + return yield* reject("challenge_mode_mismatch"); + } + if (info.value.size > BigInt(LOCAL_SERVER_CHALLENGE_MAX_BYTES)) { + return yield* reject("challenge_too_large"); + } + + // 5. Its contents must equal the presented nonce, compared in constant time. + const nonce = input.challenge.nonce.trim(); + if (nonce.length < MINIMUM_NONCE_LENGTH) { + return yield* reject("nonce_too_short"); + } + const contents = yield* fileSystem + .readFileString(canonicalChallengePath.value) + .pipe(Effect.option); + if (Option.isNone(contents)) { + return yield* reject("challenge_unreadable"); + } + if (!timingSafeEqualUtf8(contents.value.trim(), nonce)) { + return yield* reject("nonce_mismatch"); + } + + // 6. Proven. Issue a fresh pairing credential; nothing was ever on disk. + // The challenge file belongs to the client, so we leave it alone. + const issued = yield* serverAuth.issueStartupPairingCredential(); + return { + pairingUrl: buildPairingUrl(discovery.httpBaseUrl, issued.credential), + pairingExpiresAt: DateTime.formatIso(issued.expiresAt), + } satisfies LocalServerPairingResult; + }); +}); diff --git a/apps/server/src/auth/utils.ts b/apps/server/src/auth/utils.ts index 39f04988ac5..085fc0d5bd0 100644 --- a/apps/server/src/auth/utils.ts +++ b/apps/server/src/auth/utils.ts @@ -120,6 +120,17 @@ function readRemoteAddressFromSource(source: unknown): string | undefined { return normalizeIpAddress(candidate.socket?.remoteAddress ?? candidate.remoteAddress); } +/** + * Peer address of the underlying socket, IPv4-mapped IPv6 unwrapped. Returns + * `undefined` when it cannot be determined so callers that gate on loopback can + * fail closed. + */ +export function readRequestRemoteAddress( + request: HttpServerRequest.HttpServerRequest, +): string | undefined { + return readRemoteAddressFromSource(request.source); +} + export function deriveAuthClientMetadata(input: { readonly request: HttpServerRequest.HttpServerRequest; readonly presented?: AuthClientPresentationMetadata; diff --git a/apps/server/src/localServerAdvertisement.test.ts b/apps/server/src/localServerAdvertisement.test.ts new file mode 100644 index 00000000000..3c4f5ea540b --- /dev/null +++ b/apps/server/src/localServerAdvertisement.test.ts @@ -0,0 +1,217 @@ +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 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"; + +import * as ServerConfig from "./config.ts"; +import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; +import { startLocalServerAdvertisement } from "./localServerAdvertisement.ts"; +import * as LocalServerDiscoveryState from "./localServerDiscoveryState.ts"; + +const decodeRecord = Schema.decodeUnknownEffect(Schema.fromJsonString(LocalServerAdvertisement)); + +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 }, + }), +}); + +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; + readonly fileSystem?: FileSystem.FileSystem; +}) { + const discoveryState = yield* LocalServerDiscoveryState.LocalServerDiscoveryState; + let advertisement = 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), + ); + if (input.fileSystem !== undefined) { + advertisement = advertisement.pipe( + Effect.provideService(FileSystem.FileSystem, input.fileSystem), + ); + } + yield* advertisement; + 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 config = yield* makeConfig(runtimeDirectory); + const advertisementScope = yield* Scope.make(); + + const discoveryState = yield* runAdvertisement({ + config, + runtimeDirectory, + scope: advertisementScope, + }); + + 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); + + 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(yield* fileSystem.readDirectory(directory)).toEqual([]); + expect(yield* discoveryState.current).toBeNull(); + }).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; + const path = yield* Path.Path; + 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 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(); + + const discoveryState = yield* runAdvertisement({ + config, + runtimeDirectory, + platform: testCase.platform, + scope: advertisementScope, + }); + + 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(Layer.mergeAll(LocalServerDiscoveryState.layer, NodeServices.layer))), +); diff --git a/apps/server/src/localServerAdvertisement.ts b/apps/server/src/localServerAdvertisement.ts new file mode 100644 index 00000000000..064a52c3be7 --- /dev/null +++ b/apps/server/src/localServerAdvertisement.ts @@ -0,0 +1,135 @@ +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 Effect from "effect/Effect"; +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 * as ServerConfig from "./config.ts"; +import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; +import { LocalServerDiscoveryState } from "./localServerDiscoveryState.ts"; +import { resolveLocalAdvertisementHttpBaseUrl } from "./startupAccess.ts"; + +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))); +}); + +/** + * 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 connectionString: string; + 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 discoveryState = yield* LocalServerDiscoveryState; + 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.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 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, + recordPath, + tempPath, + record: { + version: 1, + instanceId, + pid: process.pid, + startedAt, + httpBaseUrl, + environmentId: environment.environmentId, + label: environment.label, + }, + }), + ); + if (Exit.isFailure(publishExit)) { + yield* cleanup; + yield* Effect.logWarning("Local T3 Code server discovery is unavailable.", { + recordPath, + cause: publishExit.cause, + }); + return; + } + }, +); diff --git a/apps/server/src/localServerDiscoveryState.ts b/apps/server/src/localServerDiscoveryState.ts new file mode 100644 index 00000000000..b92131475fc --- /dev/null +++ b/apps/server/src/localServerDiscoveryState.ts @@ -0,0 +1,43 @@ +import * as Context from "effect/Context"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; + +/** + * What `startLocalServerAdvertisement` published for this process. It exists so + * `/api/auth/local-pair` can answer two questions without re-deriving them: + * whether local discovery is active at all, and which instance/runtime + * directory the caller must prove against. + * + * `null` (the initial value) means the endpoint is closed. That is the + * fail-closed default for every server that is not a headless, Linux, + * loopback-only `t3 serve`. + */ +export interface LocalServerDiscoveryRecord { + readonly instanceId: string; + /** Canonical loopback base URL, e.g. `http://127.0.0.1:3773/`. */ + readonly httpBaseUrl: string; + readonly platform: NodeJS.Platform; + readonly xdgRuntimeDirectory: string | undefined; +} + +export class LocalServerDiscoveryState extends Context.Service< + LocalServerDiscoveryState, + { + readonly current: Effect.Effect; + readonly activate: (record: LocalServerDiscoveryRecord) => Effect.Effect; + readonly deactivate: Effect.Effect; + } +>()("t3/localServerDiscoveryState") {} + +const make = Effect.gen(function* () { + const state = yield* Ref.make(null); + + return { + current: Ref.get(state), + activate: (record) => Ref.set(state, record), + deactivate: Ref.set(state, null), + } satisfies LocalServerDiscoveryState["Service"]; +}); + +export const layer = Layer.effect(LocalServerDiscoveryState, make); diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 871b79eca90..17b801bdcf6 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -72,6 +72,7 @@ import { vi } from "vite-plus/test"; const TEST_EPOCH = DateTime.makeUnsafe("1970-01-01T00:00:00.000Z"); import * as ServerConfig from "./config.ts"; +import * as LocalServerDiscoveryState from "./localServerDiscoveryState.ts"; import { makeRoutesLayer } from "./server.ts"; import * as CheckpointDiffQuery from "./checkpointing/CheckpointDiffQuery.ts"; import * as GitManager from "./git/GitManager.ts"; @@ -635,7 +636,9 @@ const buildAppUnderTest = (options?: { }), }), ), - Layer.provide(gitManagerLayer), + // Merged rather than a separate `Layer.provide` so this chain stays under + // the 20-argument `pipe` overload limit. + Layer.provide(Layer.mergeAll(gitManagerLayer, LocalServerDiscoveryState.layer)), Layer.provide(gitVcsDriverLayer), Layer.provide(gitWorkflowLayer), Layer.provide(reviewLayer), diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 66b9823afb3..e2885642c99 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -16,6 +16,7 @@ import { fixPath } from "./os-jank.ts"; import { websocketRpcRouteLayer } from "./ws.ts"; import * as ExternalLauncher from "./process/externalLauncher.ts"; import { layerConfig as SqlitePersistenceLayerLive } from "./persistence/Layers/Sqlite.ts"; +import * as LocalServerDiscoveryState from "./localServerDiscoveryState.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; import { ProviderSessionDirectoryLive } from "./provider/Layers/ProviderSessionDirectory.ts"; @@ -342,6 +343,7 @@ const RuntimeDependenciesLive = RuntimeCoreDependenciesLive.pipe( Layer.provideMerge(AnalyticsService.layer), Layer.provideMerge(ExternalLauncher.layer), Layer.provideMerge(ServerLifecycleEvents.layer), + Layer.provideMerge(LocalServerDiscoveryState.layer), Layer.provide(NetService.layer), ); diff --git a/apps/server/src/serverRuntimeStartup.ts b/apps/server/src/serverRuntimeStartup.ts index b52b577c5b5..b399173b2ff 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({ + connectionString: accessInfo.connectionString, + }); yield* runStartupPhase( "headless.output", Console.log(formatHeadlessServeOutput(accessInfo)), diff --git a/apps/server/src/startupAccess.test.ts b/apps/server/src/startupAccess.test.ts index 03c01170f15..8198d93ef17 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"); diff --git a/apps/server/src/startupAccess.ts b/apps/server/src/startupAccess.ts index 7df131669ba..7964f50eed7 100644 --- a/apps/server/src/startupAccess.ts +++ b/apps/server/src/startupAccess.ts @@ -97,6 +97,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 = []; diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx index aa7547c8ba6..b0f21b281f6 100644 --- a/apps/web/src/components/CommandPalette.tsx +++ b/apps/web/src/components/CommandPalette.tsx @@ -56,7 +56,11 @@ import { useEnvironmentQuery } from "../state/query"; import { sourceControlEnvironment } from "../state/sourceControl"; import { useAtomCommand } from "../state/use-atom-command"; import { useAtomQueryRunner } from "../state/use-atom-query-runner"; -import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; +import { + useAppOwnsLocalEnvironment, + useEnvironments, + usePrimaryEnvironmentId, +} from "../state/environments"; import { useProjects, useThreadShells } from "../state/entities"; import { resolveThreadActionProjectRef, startNewThreadFromContext } from "../lib/chatThreadActions"; import { @@ -492,6 +496,7 @@ function OpenCommandPaletteDialog(props: { const { environments } = useEnvironments(); const desktopLocalBootstraps = useDesktopLocalBootstraps(); const primaryEnvironmentId = usePrimaryEnvironmentId(); + const ownsLocalEnvironment = useAppOwnsLocalEnvironment(); const { activeDraftThread, activeThread, defaultProjectRef, handleNewThread } = useHandleNewThread(); const projects = useProjects(); @@ -540,12 +545,14 @@ function OpenCommandPaletteDialog(props: { projects: clientSettings.sidebarProjectSortOrder === "manual" ? orderedProjects : projects, settings: projectGroupingSettings, primaryEnvironmentId, + ownsLocalEnvironment, resolveEnvironmentLabel: (environmentId) => environmentLabelById.get(environmentId) ?? null, }), [ clientSettings.sidebarProjectSortOrder, environmentLabelById, orderedProjects, + ownsLocalEnvironment, primaryEnvironmentId, projectGroupingSettings, projects, diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index a1d95eaa734..b814edce7fa 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -71,6 +71,7 @@ import { type SidebarThreadSortOrder, } from "@t3tools/contracts/settings"; import { isDesktopLocalConnectionTarget } from "../connection/desktopLocal"; +import { isRemoteEnvironmentId } from "../environmentPresence"; import { useDesktopLocalBootstraps } from "../connection/useDesktopLocalBootstraps"; import { isElectron } from "../env"; import { useOpenPrLink } from "../lib/openPullRequestLink"; @@ -114,7 +115,13 @@ import { projectEnvironment } from "../state/projects"; import { useEnvironmentQuery } from "../state/query"; import { threadEnvironment, useEnvironmentThread } from "../state/threads"; import { vcsEnvironment } from "../state/vcs"; -import { useEnvironment, useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; +import { + useAppOwnsLocalEnvironment, + useEnvironment, + useEnvironmentPresenceScope, + useEnvironments, + usePrimaryEnvironmentId, +} from "../state/environments"; import { buildThreadRouteParams, resolveActiveThreadRouteRef, @@ -382,9 +389,8 @@ export const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThr reportFailure: false, }); const environment = useEnvironment(thread.environmentId); - const primaryEnvironmentId = usePrimaryEnvironmentId(); - const isRemoteThread = - primaryEnvironmentId !== null && thread.environmentId !== primaryEnvironmentId; + const presenceScope = useEnvironmentPresenceScope(); + const isRemoteThread = isRemoteEnvironmentId(thread.environmentId, presenceScope); const remoteEnvLabel = environment?.label ?? null; // A desktop-local secondary backend (e.g. the WSL backend) shows up as a // bearer environment whose connection id is prefixed "local:". It runs on the @@ -3038,6 +3044,7 @@ export default function Sidebar() { const shortcutModifiers = useShortcutModifierState(); const { environments } = useEnvironments(); const primaryEnvironmentId = usePrimaryEnvironmentId(); + const ownsLocalEnvironment = useAppOwnsLocalEnvironment(); const environmentLabelById = useMemo( () => new Map( @@ -3092,6 +3099,7 @@ export default function Sidebar() { projects: orderedProjects, settings: projectGroupingSettings, primaryEnvironmentId, + ownsLocalEnvironment, resolveEnvironmentLabel: (environmentId) => environmentLabelById.get(environmentId) ?? null, isDesktopLocalEnvironment: (environmentId) => desktopLocalEnvironmentIds.has(environmentId), }); @@ -3099,6 +3107,7 @@ export default function Sidebar() { environmentLabelById, desktopLocalEnvironmentIds, orderedProjects, + ownsLocalEnvironment, projectGroupingSettings, primaryEnvironmentId, ]); diff --git a/apps/web/src/components/SidebarV2.tsx b/apps/web/src/components/SidebarV2.tsx index 8c5891ebe7e..c312a9533fa 100644 --- a/apps/web/src/components/SidebarV2.tsx +++ b/apps/web/src/components/SidebarV2.tsx @@ -88,7 +88,8 @@ import { startNewThreadFromContext } from "../lib/chatThreadActions"; import { useClientSettings, useUpdateClientSettings } from "../hooks/useSettings"; import { useCopyToClipboard } from "../hooks/useCopyToClipboard"; import { useNowMinute } from "../hooks/useNowMinute"; -import { useEnvironments, usePrimaryEnvironmentId } from "../state/environments"; +import { useEnvironmentPresenceScope, useEnvironments } from "../state/environments"; +import { isRemoteEnvironmentId, type EnvironmentPresenceScope } from "../environmentPresence"; import { useProjects, useThreadShells } from "../state/entities"; import { environmentServerConfigsAtom, primaryServerKeybindingsAtom } from "../state/server"; import { vcsEnvironment } from "../state/vcs"; @@ -370,7 +371,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { wokeAt: string | null; isActive: boolean; jumpLabel: string | null; - currentEnvironmentId: string | null; + presenceScope: EnvironmentPresenceScope; environmentLabel: string | null; projectCwd: string | null; projectTitle: string | null; @@ -523,8 +524,7 @@ const SidebarV2Row = memo(function SidebarV2Row(props: { ? getTriggerDisplayModelLabel(selectedModel) : thread.modelSelection.model; - const isRemote = - props.currentEnvironmentId !== null && thread.environmentId !== props.currentEnvironmentId; + const isRemote = isRemoteEnvironmentId(thread.environmentId, props.presenceScope); const detailsTooltip = ( s.clearSelection); const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor); const toggleThreadSelection = useThreadSelectionStore((s) => s.toggleThread); @@ -1092,11 +1093,13 @@ export default function SidebarV2() { projects: sidebarProjectSortOrder === "manual" ? orderedProjects : projects, settings: projectGroupingSettings, primaryEnvironmentId, + ownsLocalEnvironment, resolveEnvironmentLabel: (environmentId) => environmentLabelById.get(environmentId) ?? null, }), [ environmentLabelById, orderedProjects, + ownsLocalEnvironment, primaryEnvironmentId, projectGroupingSettings, projects, @@ -2421,7 +2424,7 @@ export default function SidebarV2() { wokeAt={threadWokeAt(thread, { now: snoozeNow })} isActive={routeThreadKey === threadKey} jumpLabel={showJumpHints ? (jumpLabelByKey.get(threadKey) ?? null) : null} - currentEnvironmentId={primaryEnvironmentId} + presenceScope={presenceScope} environmentLabel={environmentLabelById.get(thread.environmentId) ?? null} projectCwd={ projectCwdByKey.get(`${thread.environmentId}:${thread.projectId}`) ?? null diff --git a/apps/web/src/components/ThreadStatusIndicators.tsx b/apps/web/src/components/ThreadStatusIndicators.tsx index 0d3291a9e28..ecbb6d6c4a0 100644 --- a/apps/web/src/components/ThreadStatusIndicators.tsx +++ b/apps/web/src/components/ThreadStatusIndicators.tsx @@ -6,7 +6,8 @@ import { import type { VcsStatusResult } from "@t3tools/contracts"; import { CloudIcon, FolderGit2Icon, GitPullRequestIcon, TerminalIcon } from "lucide-react"; import { useMemo } from "react"; -import { useEnvironment, usePrimaryEnvironmentId } from "../state/environments"; +import { isRemoteEnvironmentId } from "../environmentPresence"; +import { useEnvironment, useEnvironmentPresenceScope } from "../state/environments"; import { useProject } from "../state/entities"; import { useEnvironmentQuery } from "../state/query"; import { useThreadRunningTerminalIds } from "../state/terminalSessions"; @@ -307,9 +308,8 @@ export function ThreadRowTrailingStatus({ thread }: { thread: SidebarThreadSumma threadId: thread.id, }); const environment = useEnvironment(thread.environmentId); - const primaryEnvironmentId = usePrimaryEnvironmentId(); - const isRemoteThread = - primaryEnvironmentId !== null && thread.environmentId !== primaryEnvironmentId; + const presenceScope = useEnvironmentPresenceScope(); + const isRemoteThread = isRemoteEnvironmentId(thread.environmentId, presenceScope); const remoteEnvLabel = environment?.label ?? null; const threadEnvironmentLabel = isRemoteThread ? (remoteEnvLabel ?? "Remote") : null; const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); diff --git a/apps/web/src/components/chat/DraftHeroHeadline.tsx b/apps/web/src/components/chat/DraftHeroHeadline.tsx index 98091f9aab6..6f1dd2a7be4 100644 --- a/apps/web/src/components/chat/DraftHeroHeadline.tsx +++ b/apps/web/src/components/chat/DraftHeroHeadline.tsx @@ -12,7 +12,11 @@ import { buildSidebarProjectSnapshots, } from "~/sidebarProjectGrouping"; import { useProjects, useThreadShells } from "~/state/entities"; -import { useEnvironments, usePrimaryEnvironmentId } from "~/state/environments"; +import { + useAppOwnsLocalEnvironment, + useEnvironments, + usePrimaryEnvironmentId, +} from "~/state/environments"; import { sortLogicalProjectsForSidebar } from "../Sidebar.logic"; import { Menu, @@ -37,6 +41,7 @@ export function DraftHeroHeadline({ const threads = useThreadShells(); const { environments } = useEnvironments(); const primaryEnvironmentId = usePrimaryEnvironmentId(); + const ownsLocalEnvironment = useAppOwnsLocalEnvironment(); const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const projectSortOrder = useClientSettings((settings) => settings.sidebarProjectSortOrder); const handleNewThread = useNewThreadHandler(); @@ -56,6 +61,7 @@ export function DraftHeroHeadline({ projects, settings: projectGroupingSettings, primaryEnvironmentId, + ownsLocalEnvironment, resolveEnvironmentLabel: (environmentId) => environmentLabelById.get(environmentId) ?? null, }), @@ -64,6 +70,7 @@ export function DraftHeroHeadline({ ), [ environmentLabelById, + ownsLocalEnvironment, primaryEnvironmentId, projectGroupingSettings, projectSortOrder, diff --git a/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts b/apps/web/src/components/settings/ConnectionsSettings.logic.test.ts index c8f039e6428..3a4699536d8 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,46 @@ 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/", + 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 connected saved environments and offers Pair again while reconnecting", () => { + expect( + selectLocalServerPairingCandidates( + [advertisement], + [ + { + environmentId: advertisement.environmentId, + connection: { phase: "connected" }, + }, + ], + ), + ).toEqual([]); + expect( + selectLocalServerPairingCandidates( + [advertisement], + [ + { + environmentId: advertisement.environmentId, + connection: { phase: "reconnecting" }, + }, + ], + ), + ).toEqual([{ advertisement, pairAgain: true }]); + }); +}); diff --git a/apps/web/src/components/settings/ConnectionsSettings.logic.ts b/apps/web/src/components/settings/ConnectionsSettings.logic.ts index 362a24dd3ff..eec3ff06aeb 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?.connection.phase === "connected") { + 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 52b89530127..4d94d0b4895 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, @@ -24,6 +24,9 @@ import { type AuthPairingLink, type AdvertisedEndpoint, type DesktopDiscoveredSshHost, + type DesktopBackendMode, + type DesktopBackendModeState, + type LocalServerAdvertisement, type DesktopSshEnvironmentTarget, type DesktopServerExposureState, type DesktopWslState, @@ -41,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, @@ -139,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 @@ -1711,6 +1718,18 @@ 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 [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 }); @@ -1734,6 +1753,88 @@ 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 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 pairLocalServer = desktopBridge?.pairLocalServer; + if (!pairLocalServer) return; + setPairingLocalServerInstanceId(advertisement.instanceId); + setLocalServerDiscoveryError(null); + try { + // 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 }); + 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: `${advertisement.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>( @@ -1841,7 +1942,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 @@ -2432,6 +2534,42 @@ export function ConnectionsSettings() { ); + const renderLocalServerPairingCandidates = () => ( +
+ {localServerPairingCandidates.map(({ advertisement, pairAgain }) => ( +
+ + + + + + {advertisement.label} + + + {advertisement.httpBaseUrl} · process {advertisement.pid} + + + +
+ ))} + {localServerDiscoveryError ? ( +

{localServerDiscoveryError}

+ ) : null} +
+ ); const renderSshFields = () => (
@@ -2975,9 +3113,89 @@ 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); + void refreshLocalServerAdvertisements(); + 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 ? ( @@ -3315,6 +3533,34 @@ export function ConnectionsSettings() { )} + {localServerPairingCandidates.length > 0 ? ( + void refreshLocalServerAdvertisements()} + > + {isDiscoveringLocalServers ? ( + + ) : ( + + )} + Scan again + + } + > +

+ 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()} +
+ ) : null} + { setAddBackendDialogOpen(open); + if (open) { + void refreshLocalServerAdvertisements(); + } if (!open) { setSavedBackendError(null); } @@ -3354,6 +3603,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/apps/web/src/connection/platform.ts b/apps/web/src/connection/platform.ts index 56d25fa142e..699711ffcc3 100644 --- a/apps/web/src/connection/platform.ts +++ b/apps/web/src/connection/platform.ts @@ -45,7 +45,8 @@ import { FetchHttpClient } from "effect/unstable/http"; import { readDesktopPrimaryBearerToken } from "../environments/primary/desktopAuth"; import { primaryEnvironmentHttpLayer } from "../environments/primary/httpLayer"; import { - readPrimaryEnvironmentTarget, + readOptionalPrimaryEnvironmentTarget, + isDesktopClientOnlyMode, type PrimaryEnvironmentTarget, } from "../environments/primary/target"; import { clearComposerDraftsEnvironment } from "../composerDraftStore"; @@ -399,7 +400,7 @@ export type PrimaryEnvironmentTargetRead = }; export function readPrimaryEnvironmentTargetResult( - readTarget: () => PrimaryEnvironmentTarget | null = readPrimaryEnvironmentTarget, + readTarget: () => PrimaryEnvironmentTarget | null = readOptionalPrimaryEnvironmentTarget, ): PrimaryEnvironmentTargetRead { try { return { _tag: "Success", target: readTarget() }; @@ -456,7 +457,7 @@ export function secondaryRegistrationsToRetainAfterTopologyRead( const platformConnectionSourceLayer = Layer.effect( PlatformConnectionSource, Effect.gen(function* () { - if (isHostedStaticApp()) { + if (isHostedStaticApp() || isDesktopClientOnlyMode()) { return PlatformConnectionSource.of({ registrations: Stream.empty, }); diff --git a/apps/web/src/environmentGrouping.test.ts b/apps/web/src/environmentGrouping.test.ts index 17d86ca0912..c7968ee0eee 100644 --- a/apps/web/src/environmentGrouping.test.ts +++ b/apps/web/src/environmentGrouping.test.ts @@ -81,6 +81,33 @@ describe("environment grouping", () => { expect(projectGroupCount).toBe(1); }); + it("marks every project remote for an app with no local backend of its own", () => { + const remote = makeProject({ + id: ProjectId.make("project-remote"), + environmentId: remoteEnvironmentId, + }); + + const [clientOnly] = buildSidebarProjectSnapshots({ + projects: [remote], + settings: defaultGroupingSettings, + primaryEnvironmentId: null, + ownsLocalEnvironment: false, + resolveEnvironmentLabel: () => "remote", + }); + + expect(clientOnly?.environmentPresence).toBe("remote-only"); + expect(clientOnly?.remoteEnvironmentLabels).toEqual(["remote"]); + + const [managed] = buildSidebarProjectSnapshots({ + projects: [remote], + settings: defaultGroupingSettings, + primaryEnvironmentId: null, + resolveEnvironmentLabel: () => "remote", + }); + + expect(managed?.environmentPresence).toBe("local-only"); + }); + it("keeps projects without repository identity physically scoped", () => { const primary = makeProject(); const remote = makeProject({ diff --git a/apps/web/src/environmentPresence.test.ts b/apps/web/src/environmentPresence.test.ts new file mode 100644 index 00000000000..7e6e9daf47a --- /dev/null +++ b/apps/web/src/environmentPresence.test.ts @@ -0,0 +1,29 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { isRemoteEnvironmentId } from "./environmentPresence"; + +const primaryEnvironmentId = EnvironmentId.make("env-primary"); +const remoteEnvironmentId = EnvironmentId.make("env-remote"); + +describe("isRemoteEnvironmentId", () => { + it("treats the primary environment as local and everything else as remote", () => { + const scope = { primaryEnvironmentId, ownsLocalEnvironment: true }; + + expect(isRemoteEnvironmentId(primaryEnvironmentId, scope)).toBe(false); + expect(isRemoteEnvironmentId(remoteEnvironmentId, scope)).toBe(true); + }); + + it("treats every environment as remote when the app owns no local backend", () => { + const scope = { primaryEnvironmentId: null, ownsLocalEnvironment: false }; + + expect(isRemoteEnvironmentId(remoteEnvironmentId, scope)).toBe(true); + expect(isRemoteEnvironmentId(primaryEnvironmentId, scope)).toBe(true); + }); + + it("treats nothing as remote while a managed app's primary is still registering", () => { + const scope = { primaryEnvironmentId: null, ownsLocalEnvironment: true }; + + expect(isRemoteEnvironmentId(remoteEnvironmentId, scope)).toBe(false); + }); +}); diff --git a/apps/web/src/environmentPresence.ts b/apps/web/src/environmentPresence.ts new file mode 100644 index 00000000000..109f0cd8a8c --- /dev/null +++ b/apps/web/src/environmentPresence.ts @@ -0,0 +1,28 @@ +import type { EnvironmentId } from "@t3tools/contracts"; + +/** + * The comparison the UI uses to decide whether an environment is "remote" — + * i.e. somewhere other than the machine this app runs its own backend on. + */ +export interface EnvironmentPresenceScope { + /** The environment served by the app's own backend, when it has one. */ + readonly primaryEnvironmentId: EnvironmentId | null; + /** + * False when this app can never own a local backend: a client-only desktop + * and the hosted static web app only ever talk to backends they paired with, + * so every environment they reach is remote. This is deliberately separate + * from a null `primaryEnvironmentId`, which a managed app also reports while + * its local backend is still registering — there, nothing is remote yet. + */ + readonly ownsLocalEnvironment: boolean; +} + +export function isRemoteEnvironmentId( + environmentId: EnvironmentId, + scope: EnvironmentPresenceScope, +): boolean { + if (!scope.ownsLocalEnvironment) { + return true; + } + return scope.primaryEnvironmentId !== null && environmentId !== scope.primaryEnvironmentId; +} diff --git a/apps/web/src/environments/primary/bootstrap.test.ts b/apps/web/src/environments/primary/bootstrap.test.ts index e200ecf2ff8..6ee612f54c1 100644 --- a/apps/web/src/environments/primary/bootstrap.test.ts +++ b/apps/web/src/environments/primary/bootstrap.test.ts @@ -8,6 +8,7 @@ import { isPrimaryEnvironmentProtocolUnsupportedError, isPrimaryEnvironmentUrlInvalidError, readPrimaryEnvironmentTarget, + readOptionalPrimaryEnvironmentTarget, resolvePrimaryEnvironmentHttpUrl, resolveInitialPrimaryEnvironmentDescriptor, resetPrimaryEnvironmentDescriptorForTests, @@ -257,4 +258,21 @@ describe("environmentBootstrap", () => { message: "The window-origin primary environment target uses unsupported protocol file:.", }); }); + + it("returns no primary target for a client-only desktop without using the custom origin", () => { + vi.stubGlobal("window", { + location: new URL("t3code://app/"), + history: { replaceState: vi.fn() }, + desktopBridge: { + getBackendModeState: () => ({ + effectiveMode: "client-only", + configuredMode: "client-only", + cliOverride: null, + }), + getLocalEnvironmentBootstraps: () => [], + }, + }); + + expect(readOptionalPrimaryEnvironmentTarget()).toBeNull(); + }); }); diff --git a/apps/web/src/environments/primary/index.ts b/apps/web/src/environments/primary/index.ts index f85c91c1536..ed01813c67d 100644 --- a/apps/web/src/environments/primary/index.ts +++ b/apps/web/src/environments/primary/index.ts @@ -48,6 +48,8 @@ export { PrimaryEnvironmentProtocolUnsupportedError, PrimaryEnvironmentUrlInvalidError, readPrimaryEnvironmentTarget, + readOptionalPrimaryEnvironmentTarget, + isDesktopClientOnlyMode, resolvePrimaryEnvironmentHttpUrl, isLoopbackHostname, type PrimaryEnvironmentTarget, diff --git a/apps/web/src/environments/primary/target.ts b/apps/web/src/environments/primary/target.ts index cc002419fe7..f3fed57afeb 100644 --- a/apps/web/src/environments/primary/target.ts +++ b/apps/web/src/environments/primary/target.ts @@ -73,6 +73,11 @@ export interface PrimaryEnvironmentTarget { }; } +export function isDesktopClientOnlyMode(): boolean { + const bridge = window.desktopBridge; + return bridge?.getBackendModeState?.().effectiveMode === "client-only"; +} + const LOOPBACK_HOSTNAMES = new Set(["127.0.0.1", "::1", "localhost"]); function getDesktopLocalEnvironmentBootstrap(): DesktopEnvironmentBootstrap | null { @@ -292,3 +297,7 @@ export function readPrimaryEnvironmentTarget(): PrimaryEnvironmentTarget { resolveWindowOriginPrimaryTarget() ); } + +export function readOptionalPrimaryEnvironmentTarget(): PrimaryEnvironmentTarget | null { + return isDesktopClientOnlyMode() ? null : readPrimaryEnvironmentTarget(); +} diff --git a/apps/web/src/routes/__root.tsx b/apps/web/src/routes/__root.tsx index 346991d114d..ed943e7e59c 100644 --- a/apps/web/src/routes/__root.tsx +++ b/apps/web/src/routes/__root.tsx @@ -36,7 +36,10 @@ import { import { useUiStateStore } from "../uiStateStore"; import { syncBrowserChromeTheme } from "../hooks/useTheme"; import { configureClientTracing } from "../observability/clientTracing"; -import { resolveInitialServerAuthGateState } from "../environments/primary"; +import { + isDesktopClientOnlyMode, + resolveInitialServerAuthGateState, +} from "../environments/primary"; import { hasHostedPairingRequest, isHostedStaticApp } from "../hostedPairing"; import { shellEnvironment } from "../state/shell"; import { useAtomValue } from "@effect/atom-react"; @@ -63,7 +66,7 @@ export const Route = createRootRoute({ }; } - if (isHostedStaticApp(new URL(window.location.href))) { + if (isHostedStaticApp(new URL(window.location.href)) || isDesktopClientOnlyMode()) { return { authGateState: { status: "hosted-static", diff --git a/apps/web/src/routes/_chat.tsx b/apps/web/src/routes/_chat.tsx index d3cf003d99c..d38085bdbd8 100644 --- a/apps/web/src/routes/_chat.tsx +++ b/apps/web/src/routes/_chat.tsx @@ -6,7 +6,7 @@ import { isCommandPaletteOpen } from "../commandPaletteBus"; import { useClientSettings } from "../hooks/useSettings"; import { openCommandPalette } from "../commandPaletteBus"; import { useProjects } from "../state/entities"; -import { usePrimaryEnvironmentId } from "../state/environments"; +import { useAppOwnsLocalEnvironment, usePrimaryEnvironmentId } from "../state/environments"; import { selectProjectGroupingSettings } from "../logicalProject"; import { buildSidebarProjectSnapshots } from "../sidebarProjectGrouping"; import { dispatchPreviewAction } from "../components/preview/previewActionBus"; @@ -32,15 +32,17 @@ function ChatRouteGlobalShortcuts() { const projectGroupingSettings = useClientSettings(selectProjectGroupingSettings); const projects = useProjects(); const primaryEnvironmentId = usePrimaryEnvironmentId(); + const ownsLocalEnvironment = useAppOwnsLocalEnvironment(); const projectGroupCount = useMemo( () => buildSidebarProjectSnapshots({ projects, settings: projectGroupingSettings, primaryEnvironmentId, + ownsLocalEnvironment, resolveEnvironmentLabel: () => null, }).length, - [primaryEnvironmentId, projectGroupingSettings, projects], + [ownsLocalEnvironment, primaryEnvironmentId, projectGroupingSettings, projects], ); const terminalOpen = useTerminalUiStateStore((state) => routeThreadRef diff --git a/apps/web/src/sidebarProjectGrouping.ts b/apps/web/src/sidebarProjectGrouping.ts index 32299b565f4..36859f37eea 100644 --- a/apps/web/src/sidebarProjectGrouping.ts +++ b/apps/web/src/sidebarProjectGrouping.ts @@ -1,5 +1,6 @@ import { scopeProjectRef } from "@t3tools/client-runtime/environment"; import type { EnvironmentId, ScopedProjectRef } from "@t3tools/contracts"; +import { isRemoteEnvironmentId, type EnvironmentPresenceScope } from "./environmentPresence"; import { deriveLogicalProjectKeyFromSettings, derivePhysicalProjectKey, @@ -116,6 +117,10 @@ export function buildSidebarProjectSnapshots(input: { projects: ReadonlyArray; settings: ProjectGroupingSettings; primaryEnvironmentId: EnvironmentId | null; + // False when the app has no backend of its own (a client-only desktop or the + // hosted static web app), which makes every member of a group remote. + // Defaults to true so callers that always own a local backend are unchanged. + ownsLocalEnvironment?: boolean; resolveEnvironmentLabel: (environmentId: EnvironmentId) => string | null; // Returns true when an env id maps to a desktopLocal saved-env // record (today: the WSL backend). Defaults to "false for every @@ -159,6 +164,10 @@ export function buildSidebarProjectSnapshots(input: { } } + const presenceScope: EnvironmentPresenceScope = { + primaryEnvironmentId: input.primaryEnvironmentId, + ownsLocalEnvironment: input.ownsLocalEnvironment ?? true, + }; const result: SidebarProjectSnapshot[] = []; const seen = new Set(); for (const project of input.projects) { @@ -177,17 +186,11 @@ export function buildSidebarProjectSnapshots(input: { continue; } - const hasLocal = - input.primaryEnvironmentId !== null && - members.some((member) => member.environmentId === input.primaryEnvironmentId); - const hasRemote = - input.primaryEnvironmentId !== null - ? members.some((member) => member.environmentId !== input.primaryEnvironmentId) - : false; - const remoteMembers = members.filter( - (member) => - input.primaryEnvironmentId !== null && member.environmentId !== input.primaryEnvironmentId, + const remoteMembers = members.filter((member) => + isRemoteEnvironmentId(member.environmentId, presenceScope), ); + const hasRemote = remoteMembers.length > 0; + const hasLocal = remoteMembers.length < members.length; const remoteEnvironmentLabels = remoteMembers .flatMap((member) => (member.environmentLabel ? [member.environmentLabel] : [])) .filter((label, index, labels) => labels.indexOf(label) === index); diff --git a/apps/web/src/state/environments.ts b/apps/web/src/state/environments.ts index 443e99b84cd..b761dfd37a8 100644 --- a/apps/web/src/state/environments.ts +++ b/apps/web/src/state/environments.ts @@ -9,6 +9,9 @@ import * as Option from "effect/Option"; import { useMemo } from "react"; import { environmentCatalog } from "../connection/catalog"; +import type { EnvironmentPresenceScope } from "../environmentPresence"; +import { isDesktopClientOnlyMode } from "../environments/primary"; +import { isHostedStaticApp } from "../hostedPairing"; import { environmentPresentations, useEnvironmentPresentation } from "./presentation"; import { primaryEnvironmentIdAtom } from "./primaryEnvironment"; import { useEnvironmentQuery } from "./query"; @@ -60,6 +63,30 @@ export function usePrimaryEnvironmentId(): EnvironmentId | null { return useAtomValue(primaryEnvironmentIdAtom); } +/** + * Whether this app can ever serve an environment from its own backend. A + * client-only desktop and the hosted static web app cannot, so they have no + * local environment for threads and projects to belong to. + */ +export function appOwnsLocalEnvironment(): boolean { + return !isHostedStaticApp() && !isDesktopClientOnlyMode(); +} + +export function useAppOwnsLocalEnvironment(): boolean { + // The answer is fixed for the app's lifetime: the hosted-static check is + // origin/build derived, and changing the desktop backend mode relaunches. + return useMemo(appOwnsLocalEnvironment, []); +} + +export function useEnvironmentPresenceScope(): EnvironmentPresenceScope { + const primaryEnvironmentId = usePrimaryEnvironmentId(); + const ownsLocalEnvironment = useAppOwnsLocalEnvironment(); + return useMemo( + () => ({ primaryEnvironmentId, ownsLocalEnvironment }), + [primaryEnvironmentId, ownsLocalEnvironment], + ); +} + export function useEnvironment( environmentId: EnvironmentId | null, ): EnvironmentPresentation | null { diff --git a/apps/web/test/environmentHttpTest.ts b/apps/web/test/environmentHttpTest.ts index ce43faacc40..6d7778dfadf 100644 --- a/apps/web/test/environmentHttpTest.ts +++ b/apps/web/test/environmentHttpTest.ts @@ -111,6 +111,7 @@ export async function installEnvironmentHttpTest(scenario: EnvironmentHttpTestSc ); }), ) + .handle("localPair", () => unexpectedEndpoint("auth.localPair")) .handle("pairingLinks", () => unexpectedEndpoint("auth.pairingLinks")) .handle("revokePairingLink", () => unexpectedEndpoint("auth.revokePairingLink")) .handle("clients", () => unexpectedEndpoint("auth.clients")) diff --git a/packages/contracts/src/environmentHttp.ts b/packages/contracts/src/environmentHttp.ts index 2d40dad60cc..ce928ea3284 100644 --- a/packages/contracts/src/environmentHttp.ts +++ b/packages/contracts/src/environmentHttp.ts @@ -26,6 +26,7 @@ import { } from "./auth.ts"; import { AuthSessionId, ThreadId, TrimmedNonEmptyString } from "./baseSchemas.ts"; import { ExecutionEnvironmentDescriptor } from "./environment.ts"; +import { LocalServerPairingChallenge, LocalServerPairingResult } from "./localServerDiscovery.ts"; import { ClientOrchestrationCommand, DispatchResult, @@ -392,6 +393,16 @@ export class EnvironmentAuthHttpApi extends HttpApiGroup.make("auth") error: EnvironmentSessionCreationErrors, }), ) + .add( + // Unauthenticated by design: this is how a same-user local client bootstraps + // its first credential. Authorization is filesystem proof (see the handler + // in apps/server/src/auth/localPairing.ts), not a bearer token. + HttpApiEndpoint.post("localPair", "/api/auth/local-pair", { + payload: LocalServerPairingChallenge, + success: LocalServerPairingResult, + error: EnvironmentSessionCreationErrors, + }), + ) .add( HttpApiEndpoint.post("token", "/oauth/token", { headers: OptionalDpopProofHeaders, diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index 5936cb0fc17..61764a8ae27 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -2,6 +2,7 @@ export * from "./baseSchemas.ts"; export * from "./auth.ts"; export * from "./environment.ts"; export * from "./environmentHttp.ts"; +export * from "./localServerDiscovery.ts"; export * from "./relayClient.ts"; export * from "./desktopBootstrap.ts"; export * from "./remoteAccess.ts"; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 4b1676d7926..9c6bcbbc888 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, LocalServerPairingResult } from "./localServerDiscovery.ts"; import type { FilesystemBrowseInput, FilesystemBrowseResult } from "./filesystem.ts"; import type { AssetCreateUrlInput, AssetCreateUrlResult } from "./assets.ts"; import type { @@ -163,6 +164,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 +180,19 @@ export const DesktopRuntimeArchSchema = Schema.Literals(["arm64", "x64", "other" export const DesktopThemeSchema = Schema.Literals(["light", "dark", "system"]); export const DesktopUpdateChannelSchema = Schema.Literals(["latest", "nightly"]); export const DesktopAppStageLabelSchema = Schema.Literals(["Alpha", "Dev", "Nightly"]); +export const DesktopBackendModeSchema = Schema.Literals(["managed", "client-only"]); + +export interface DesktopBackendModeState { + effectiveMode: DesktopBackendMode; + configuredMode: DesktopBackendMode; + cliOverride: DesktopBackendMode | null; +} + +export const DesktopBackendModeStateSchema = Schema.Struct({ + effectiveMode: DesktopBackendModeSchema, + configuredMode: DesktopBackendModeSchema, + cliOverride: Schema.NullOr(DesktopBackendModeSchema), +}); export interface DesktopAppBranding { baseName: string; @@ -974,11 +989,17 @@ 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. 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.test.ts b/packages/contracts/src/localServerDiscovery.test.ts new file mode 100644 index 00000000000..6b42e2cbae4 --- /dev/null +++ b/packages/contracts/src/localServerDiscovery.test.ts @@ -0,0 +1,16 @@ +import { expect, it } from "vite-plus/test"; +import * as Schema from "effect/Schema"; + +import { LocalServerPairingChallenge } from "./localServerDiscovery.ts"; + +const decodePairingChallenge = Schema.decodeUnknownSync(LocalServerPairingChallenge); + +it("preserves filesystem-significant whitespace in pairing challenge paths", () => { + const challenge = decodePairingChallenge({ + instanceId: "instance-local", + challengePath: " /tmp/t3code challenge ", + nonce: "nonce", + }); + + expect(challenge.challengePath).toBe(" /tmp/t3code challenge "); +}); diff --git a/packages/contracts/src/localServerDiscovery.ts b/packages/contracts/src/localServerDiscovery.ts new file mode 100644 index 00000000000..1973647a84c --- /dev/null +++ b/packages/contracts/src/localServerDiscovery.ts @@ -0,0 +1,49 @@ +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; + +/** + * 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, + instanceId: TrimmedNonEmptyString, + pid: PositiveInt, + startedAt: TrimmedNonEmptyString, + httpBaseUrl: 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, + // 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; + +export const LocalServerPairingResult = Schema.Struct({ + pairingUrl: TrimmedNonEmptyString, + pairingExpiresAt: TrimmedNonEmptyString, +}); +export type LocalServerPairingResult = typeof LocalServerPairingResult.Type; diff --git a/packages/shared/package.json b/packages/shared/package.json index 7d0cc5eb820..37394dc5d0e 100644 --- a/packages/shared/package.json +++ b/packages/shared/package.json @@ -194,6 +194,10 @@ "./httpReadiness": { "types": "./src/httpReadiness.ts", "import": "./src/httpReadiness.ts" + }, + "./localServerDiscovery": { + "types": "./src/localServerDiscovery.ts", + "import": "./src/localServerDiscovery.ts" } }, "scripts": { diff --git a/packages/shared/src/localServerDiscovery.test.ts b/packages/shared/src/localServerDiscovery.test.ts new file mode 100644 index 00000000000..2460ed52f28 --- /dev/null +++ b/packages/shared/src/localServerDiscovery.test.ts @@ -0,0 +1,67 @@ +import { expect, it } from "@effect/vitest"; + +import { + isCanonicalLoopbackHostname, + isValidLocalServerPairingUrl, + parseCanonicalLoopbackHttpBaseUrl, +} from "./localServerDiscovery.ts"; + +it("accepts canonical IPv4 and IPv6 loopback hosts", () => { + expect(isCanonicalLoopbackHostname("127.0.0.1")).toBe(true); + expect(isCanonicalLoopbackHostname("127.12.34.56")).toBe(true); + expect(isCanonicalLoopbackHostname("::1")).toBe(true); + expect(isCanonicalLoopbackHostname("localhost")).toBe(false); + expect(isCanonicalLoopbackHostname("127.00.0.1")).toBe(false); + expect(isCanonicalLoopbackHostname("192.168.1.2")).toBe(false); +}); + +it("accepts only normalized loopback HTTP base URLs", () => { + expect(parseCanonicalLoopbackHttpBaseUrl("http://127.0.0.1:3773/")?.origin).toBe( + "http://127.0.0.1:3773", + ); + expect(parseCanonicalLoopbackHttpBaseUrl("http://[::1]:3773/")?.hostname).toBe("[::1]"); + expect(parseCanonicalLoopbackHttpBaseUrl("http://localhost:3773/")).toBeNull(); + expect(parseCanonicalLoopbackHttpBaseUrl("https://127.0.0.1:3773/")).toBeNull(); + expect(parseCanonicalLoopbackHttpBaseUrl("http://127.0.0.1:3773/path")).toBeNull(); + expect(parseCanonicalLoopbackHttpBaseUrl("http://127.0.0.1/")).toBeNull(); +}); + +it("requires a same-origin pairing URL with a non-empty fragment token", () => { + const httpBaseUrl = new URL("http://127.0.0.1:3773/"); + expect( + isValidLocalServerPairingUrl({ + httpBaseUrl, + pairingUrl: "http://127.0.0.1:3773/pair#token=PAIRCODE", + }), + ).toBe(true); + expect( + isValidLocalServerPairingUrl({ + httpBaseUrl, + pairingUrl: "http://127.0.0.1:3774/pair#token=PAIRCODE", + }), + ).toBe(false); + expect( + isValidLocalServerPairingUrl({ + httpBaseUrl, + pairingUrl: "http://127.0.0.1:3773/pair", + }), + ).toBe(false); +}); + +it("rejects pairing URLs that could retarget pairing at another host", () => { + const httpBaseUrl = new URL("http://127.0.0.1:3773/"); + // `readHostedPairingRequest` honours a `host` query parameter, so a query + // string on a "loopback" link would send the credential somewhere else. + expect( + isValidLocalServerPairingUrl({ + httpBaseUrl, + pairingUrl: "http://127.0.0.1:3773/pair?host=https://evil.example#token=PAIRCODE", + }), + ).toBe(false); + expect( + isValidLocalServerPairingUrl({ + httpBaseUrl, + pairingUrl: "http://user:pw@127.0.0.1:3773/pair#token=PAIRCODE", + }), + ).toBe(false); +}); diff --git a/packages/shared/src/localServerDiscovery.ts b/packages/shared/src/localServerDiscovery.ts new file mode 100644 index 00000000000..b65bf3e8858 --- /dev/null +++ b/packages/shared/src/localServerDiscovery.ts @@ -0,0 +1,137 @@ +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 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; + } + const runtimeDirectory = input.xdgRuntimeDirectory?.trim(); + if (!runtimeDirectory || !input.path.isAbsolute(runtimeDirectory)) { + return null; + } + 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 { + // `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 + ); +}