diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7fee5f57a83c..f8be6af0ed56 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -45,6 +45,11 @@ jobs: - name: Ensure Electron runtime is installed run: vp run --filter @t3tools/desktop ensure:electron + # Schema and per-platform pin check only; no download. Release builds + # fetch the binaries this manifest describes. + - name: Verify Tailcat manifest + run: node scripts/fetch-tailcat.ts --verify --manifest-only + - name: Check run: vp check diff --git a/.github/workflows/desktop-macos-preview.yml b/.github/workflows/desktop-macos-preview.yml index 7875aec6f36b..fc44de7a3f35 100644 --- a/.github/workflows/desktop-macos-preview.yml +++ b/.github/workflows/desktop-macos-preview.yml @@ -67,6 +67,29 @@ jobs: with: targets: aarch64-apple-darwin + - name: Cache Tailcat runtime + id: tailcat_cache + uses: actions/cache@v6 + with: + path: native/tailcat/dist/darwin-arm64 + key: tailcat-darwin-arm64-${{ hashFiles('native/tailcat/manifest.json') }} + + # No upstream macOS archive: compile the pinned tag with the manifest's Go. + - name: Resolve Tailcat Go version + if: steps.tailcat_cache.outputs.cache-hit != 'true' + id: tailcat_go + run: echo "version=$(node -p "require('./native/tailcat/manifest.json').source.goVersion")" >> "$GITHUB_OUTPUT" + + - name: Setup Go + if: steps.tailcat_cache.outputs.cache-hit != 'true' + uses: actions/setup-go@v7 + with: + go-version: ${{ steps.tailcat_go.outputs.version }} + cache: false + + - name: Fetch Tailcat runtime + run: node scripts/fetch-tailcat.ts --platform darwin-arm64 --build-from-source + - id: version name: Set preview version and public configuration shell: bash diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 404e4e8075cc..e8563265d3e8 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -486,6 +486,34 @@ jobs: with: targets: ${{ matrix.rust_target }} + - name: Cache Tailcat runtime + id: tailcat_cache + uses: actions/cache@v6 + with: + path: native/tailcat/dist/${{ matrix.resource_key }} + key: tailcat-${{ matrix.resource_key }}-${{ hashFiles('native/tailcat/manifest.json') }} + + # Upstream publishes no macOS Tailcat archive, so the macOS jobs compile + # the pinned tag with the Go toolchain the manifest names. + - name: Resolve Tailcat Go version + if: matrix.platform == 'mac' && steps.tailcat_cache.outputs.cache-hit != 'true' + id: tailcat_go + shell: bash + run: echo "version=$(node -p "require('./native/tailcat/manifest.json').source.goVersion")" >> "$GITHUB_OUTPUT" + + - name: Setup Go + if: matrix.platform == 'mac' && steps.tailcat_cache.outputs.cache-hit != 'true' + uses: actions/setup-go@v7 + with: + go-version: ${{ steps.tailcat_go.outputs.version }} + cache: false + + # Verifies a cached runtime against the manifest and only downloads or + # builds when nothing valid is staged. + - name: Fetch Tailcat runtime + shell: bash + run: node scripts/fetch-tailcat.ts --platform "${{ matrix.resource_key }}"${{ matrix.platform == 'mac' && ' --build-from-source' || '' }} + - name: Download relay client tracing config uses: actions/download-artifact@v8 with: @@ -806,8 +834,35 @@ jobs: - name: Align package versions to release version run: node scripts/update-release-package-versions.ts "${{ needs.preflight.outputs.version }}" + - name: Cache Tailcat runtimes + id: tailcat_cache + uses: actions/cache@v6 + with: + path: native/tailcat/dist + key: tailcat-all-${{ hashFiles('native/tailcat/manifest.json') }} + + - name: Resolve Tailcat Go version + if: steps.tailcat_cache.outputs.cache-hit != 'true' + id: tailcat_go + run: echo "version=$(node -p "require('./native/tailcat/manifest.json').source.goVersion")" >> "$GITHUB_OUTPUT" + + # The npm package ships every platform's Tailcat runtime. Linux and + # Windows come from the pinned upstream archives; both darwin binaries are + # cross-compiled here (CGO_ENABLED=0, -trimpath), which yields the same + # bytes as the native build on the macOS runners. + - name: Setup Go + if: steps.tailcat_cache.outputs.cache-hit != 'true' + uses: actions/setup-go@v7 + with: + go-version: ${{ steps.tailcat_go.outputs.version }} + cache: false + + - name: Fetch Tailcat runtimes + run: node scripts/fetch-tailcat.ts --all + # The t3 build task depends on @t3tools/web#build, so the web client is - # built (once) as part of this step. + # built (once) as part of this step. The publish step below stages the + # fetched Tailcat runtimes into dist/tailcat//. - name: Build CLI package run: vp run --filter t3 build diff --git a/.gitignore b/.gitignore index 57262578a786..32bbc0ecf48c 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,8 @@ apps/mobile/.showcase/ artifacts/app-store/screenshots/ .github/pr-assets/ native/**/target/ +native/tailcat/dist/ +apps/desktop/prod-resources/tailcat/ node_modules/ .alchemy/ *.log diff --git a/apps/desktop/package.json b/apps/desktop/package.json index cb587e152aaa..058f913a7a15 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -20,6 +20,7 @@ "@t3tools/contracts": "workspace:*", "@t3tools/shared": "workspace:*", "@t3tools/ssh": "workspace:*", + "@t3tools/tailcat": "workspace:*", "@t3tools/tailscale": "workspace:*", "effect": "catalog:", "electron": "43.4.1", diff --git a/apps/desktop/src/backend/DesktopBackendConfiguration.ts b/apps/desktop/src/backend/DesktopBackendConfiguration.ts index 4c43070b5f97..44c3b7a583bd 100644 --- a/apps/desktop/src/backend/DesktopBackendConfiguration.ts +++ b/apps/desktop/src/backend/DesktopBackendConfiguration.ts @@ -16,6 +16,7 @@ import serverPackageJson from "../../../server/package.json" with { type: "json" import * as DesktopBackendManager from "./DesktopBackendManager.ts"; import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import { resolveDesktopTailcatBinaryPath } from "../tailcat/DesktopTailcatRuntime.ts"; import * as DesktopServerExposure from "./DesktopServerExposure.ts"; import * as DesktopAppSettings from "../settings/DesktopAppSettings.ts"; import * as DesktopWslEnvironment from "../wsl/DesktopWslEnvironment.ts"; @@ -471,6 +472,7 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv function* ( input: SharedBootstrapInput & { readonly resourceMonitorPath: Option.Option; + readonly tailcatBinaryPath: Option.Option; }, ): Effect.fn.Return< DesktopBackendManager.DesktopBackendStartConfig, @@ -496,6 +498,10 @@ const resolvePrimaryStartConfig = Effect.fn("desktop.backendConfiguration.resolv onNone: () => ({}), onSome: (resourceMonitorPath) => ({ resourceMonitorPath }), }), + ...Option.match(input.tailcatBinaryPath, { + onNone: () => ({}), + onSome: (tailcatBinaryPath) => ({ tailcatBinaryPath }), + }), ...buildObservabilityFragment(input.observabilitySettings), }; @@ -809,7 +815,17 @@ export const make = Effect.gen(function* () { Effect.provideService(FileSystem.FileSystem, fileSystem), Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), ); - return yield* resolvePrimaryStartConfig({ ...shared, resourceMonitorPath }).pipe( + // The bundled Tailcat binary is shared with the backend so the server's + // remote access and the desktop's forwards run the same pinned build. + const tailcatBinaryPath = yield* resolveDesktopTailcatBinaryPath().pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), + ); + return yield* resolvePrimaryStartConfig({ + ...shared, + resourceMonitorPath, + tailcatBinaryPath, + }).pipe( Effect.provideService(DesktopEnvironment.DesktopEnvironment, environment), Effect.provideService(DesktopServerExposure.DesktopServerExposure, serverExposure), ); diff --git a/apps/desktop/src/ipc/DesktopIpcHandlers.ts b/apps/desktop/src/ipc/DesktopIpcHandlers.ts index 2cdffbefb7ad..5d3b272bbab1 100644 --- a/apps/desktop/src/ipc/DesktopIpcHandlers.ts +++ b/apps/desktop/src/ipc/DesktopIpcHandlers.ts @@ -46,6 +46,7 @@ import { showContextMenu, } from "./methods/window.ts"; import * as PreviewIpc from "./methods/preview.ts"; +import * as TailcatIpc from "./methods/tailcatEnvironment.ts"; import * as AppActivationIpc from "./methods/appActivation.ts"; import { getWslState, setWslBackendEnabled, setWslDistro, setWslOnly } from "./methods/wsl.ts"; @@ -78,6 +79,10 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers" yield* ipc.handle(issueSshWebSocketTicket); yield* ipc.handle(resolveSshPasswordPrompt); + for (const tailcatMethod of TailcatIpc.methods) { + yield* ipc.handle(tailcatMethod); + } + yield* ipc.handle(getServerExposureState); yield* ipc.handle(setServerExposureMode); yield* ipc.handle(setTailscaleServeEnabled); diff --git a/apps/desktop/src/ipc/channels.ts b/apps/desktop/src/ipc/channels.ts index 81b50d165d24..fbac3f9a763d 100644 --- a/apps/desktop/src/ipc/channels.ts +++ b/apps/desktop/src/ipc/channels.ts @@ -47,6 +47,12 @@ export const SET_WSL_BACKEND_ENABLED_CHANNEL = "desktop:set-wsl-backend-enabled" export const SET_WSL_DISTRO_CHANNEL = "desktop:set-wsl-distro"; export const SET_WSL_ONLY_CHANNEL = "desktop:set-wsl-only"; export const SSH_PASSWORD_PROMPT_CANCELLED_RESULT = "ssh-password-prompt-cancelled"; +export const ENSURE_TAILCAT_ENVIRONMENT_CHANNEL = "desktop:ensure-tailcat-environment"; +export const RESTART_TAILCAT_ENVIRONMENT_CHANNEL = "desktop:restart-tailcat-environment"; +export const DISCONNECT_TAILCAT_ENVIRONMENT_CHANNEL = "desktop:disconnect-tailcat-environment"; +export const GET_TAILCAT_CONNECTION_DIAGNOSTICS_CHANNEL = + "desktop:get-tailcat-connection-diagnostics"; +export const PROBE_TAILCAT_CONNECTION_PATH_CHANNEL = "desktop:probe-tailcat-connection-path"; export const PREVIEW_CREATE_TAB_CHANNEL = "desktop:preview-create-tab"; export const PREVIEW_CLOSE_TAB_CHANNEL = "desktop:preview-close-tab"; export const PREVIEW_REGISTER_WEBVIEW_CHANNEL = "desktop:preview-register-webview"; diff --git a/apps/desktop/src/ipc/methods/tailcatEnvironment.ts b/apps/desktop/src/ipc/methods/tailcatEnvironment.ts new file mode 100644 index 000000000000..71e40e777949 --- /dev/null +++ b/apps/desktop/src/ipc/methods/tailcatEnvironment.ts @@ -0,0 +1,81 @@ +import { + DesktopTailcatConnectionIdInputSchema, + DesktopTailcatEnvironmentBootstrapSchema, + DesktopTailcatEnvironmentEnsureInputSchema, + TailcatConnectionDiagnostics, +} from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Option from "effect/Option"; +import * as Schema from "effect/Schema"; + +import * as IpcChannels from "../channels.ts"; +import * as DesktopIpc from "../DesktopIpc.ts"; +import * as DesktopTailcatEnvironment from "../../tailcat/DesktopTailcatEnvironment.ts"; + +/** + * Renderer-facing Tailcat transport methods. The renderer never touches the + * private key or the child process; it receives a loopback endpoint and + * diagnostics, and asks for lifecycle changes by connection id. + */ + +export const ensureTailcatEnvironment = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.ENSURE_TAILCAT_ENVIRONMENT_CHANNEL, + payload: DesktopTailcatEnvironmentEnsureInputSchema, + result: DesktopTailcatEnvironmentBootstrapSchema, + handler: Effect.fn("desktop.ipc.tailcatEnvironment.ensureEnvironment")(function* (input) { + const tailcat = yield* DesktopTailcatEnvironment.DesktopTailcatEnvironment; + return yield* tailcat.ensureEnvironment(input); + }), +}); + +export const restartTailcatEnvironment = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.RESTART_TAILCAT_ENVIRONMENT_CHANNEL, + payload: DesktopTailcatConnectionIdInputSchema, + result: DesktopTailcatEnvironmentBootstrapSchema, + handler: Effect.fn("desktop.ipc.tailcatEnvironment.restartEnvironment")(function* ({ + connectionId, + }) { + const tailcat = yield* DesktopTailcatEnvironment.DesktopTailcatEnvironment; + return yield* tailcat.restartEnvironment(connectionId); + }), +}); + +export const disconnectTailcatEnvironment = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.DISCONNECT_TAILCAT_ENVIRONMENT_CHANNEL, + payload: DesktopTailcatConnectionIdInputSchema, + result: Schema.Void, + handler: Effect.fn("desktop.ipc.tailcatEnvironment.disconnectEnvironment")(function* ({ + connectionId, + }) { + const tailcat = yield* DesktopTailcatEnvironment.DesktopTailcatEnvironment; + yield* tailcat.disconnectEnvironment(connectionId); + }), +}); + +export const getTailcatConnectionDiagnostics = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.GET_TAILCAT_CONNECTION_DIAGNOSTICS_CHANNEL, + payload: DesktopTailcatConnectionIdInputSchema, + result: Schema.NullOr(TailcatConnectionDiagnostics), + handler: Effect.fn("desktop.ipc.tailcatEnvironment.diagnostics")(function* ({ connectionId }) { + const tailcat = yield* DesktopTailcatEnvironment.DesktopTailcatEnvironment; + return Option.getOrNull(yield* tailcat.diagnostics(connectionId)); + }), +}); + +export const probeTailcatConnectionPath = DesktopIpc.makeIpcMethod({ + channel: IpcChannels.PROBE_TAILCAT_CONNECTION_PATH_CHANNEL, + payload: DesktopTailcatConnectionIdInputSchema, + result: Schema.NullOr(TailcatConnectionDiagnostics), + handler: Effect.fn("desktop.ipc.tailcatEnvironment.probePath")(function* ({ connectionId }) { + const tailcat = yield* DesktopTailcatEnvironment.DesktopTailcatEnvironment; + return Option.getOrNull(yield* tailcat.probePath(connectionId)); + }), +}); + +export const methods = [ + ensureTailcatEnvironment, + restartTailcatEnvironment, + disconnectTailcatEnvironment, + getTailcatConnectionDiagnostics, + probeTailcatConnectionPath, +] as const; diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts index 3337228aa962..0cdc6d894e66 100644 --- a/apps/desktop/src/main.ts +++ b/apps/desktop/src/main.ts @@ -54,6 +54,9 @@ import * as DesktopAppSettings from "./settings/DesktopAppSettings.ts"; import * as DesktopPreReadyPlatform from "./app/DesktopPreReadyPlatform.ts"; import * as DesktopShellEnvironment from "./shell/DesktopShellEnvironment.ts"; import * as DesktopSshEnvironment from "./ssh/DesktopSshEnvironment.ts"; +import * as DesktopTailcatEnvironment from "./tailcat/DesktopTailcatEnvironment.ts"; +import * as DesktopTailcatIdentity from "./tailcat/DesktopTailcatIdentity.ts"; +import * as DesktopTailcatRuntime from "./tailcat/DesktopTailcatRuntime.ts"; import * as DesktopSshPasswordPrompts from "./ssh/DesktopSshPasswordPrompts.ts"; import * as DesktopState from "./app/DesktopState.ts"; import * as DesktopTelemetryPublisher from "./telemetry/DesktopTelemetryPublisher.ts"; @@ -145,6 +148,14 @@ const desktopSshLayer = desktopSshEnvironmentLayer.pipe( Layer.provideMerge(DesktopSshPasswordPrompts.layer()), ); +// Tailcat forwards for saved Tailcat environments, plus this device's client +// identity (encrypted with safeStorage). Rides on the foundation for paths. +const desktopTailcatLayer = DesktopTailcatEnvironment.layer.pipe( + Layer.provideMerge(DesktopTailcatIdentity.layer), + Layer.provideMerge(DesktopTailcatRuntime.layer), + Layer.provide(desktopFoundationLayer), +); + const desktopServerExposureLayer = DesktopServerExposure.layer.pipe( Layer.provideMerge(DesktopNetworkInterfaces.layer), Layer.provideMerge(desktopFoundationLayer), @@ -199,6 +210,7 @@ const desktopApplicationLayer = Layer.mergeAll( DesktopLinuxUrlHandler.layer, DesktopShellEnvironment.layer, desktopSshLayer, + desktopTailcatLayer, ).pipe( Layer.provideMerge(DesktopUpdates.layer), Layer.provideMerge(desktopWslBackendLayer), diff --git a/apps/desktop/src/preload.ts b/apps/desktop/src/preload.ts index 685a9b1204db..ddde9716f145 100644 --- a/apps/desktop/src/preload.ts +++ b/apps/desktop/src/preload.ts @@ -94,6 +94,16 @@ contextBridge.exposeInMainWorld("desktopBridge", { }, resolveSshPasswordPrompt: (requestId, password) => ipcRenderer.invoke(IpcChannels.RESOLVE_SSH_PASSWORD_PROMPT_CHANNEL, { requestId, password }), + ensureTailcatEnvironment: (input) => + ipcRenderer.invoke(IpcChannels.ENSURE_TAILCAT_ENVIRONMENT_CHANNEL, input), + restartTailcatEnvironment: (connectionId) => + ipcRenderer.invoke(IpcChannels.RESTART_TAILCAT_ENVIRONMENT_CHANNEL, { connectionId }), + disconnectTailcatEnvironment: (connectionId) => + ipcRenderer.invoke(IpcChannels.DISCONNECT_TAILCAT_ENVIRONMENT_CHANNEL, { connectionId }), + getTailcatConnectionDiagnostics: (connectionId) => + ipcRenderer.invoke(IpcChannels.GET_TAILCAT_CONNECTION_DIAGNOSTICS_CHANNEL, { connectionId }), + probeTailcatConnectionPath: (connectionId) => + ipcRenderer.invoke(IpcChannels.PROBE_TAILCAT_CONNECTION_PATH_CHANNEL, { connectionId }), getServerExposureState: () => ipcRenderer.invoke(IpcChannels.GET_SERVER_EXPOSURE_STATE_CHANNEL), setServerExposureMode: (mode) => ipcRenderer.invoke(IpcChannels.SET_SERVER_EXPOSURE_MODE_CHANNEL, mode), diff --git a/apps/desktop/src/tailcat/DesktopTailcatEnvironment.test.ts b/apps/desktop/src/tailcat/DesktopTailcatEnvironment.test.ts new file mode 100644 index 000000000000..70d782439731 --- /dev/null +++ b/apps/desktop/src/tailcat/DesktopTailcatEnvironment.test.ts @@ -0,0 +1,505 @@ +import { assert, describe, it } from "@effect/vitest"; +import type { + DesktopTailcatEnvironmentEnsureInput, + TailcatPathProbe, + TailcatRuntimeInfo, +} from "@t3tools/contracts"; +import * as NetService from "@t3tools/shared/Net"; +import { tailcatBackoffDelayMs } from "@t3tools/tailcat/backoff"; +import * as TailcatRuntime from "@t3tools/tailcat/runtime"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; +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 TestClock from "effect/testing/TestClock"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; +import * as HttpClientResponse from "effect/unstable/http/HttpClientResponse"; + +import * as DesktopTailcatEnvironment from "./DesktopTailcatEnvironment.ts"; +import * as DesktopTailcatIdentity from "./DesktopTailcatIdentity.ts"; + +const CONNECTION_ID = "connection-1"; +// Captured from a real `tailcat serve` run; the fake runtime never decodes it. +const ADDRESS = + "tco2FwWCB-p3FjjOrzlCPp0w8aT3p9xDZ1nNaXWX_dASxDCFT_MmFrWCDRnh2-iykbZ7W4Fl0g3nBpwTnR3iXVCKKCk4pps47ndGFpGQEu"; +const OTHER_ADDRESS = "tcAnotherServer_0123456789abcdefABCDEF"; +const REMOTE_PORT = 3773; +const FIRST_PORT = 41000; +const NODE_KEY = `nodekey:${"7f".repeat(32)}`; +const KEY_PATH = "/tmp/fake.key"; +// The TestClock starts at the epoch, so every recorded timestamp is fixed. +const EPOCH_ISO = "1970-01-01T00:00:00.000Z"; +const RECENT_OUTPUT = ["forward: tunnel established"]; +const FIRST_BACKOFF_MAX_MS = tailcatBackoffDelayMs(1, 1); +const SECOND_BACKOFF_MIN_MS = tailcatBackoffDelayMs(2, 0); +const SECOND_BACKOFF_MAX_MS = tailcatBackoffDelayMs(2, 1); + +const ENSURE_INPUT = { + connectionId: CONNECTION_ID, + address: ADDRESS, + remotePort: REMOTE_PORT, +} satisfies DesktopTailcatEnvironmentEnsureInput; + +const RUNTIME_INFO: TailcatRuntimeInfo = { + executablePath: "/opt/t3/resources/tailcat/linux-x64/tailcat", + source: "bundled", + version: "0.3.0", + pinnedVersion: "0.3.0", + compatible: true, +}; + +const PATH_PROBE: TailcatPathProbe = { + kind: "direct", + via: "203.0.113.5:41641", + latencyMs: 12.5, + measuredAt: EPOCH_ISO, +}; + +const DESCRIPTOR = { + environmentId: "env-remote", + label: "Remote Devbox", + platform: { os: "linux", arch: "x64" }, + serverVersion: "1.2.3", + capabilities: {}, +}; + +const httpBaseUrlFor = (localPort: number) => `http://127.0.0.1:${localPort}/`; +const probeUrlFor = (localPort: number) => `${httpBaseUrlFor(localPort)}.well-known/t3/environment`; + +function jsonResponse(request: HttpClientRequest.HttpClientRequest, body: unknown, status = 200) { + return HttpClientResponse.fromWeb( + request, + new Response(JSON.stringify(body), { + status, + headers: { "content-type": "application/json" }, + }), + ); +} + +interface FakeForward { + readonly pid: number; + readonly input: { + readonly keyPath: string | null; + readonly address: string; + readonly remotePort: number; + readonly localPort: number; + }; + /** Settle to simulate the forwarder process exiting on its own. */ + readonly exit: Deferred.Deferred>; + readonly state: { + running: boolean; + /** Set when the owning scope closes, which is how the runtime stops a forwarder. */ + stopped: boolean; + }; + readonly handle: TailcatRuntime.TailcatForwardHandle; +} + +interface Harness { + readonly layer: Layer.Layer; + /** Every `forward` call in order, whether or not it became ready. */ + readonly forwards: ReadonlyArray; + readonly probeRequests: ReadonlyArray; + readonly pings: ReadonlyArray<{ readonly keyPath: string | null; readonly address: string }>; + /** Whether the fake T3 server behind the tunnel answers the readiness probe. */ + readonly setRemoteHealthy: (healthy: boolean) => void; + /** Settles once the n-th (1-based) forward has been spawned. */ + readonly spawned: (count: number) => Effect.Effect; +} + +function makeHarness(options?: { + readonly resolve?: Effect.Effect; +}): Harness { + const forwards: Array = []; + const probeRequests: Array = []; + const pings: Array<{ readonly keyPath: string | null; readonly address: string }> = []; + const spawnSignals = new Map>(); + let remoteHealthy = true; + let nextPort = FIRST_PORT; + + const spawnSignal = (count: number) => { + const existing = spawnSignals.get(count); + if (existing !== undefined) { + return existing; + } + const created = Deferred.makeUnsafe(); + spawnSignals.set(count, created); + return created; + }; + + const runtimeLayer = Layer.mock(TailcatRuntime.TailcatRuntime)({ + resolve: options?.resolve ?? Effect.succeed(RUNTIME_INFO), + forward: (input) => + Effect.gen(function* () { + const exit = yield* Deferred.make>(); + const state = { running: true, stopped: false }; + const handle: TailcatRuntime.TailcatForwardHandle = { + pid: 5000 + forwards.length + 1, + address: input.address, + remotePort: input.remotePort, + localPort: input.localPort, + httpBaseUrl: httpBaseUrlFor(input.localPort), + wsBaseUrl: `ws://127.0.0.1:${input.localPort}/`, + exit: Deferred.await(exit), + isRunning: Effect.sync(() => state.running), + recentOutput: Effect.succeed(RECENT_OUTPUT), + stop: Effect.sync(() => { + state.running = false; + }), + }; + forwards.push({ + pid: handle.pid, + input: { + keyPath: input.keyPath, + address: input.address, + remotePort: input.remotePort, + localPort: input.localPort, + }, + exit, + state, + handle, + }); + yield* Deferred.done(spawnSignal(forwards.length), Exit.void); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + state.running = false; + state.stopped = true; + }), + ); + if (input.readiness !== undefined) { + // Like the runtime, a failed probe kills the forwarder before the error surfaces. + yield* input.readiness({ httpBaseUrl: handle.httpBaseUrl }).pipe( + Effect.onError(() => + Effect.sync(() => { + state.running = false; + }), + ), + ); + } + return handle; + }), + ping: (input) => + Effect.sync(() => { + pings.push({ keyPath: input.keyPath, address: input.address }); + return PATH_PROBE; + }), + }); + + const identityLayer = Layer.mock(DesktopTailcatIdentity.DesktopTailcatIdentity)({ + nodeKey: Effect.succeed(NODE_KEY), + encrypted: Effect.succeed(true), + withKeyFile: (use) => use(KEY_PATH), + }); + + const netLayer = Layer.mock(NetService.NetService)({ + reserveLoopbackPort: () => + Effect.sync(() => { + const port = nextPort; + nextPort += 1; + return port; + }), + }); + + const httpClientLayer = Layer.succeed( + HttpClient.HttpClient, + HttpClient.make((request) => + Effect.sync(() => { + probeRequests.push(request.url); + return remoteHealthy + ? jsonResponse(request, DESCRIPTOR) + : jsonResponse(request, { error: "server offline" }, 503); + }), + ), + ); + + return { + layer: DesktopTailcatEnvironment.layer.pipe( + Layer.provide(Layer.mergeAll(runtimeLayer, identityLayer, netLayer, httpClientLayer)), + ), + forwards, + probeRequests, + pings, + setRemoteHealthy: (healthy) => { + remoteHealthy = healthy; + }, + spawned: (count) => Deferred.await(spawnSignal(count)), + }; +} + +describe("DesktopTailcatEnvironment", () => { + it.effect("ensureEnvironment forwards a reserved loopback port and reports the bootstrap", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const environment = yield* DesktopTailcatEnvironment.DesktopTailcatEnvironment; + + const bootstrap = yield* environment.ensureEnvironment(ENSURE_INPUT); + + assert.deepEqual(bootstrap, { + connectionId: CONNECTION_ID, + address: ADDRESS, + remotePort: REMOTE_PORT, + localPort: FIRST_PORT, + httpBaseUrl: httpBaseUrlFor(FIRST_PORT), + wsBaseUrl: `ws://127.0.0.1:${FIRST_PORT}/`, + clientNodeKey: NODE_KEY, + }); + assert.equal(harness.forwards.length, 1); + assert.deepEqual(harness.forwards[0]?.input, { + keyPath: KEY_PATH, + address: ADDRESS, + remotePort: REMOTE_PORT, + localPort: FIRST_PORT, + }); + assert.deepEqual(harness.probeRequests, [probeUrlFor(FIRST_PORT)]); + + const diagnostics = yield* environment.diagnostics(CONNECTION_ID); + assert(Option.isSome(diagnostics)); + assert.deepEqual(diagnostics.value, { + connectionId: CONNECTION_ID, + address: ADDRESS, + remotePort: REMOTE_PORT, + status: "ready", + localEndpoint: httpBaseUrlFor(FIRST_PORT), + pid: 5001, + runtime: RUNTIME_INFO, + clientNodeKey: NODE_KEY, + path: null, + startedAt: EPOCH_ISO, + restartCount: 0, + lastError: null, + recentOutput: RECENT_OUTPUT, + }); + }).pipe(Effect.provide(harness.layer)); + }); + + it.effect("reuses a healthy forward instead of spawning again", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const environment = yield* DesktopTailcatEnvironment.DesktopTailcatEnvironment; + + const first = yield* environment.ensureEnvironment(ENSURE_INPUT); + const second = yield* environment.ensureEnvironment(ENSURE_INPUT); + + assert.equal(harness.forwards.length, 1); + assert.isFalse(harness.forwards[0]?.state.stopped); + assert.equal(second.localPort, first.localPort); + assert.deepEqual(second, first); + // The second call only re-probes the tunnel that is already up. + assert.deepEqual(harness.probeRequests, [probeUrlFor(FIRST_PORT), probeUrlFor(FIRST_PORT)]); + }).pipe(Effect.provide(harness.layer)); + }); + + it.effect("moves a connection to a new address by replacing its forward", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const environment = yield* DesktopTailcatEnvironment.DesktopTailcatEnvironment; + + const first = yield* environment.ensureEnvironment(ENSURE_INPUT); + const moved = yield* environment.ensureEnvironment({ + ...ENSURE_INPUT, + address: OTHER_ADDRESS, + }); + + assert.equal(harness.forwards.length, 2); + assert.isTrue(harness.forwards[0]?.state.stopped); + assert.isFalse(harness.forwards[1]?.state.stopped); + assert.deepEqual(harness.forwards[1]?.input, { + keyPath: KEY_PATH, + address: OTHER_ADDRESS, + remotePort: REMOTE_PORT, + localPort: FIRST_PORT + 1, + }); + assert.equal(moved.address, OTHER_ADDRESS); + assert.equal(moved.localPort, FIRST_PORT + 1); + assert.notEqual(moved.localPort, first.localPort); + assert.equal(moved.httpBaseUrl, httpBaseUrlFor(FIRST_PORT + 1)); + + const diagnostics = yield* environment.diagnostics(CONNECTION_ID); + assert(Option.isSome(diagnostics)); + assert.equal(diagnostics.value.address, OTHER_ADDRESS); + assert.equal(diagnostics.value.status, "ready"); + assert.equal(diagnostics.value.pid, 5002); + assert.equal(diagnostics.value.restartCount, 0); + }).pipe(Effect.provide(harness.layer)); + }); + + it.effect("fails ensureEnvironment when the remote never answers through the tunnel", () => { + const harness = makeHarness(); + harness.setRemoteHealthy(false); + return Effect.gen(function* () { + const environment = yield* DesktopTailcatEnvironment.DesktopTailcatEnvironment; + + const error = yield* environment.ensureEnvironment(ENSURE_INPUT).pipe(Effect.flip); + + assert.instanceOf(error, DesktopTailcatEnvironment.DesktopTailcatEnvironmentError); + assert.equal(error.code, "remote-unavailable"); + assert.isTrue(error.message.startsWith("[tailcat:remote-unavailable] ")); + assert.include(error.message, "not trusted"); + assert.include(error.message, "offline"); + // The failed attempt's forwarder went down with its scope. + assert.equal(harness.forwards.length, 1); + assert.isTrue(harness.forwards[0]?.state.stopped); + + const diagnostics = yield* environment.diagnostics(CONNECTION_ID); + assert(Option.isSome(diagnostics)); + assert.equal(diagnostics.value.status, "failed"); + assert.equal(diagnostics.value.pid, null); + assert.equal(diagnostics.value.localEndpoint, null); + assert.equal(diagnostics.value.startedAt, null); + assert.deepEqual(diagnostics.value.lastError, { + code: "remote-unavailable", + message: error.detail, + at: EPOCH_ISO, + }); + }).pipe(Effect.provide(harness.layer)); + }); + + it.effect("restarts a forward that exits on its own", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const environment = yield* DesktopTailcatEnvironment.DesktopTailcatEnvironment; + const first = yield* environment.ensureEnvironment(ENSURE_INPUT); + const [initial] = harness.forwards; + assert(initial !== undefined); + + yield* Deferred.succeed(initial.exit, Option.some(1)); + // Let the exit monitor run, then cover the longest first backoff step. + yield* Effect.yieldNow; + yield* TestClock.adjust(Duration.millis(FIRST_BACKOFF_MAX_MS)); + yield* harness.spawned(2); + // The next ensure waits behind the connection lock until the restart has settled. + const after = yield* environment.ensureEnvironment(ENSURE_INPUT); + + assert.equal(harness.forwards.length, 2); + assert.equal(after.localPort, first.localPort); + assert.deepEqual(harness.forwards[1]?.input, initial.input); + const diagnostics = yield* environment.diagnostics(CONNECTION_ID); + assert(Option.isSome(diagnostics)); + assert.equal(diagnostics.value.status, "ready"); + assert.equal(diagnostics.value.restartCount, 1); + assert.equal(diagnostics.value.pid, 5002); + assert.equal(diagnostics.value.lastError, null); + }).pipe(Effect.provide(harness.layer)); + }); + + it.effect("backs off before restarting a forward that already failed once", () => { + const harness = makeHarness(); + harness.setRemoteHealthy(false); + return Effect.gen(function* () { + const environment = yield* DesktopTailcatEnvironment.DesktopTailcatEnvironment; + // The first attempt fails and counts as the connection's first consecutive failure. + yield* environment.ensureEnvironment(ENSURE_INPUT).pipe(Effect.flip); + harness.setRemoteHealthy(true); + const bootstrap = yield* environment.ensureEnvironment(ENSURE_INPUT); + assert.equal(bootstrap.localPort, FIRST_PORT); + assert.equal(harness.forwards.length, 2); + const running = harness.forwards[1]; + assert(running !== undefined); + + yield* Deferred.succeed(running.exit, Option.some(137)); + // Let the exit monitor record the failure and schedule the restart. + yield* Effect.yieldNow; + + const failed = yield* environment.diagnostics(CONNECTION_ID); + assert(Option.isSome(failed)); + assert.equal(failed.value.status, "failed"); + assert.equal(failed.value.pid, null); + assert.equal(failed.value.restartCount, 0); + assert.deepEqual(failed.value.lastError, { + code: "process-exited", + message: "The Tailcat forwarder exited with code 137.", + at: EPOCH_ISO, + }); + + // This is the second consecutive failure, so nothing restarts before the + // shortest jittered second step has passed. + yield* TestClock.adjust(Duration.millis(SECOND_BACKOFF_MIN_MS - 1)); + assert.equal(harness.forwards.length, 2); + // The longest jittered second step is enough for any random sample. + yield* TestClock.adjust(Duration.millis(SECOND_BACKOFF_MAX_MS - SECOND_BACKOFF_MIN_MS + 1)); + assert.equal(harness.forwards.length, 3); + yield* harness.spawned(3); + // The next ensure waits behind the connection lock until the restart has settled. + const after = yield* environment.ensureEnvironment(ENSURE_INPUT); + + assert.equal(after.localPort, FIRST_PORT); + assert.equal(harness.forwards.length, 3); + const restarted = yield* environment.diagnostics(CONNECTION_ID); + assert(Option.isSome(restarted)); + assert.equal(restarted.value.status, "ready"); + assert.equal(restarted.value.restartCount, 1); + assert.equal(restarted.value.pid, 5003); + assert.equal(restarted.value.lastError, null); + }).pipe(Effect.provide(harness.layer)); + }); + + it.effect("disconnectEnvironment stops the forward and forgets the connection", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const environment = yield* DesktopTailcatEnvironment.DesktopTailcatEnvironment; + yield* environment.ensureEnvironment(ENSURE_INPUT); + const [initial] = harness.forwards; + assert(initial !== undefined); + + yield* environment.disconnectEnvironment(CONNECTION_ID); + + assert.isTrue(initial.state.stopped); + assert.isFalse(yield* initial.handle.isRunning); + assert.isTrue(Option.isNone(yield* environment.diagnostics(CONNECTION_ID))); + + // The stopped forwarder's exit arrives afterwards and must not restart anything. + yield* Deferred.succeed(initial.exit, Option.some(0)); + yield* Effect.yieldNow; + yield* TestClock.adjust(Duration.millis(FIRST_BACKOFF_MAX_MS)); + assert.equal(harness.forwards.length, 1); + assert.isTrue(Option.isNone(yield* environment.diagnostics(CONNECTION_ID))); + + // Disconnecting an unknown connection is a no-op. + yield* environment.disconnectEnvironment(CONNECTION_ID); + }).pipe(Effect.provide(harness.layer)); + }); + + it.effect("restartEnvironment replaces the forward and counts the restart", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const environment = yield* DesktopTailcatEnvironment.DesktopTailcatEnvironment; + const first = yield* environment.ensureEnvironment(ENSURE_INPUT); + + const restarted = yield* environment.restartEnvironment(CONNECTION_ID); + + assert.equal(harness.forwards.length, 2); + assert.isTrue(harness.forwards[0]?.state.stopped); + assert.isFalse(harness.forwards[1]?.state.stopped); + assert.deepEqual(restarted, first); + const diagnostics = yield* environment.diagnostics(CONNECTION_ID); + assert(Option.isSome(diagnostics)); + assert.equal(diagnostics.value.status, "ready"); + assert.equal(diagnostics.value.restartCount, 1); + assert.equal(diagnostics.value.pid, 5002); + + const missing = yield* environment.restartEnvironment("unknown-connection").pipe(Effect.flip); + assert.equal(missing.code, "unknown"); + }).pipe(Effect.provide(harness.layer)); + }); + + it.effect("probePath records the measured path in diagnostics", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const environment = yield* DesktopTailcatEnvironment.DesktopTailcatEnvironment; + assert.isTrue(Option.isNone(yield* environment.probePath(CONNECTION_ID))); + assert.equal(harness.pings.length, 0); + + yield* environment.ensureEnvironment(ENSURE_INPUT); + const probed = yield* environment.probePath(CONNECTION_ID); + + assert(Option.isSome(probed)); + assert.deepEqual(probed.value.path, PATH_PROBE); + assert.deepEqual(harness.pings, [{ keyPath: KEY_PATH, address: ADDRESS }]); + const diagnostics = yield* environment.diagnostics(CONNECTION_ID); + assert(Option.isSome(diagnostics)); + assert.deepEqual(diagnostics.value.path, PATH_PROBE); + }).pipe(Effect.provide(harness.layer)); + }); +}); diff --git a/apps/desktop/src/tailcat/DesktopTailcatEnvironment.ts b/apps/desktop/src/tailcat/DesktopTailcatEnvironment.ts new file mode 100644 index 000000000000..4b0df06b0f02 --- /dev/null +++ b/apps/desktop/src/tailcat/DesktopTailcatEnvironment.ts @@ -0,0 +1,522 @@ +import type { + DesktopTailcatEnvironmentBootstrap, + DesktopTailcatEnvironmentEnsureInput, + TailcatAddress, + TailcatConnectionDiagnostics, + TailcatFailure, + TailcatForwardStatus, + TailcatPathProbe, + TailcatRuntimeInfo, +} from "@t3tools/contracts"; +import { TailcatFailureCode } from "@t3tools/contracts"; +import { fetchRemoteEnvironmentDescriptor } from "@t3tools/client-runtime/environment"; +import * as NetService from "@t3tools/shared/Net"; +import { tailcatBackoffDelayMs, TAILCAT_BACKOFF_RESET_AFTER_MS } from "@t3tools/tailcat/backoff"; +import { tailcatFailureCode, type TailcatRuntimeError } from "@t3tools/tailcat/errors"; +import * as TailcatRuntime from "@t3tools/tailcat/runtime"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Random from "effect/Random"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import * as HttpClient from "effect/unstable/http/HttpClient"; + +import * as DesktopTailcatIdentity from "./DesktopTailcatIdentity.ts"; + +/** + * Desktop-side Tailcat transport: one `tailcat forward` per saved Tailcat + * environment, bound to a reserved loopback port, supervised for the lifetime + * of the app. The renderer only ever sees `http://127.0.0.1:`; T3 auth, + * pairing, and RPC run over that unchanged. + * + * Failure policy: a forward that exits on its own is restarted with jittered + * exponential backoff; a forward that starts but never passes the readiness + * probe (typical for "not trusted yet" or "server offline") fails the + * `ensure` call so the connection supervisor in the client can decide, and the + * probe result is kept for diagnostics. + */ + +export const TAILCAT_FORWARD_READINESS_TIMEOUT = Duration.seconds(20); +export const TAILCAT_FORWARD_MAX_RESTARTS = 8; + +export class DesktopTailcatEnvironmentError extends Schema.TaggedErrorClass()( + "DesktopTailcatEnvironmentError", + { + code: TailcatFailureCode, + detail: Schema.String, + }, +) { + /** + * The message crosses the IPC boundary as plain text, so it carries a + * machine-readable prefix the renderer can map back to a failure code. + */ + override get message(): string { + return `[tailcat:${this.code}] ${this.detail}`; + } +} + +export class DesktopTailcatEnvironment extends Context.Service< + DesktopTailcatEnvironment, + { + readonly ensureEnvironment: ( + input: DesktopTailcatEnvironmentEnsureInput, + ) => Effect.Effect; + readonly restartEnvironment: ( + connectionId: string, + ) => Effect.Effect; + readonly disconnectEnvironment: (connectionId: string) => Effect.Effect; + readonly diagnostics: ( + connectionId: string, + ) => Effect.Effect>; + readonly probePath: ( + connectionId: string, + ) => Effect.Effect, DesktopTailcatEnvironmentError>; + } +>()("@t3tools/desktop/tailcat/DesktopTailcatEnvironment") {} + +interface RunningForward { + readonly scope: Scope.Closeable; + readonly handle: TailcatRuntime.TailcatForwardHandle; + readonly startedAt: string; + readonly monitor: Fiber.Fiber; +} + +interface ForwardEntry { + readonly address: TailcatAddress; + readonly remotePort: number; + readonly localPort: number; + readonly status: TailcatForwardStatus; + readonly running: RunningForward | null; + readonly restartCount: number; + readonly consecutiveFailures: number; + readonly lastError: TailcatFailure | null; + readonly path: TailcatPathProbe | null; + /** Bumped per spawned forward so a stale exit monitor never acts on a newer one. */ + readonly generation: number; +} + +const describe = (cause: unknown) => (cause instanceof Error ? cause.message : String(cause)); +const withoutKey = (map: ReadonlyMap, key: K): ReadonlyMap => { + const next = new Map(map); + next.delete(key); + return next; +}; +const isDesktopTailcatEnvironmentError = Schema.is(DesktopTailcatEnvironmentError); + +const failureCodeOf = ( + error: TailcatRuntimeError | DesktopTailcatIdentity.DesktopTailcatIdentityError, +): TailcatFailureCode => + error._tag === "DesktopTailcatIdentityError" ? "identity-failed" : tailcatFailureCode(error); + +export const make = Effect.gen(function* () { + const runtime = yield* TailcatRuntime.TailcatRuntime; + const identity = yield* DesktopTailcatIdentity.DesktopTailcatIdentity; + const net = yield* NetService.NetService; + const httpClient = yield* HttpClient.HttpClient; + const serviceScope = yield* Scope.Scope; + const entries = yield* Ref.make>(new Map()); + const locks = yield* Ref.make>(new Map()); + + const nowIso = DateTime.now.pipe(Effect.map(DateTime.formatIso)); + + // Created and published in one modify, so concurrent first callers share a lock. + const lockFor = (connectionId: string) => + Ref.modify(locks, (map) => { + const current = map.get(connectionId); + if (current !== undefined) { + return [current, map] as const; + } + const created = Semaphore.makeUnsafe(1); + return [created, new Map(map).set(connectionId, created)] as const; + }); + + const withLock = (connectionId: string, effect: Effect.Effect) => + lockFor(connectionId).pipe(Effect.flatMap((lock) => lock.withPermits(1)(effect))); + + const getEntry = (connectionId: string) => + Ref.get(entries).pipe(Effect.map((map) => Option.fromUndefinedOr(map.get(connectionId)))); + + const setEntry = (connectionId: string, entry: ForwardEntry) => + Ref.update(entries, (map) => new Map(map).set(connectionId, entry)); + + const patchEntry = (connectionId: string, patch: (entry: ForwardEntry) => ForwardEntry) => + Ref.update(entries, (map) => { + const current = map.get(connectionId); + return current === undefined ? map : new Map(map).set(connectionId, patch(current)); + }); + + const failure = (code: TailcatFailureCode, message: string): Effect.Effect => + nowIso.pipe(Effect.map((at) => ({ code, message, at }))); + + const runtimeInfo: Effect.Effect = runtime.resolve.pipe( + Effect.map((info): TailcatRuntimeInfo | null => info), + Effect.orElseSucceed(() => null), + ); + + const readiness = (endpoint: { readonly httpBaseUrl: string }) => + fetchRemoteEnvironmentDescriptor({ httpBaseUrl: endpoint.httpBaseUrl, timeoutMs: 4_000 }).pipe( + Effect.provideService(HttpClient.HttpClient, httpClient), + Effect.asVoid, + Effect.mapError( + (cause) => + new DesktopTailcatEnvironmentError({ + code: "remote-unavailable", + detail: `The T3 server did not answer through the tunnel: ${describe(cause)}`, + }), + ), + ); + + const stopRunning = (running: RunningForward) => + Fiber.interrupt(running.monitor).pipe( + Effect.andThen(Scope.close(running.scope, Exit.void).pipe(Effect.ignore)), + ); + + /** Starts (or restarts) the forward for an entry; the entry must be locked. */ + const startForward = ( + connectionId: string, + entry: ForwardEntry, + ): Effect.Effect => + Effect.gen(function* () { + const scope = yield* Scope.make("sequential"); + yield* setEntry(connectionId, { ...entry, status: "starting", running: null }); + const started = yield* identity + .withKeyFile((keyPath) => + runtime.forward({ + keyPath, + address: entry.address, + remotePort: entry.remotePort, + localPort: entry.localPort, + readiness, + readinessTimeout: TAILCAT_FORWARD_READINESS_TIMEOUT, + }), + ) + .pipe( + Scope.provide(scope), + Effect.mapError((error) => + isDesktopTailcatEnvironmentError(error) + ? new DesktopTailcatEnvironmentError({ + code: error.code, + detail: `${error.detail} The environment may be offline, or this device is not trusted yet: redeem a fresh connection code.`, + }) + : new DesktopTailcatEnvironmentError({ + code: failureCodeOf(error), + detail: error.message, + }), + ), + Effect.onError(() => Scope.close(scope, Exit.void).pipe(Effect.ignore)), + Effect.tapError((error) => + failure(error.code, error.detail).pipe( + Effect.flatMap((recorded) => + patchEntry(connectionId, (current) => ({ + ...current, + status: "failed", + running: null, + consecutiveFailures: current.consecutiveFailures + 1, + lastError: recorded, + })), + ), + ), + ), + ); + const startedAt = yield* nowIso; + const generation = entry.generation + 1; + // The monitor only starts watching once the ready entry is published, so + // an immediate exit cannot be recorded and then overwritten by "ready". + const published = yield* Deferred.make(); + const monitor = yield* Deferred.await(published).pipe( + Effect.andThen(started.exit), + Effect.flatMap((exitCode) => onForwardExit(connectionId, generation, exitCode)), + Effect.forkIn(serviceScope), + ); + const next: ForwardEntry = { + ...entry, + status: "ready", + running: { scope, handle: started, startedAt, monitor }, + lastError: null, + generation, + }; + yield* setEntry(connectionId, next); + yield* Deferred.succeed(published, undefined); + yield* Effect.logInfo("Tailcat forward ready.", { + localPort: entry.localPort, + remotePort: entry.remotePort, + pid: started.pid, + }); + return next; + }); + + /** + * Unexpected exit: record it and restart with backoff unless a newer forward replaced it. The + * generation guard makes a monitor from an older forward a no-op once the + * connection was re-ensured or restarted. + */ + const onForwardExit = ( + connectionId: string, + generation: number, + exitCode: Option.Option, + ) => + Effect.gen(function* () { + const current = yield* getEntry(connectionId); + if (Option.isNone(current) || current.value.generation !== generation) { + return; + } + const recorded = yield* failure( + "process-exited", + `The Tailcat forwarder exited${Option.isSome(exitCode) ? ` with code ${exitCode.value}` : ""}.`, + ); + // This exit is the connection's next consecutive failure (1 = first). + const failures = current.value.consecutiveFailures + 1; + yield* patchEntry(connectionId, (entry) => ({ + ...entry, + status: "failed", + running: null, + consecutiveFailures: failures, + lastError: recorded, + })); + yield* Effect.logWarning("Tailcat forward exited unexpectedly.", { + connectionId, + exitCode: Option.getOrNull(exitCode), + failures, + }); + if (failures > TAILCAT_FORWARD_MAX_RESTARTS) { + yield* Effect.logWarning("Tailcat forward gave up restarting; waiting for the client.", { + connectionId, + failures, + }); + return; + } + const random = yield* Random.next; + yield* Effect.sleep(Duration.millis(tailcatBackoffDelayMs(failures, random))); + yield* withLock( + connectionId, + Effect.gen(function* () { + const latest = yield* getEntry(connectionId); + if ( + Option.isNone(latest) || + latest.value.running !== null || + latest.value.generation !== generation + ) { + return; + } + yield* startForward(connectionId, { + ...latest.value, + restartCount: latest.value.restartCount + 1, + }).pipe(Effect.ignore); + }), + ); + }); + + const bootstrapOf = (connectionId: string, entry: ForwardEntry, running: RunningForward) => + identity.nodeKey.pipe( + Effect.mapError( + (error) => + new DesktopTailcatEnvironmentError({ + code: "identity-failed", + detail: error.message, + }), + ), + Effect.map((clientNodeKey): DesktopTailcatEnvironmentBootstrap => ({ + connectionId, + address: entry.address, + remotePort: entry.remotePort, + localPort: entry.localPort, + httpBaseUrl: running.handle.httpBaseUrl, + wsBaseUrl: running.handle.wsBaseUrl, + clientNodeKey, + })), + ); + + const ensureEnvironment: DesktopTailcatEnvironment["Service"]["ensureEnvironment"] = (input) => + withLock( + input.connectionId, + Effect.gen(function* () { + const existing = yield* getEntry(input.connectionId); + const running = Option.isSome(existing) ? existing.value.running : null; + if (Option.isSome(existing) && running !== null) { + const entry = existing.value; + const sameTarget = + entry.address === input.address && entry.remotePort === input.remotePort; + const alive = yield* running.handle.isRunning; + if (sameTarget && alive) { + // A healthy forward stays; a stale readiness only costs one probe. + const healthy = yield* readiness({ httpBaseUrl: running.handle.httpBaseUrl }).pipe( + Effect.as(true), + Effect.orElseSucceed(() => false), + ); + if (healthy) { + // Fresh use resets the failure budget for the supervisor. + yield* patchEntry(input.connectionId, (current) => ({ + ...current, + consecutiveFailures: 0, + })); + return yield* bootstrapOf(input.connectionId, entry, running); + } + } + yield* stopRunning(running); + } + const localPort = + Option.isSome(existing) && existing.value.address === input.address + ? existing.value.localPort + : yield* net.reserveLoopbackPort().pipe( + Effect.mapError( + (error) => + new DesktopTailcatEnvironmentError({ + code: "port-in-use", + detail: `Could not reserve a loopback port: ${error.message}`, + }), + ), + ); + const resetFailures = + Option.isSome(existing) && + existing.value.lastError !== null && + DateTime.toEpochMillis(DateTime.makeUnsafe(existing.value.lastError.at)) + + TAILCAT_BACKOFF_RESET_AFTER_MS < + (yield* DateTime.now.pipe(Effect.map(DateTime.toEpochMillis))); + const base: ForwardEntry = { + address: input.address, + remotePort: input.remotePort, + localPort, + status: "starting", + running: null, + restartCount: Option.isSome(existing) ? existing.value.restartCount : 0, + consecutiveFailures: + Option.isSome(existing) && !resetFailures ? existing.value.consecutiveFailures : 0, + lastError: Option.isSome(existing) ? existing.value.lastError : null, + path: Option.isSome(existing) ? existing.value.path : null, + generation: Option.isSome(existing) ? existing.value.generation : 0, + }; + const started = yield* startForward(input.connectionId, base); + return yield* bootstrapOf(input.connectionId, started, started.running!); + }), + ); + + const restartEnvironment: DesktopTailcatEnvironment["Service"]["restartEnvironment"] = ( + connectionId, + ) => + withLock( + connectionId, + Effect.gen(function* () { + const existing = yield* getEntry(connectionId); + if (Option.isNone(existing)) { + return yield* new DesktopTailcatEnvironmentError({ + code: "unknown", + detail: "This Tailcat environment has no active tunnel to restart.", + }); + } + if (existing.value.running !== null) { + yield* stopRunning(existing.value.running); + } + const started = yield* startForward(connectionId, { + ...existing.value, + restartCount: existing.value.restartCount + 1, + consecutiveFailures: 0, + }); + return yield* bootstrapOf(connectionId, started, started.running!); + }), + ); + + const disconnectEnvironment: DesktopTailcatEnvironment["Service"]["disconnectEnvironment"] = ( + connectionId, + ) => + withLock( + connectionId, + Effect.gen(function* () { + const existing = yield* getEntry(connectionId); + if (Option.isNone(existing)) { + return; + } + if (existing.value.running !== null) { + yield* stopRunning(existing.value.running); + } + yield* Ref.update(entries, (map) => withoutKey(map, connectionId)); + yield* Effect.logInfo("Tailcat forward stopped.", { connectionId }); + }), + ); + + const diagnosticsOf = (connectionId: string, entry: ForwardEntry) => + Effect.gen(function* () { + const recentOutput = entry.running === null ? [] : yield* entry.running.handle.recentOutput; + const clientNodeKey = yield* identity.nodeKey.pipe(Effect.option); + return { + connectionId, + address: entry.address, + remotePort: entry.remotePort, + status: entry.status, + localEndpoint: entry.running === null ? null : entry.running.handle.httpBaseUrl, + pid: entry.running === null ? null : entry.running.handle.pid, + runtime: yield* runtimeInfo, + clientNodeKey: Option.getOrNull(clientNodeKey), + path: entry.path, + startedAt: entry.running === null ? null : entry.running.startedAt, + restartCount: entry.restartCount, + lastError: entry.lastError, + recentOutput, + } satisfies TailcatConnectionDiagnostics; + }); + + const diagnostics: DesktopTailcatEnvironment["Service"]["diagnostics"] = (connectionId) => + getEntry(connectionId).pipe( + Effect.flatMap( + Option.match({ + onNone: () => Effect.succeed(Option.none()), + onSome: (entry) => diagnosticsOf(connectionId, entry).pipe(Effect.map(Option.some)), + }), + ), + ); + + const probePath: DesktopTailcatEnvironment["Service"]["probePath"] = (connectionId) => + Effect.gen(function* () { + const existing = yield* getEntry(connectionId); + if (Option.isNone(existing)) { + return Option.none(); + } + const probe = yield* identity + .withKeyFile((keyPath) => runtime.ping({ keyPath, address: existing.value.address })) + .pipe( + Effect.mapError( + (error) => + new DesktopTailcatEnvironmentError({ + code: failureCodeOf(error), + detail: error.message, + }), + ), + ); + yield* patchEntry(connectionId, (entry) => ({ ...entry, path: probe })); + return yield* diagnostics(connectionId); + }); + + // App shutdown takes every forwarder down with it. + yield* Effect.addFinalizer(() => + Ref.get(entries).pipe( + Effect.flatMap((map) => + Effect.forEach( + map.values(), + (entry) => + entry.running === null + ? Effect.void + : Scope.close(entry.running.scope, Exit.void).pipe(Effect.ignore), + { discard: true }, + ), + ), + ), + ); + + return DesktopTailcatEnvironment.of({ + ensureEnvironment, + restartEnvironment, + disconnectEnvironment, + diagnostics, + probePath, + }); +}); + +export const layer = Layer.effect(DesktopTailcatEnvironment, make); diff --git a/apps/desktop/src/tailcat/DesktopTailcatIdentity.test.ts b/apps/desktop/src/tailcat/DesktopTailcatIdentity.test.ts new file mode 100644 index 000000000000..c5d3f527fcc4 --- /dev/null +++ b/apps/desktop/src/tailcat/DesktopTailcatIdentity.test.ts @@ -0,0 +1,262 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +import * as TailcatRuntime from "@t3tools/tailcat/runtime"; +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 DesktopConfig from "../app/DesktopConfig.ts"; +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts"; +import * as DesktopTailcatIdentity from "./DesktopTailcatIdentity.ts"; + +const NODE_KEY = `nodekey:${"3c".repeat(32)}`; +const KEY_FILE_TEXT = `privkey:${"5a".repeat(32)}\n`; +const ENCRYPTED_PREFIX = "enc:"; +// The TestClock starts at the epoch, so the stored record's timestamp is fixed. +const EPOCH_ISO = "1970-01-01T00:00:00.000Z"; + +const textEncoder = new TextEncoder(); +const textDecoder = new TextDecoder(); + +const IdentityRecordJson = Schema.fromJsonString( + Schema.Struct({ + version: Schema.Literal(1), + nodeKey: Schema.String, + keyFile: Schema.String, + createdAt: Schema.String, + }), +); +const decodeIdentityRecord = Schema.decodeUnknownEffect(IdentityRecordJson); + +function makeSafeStorageLayer(encryptionAvailable: boolean) { + return Layer.succeed(ElectronSafeStorage.ElectronSafeStorage, { + isEncryptionAvailable: Effect.succeed(encryptionAvailable), + encryptString: (value) => Effect.succeed(textEncoder.encode(`${ENCRYPTED_PREFIX}${value}`)), + decryptString: (value) => { + const decoded = textDecoder.decode(value); + return decoded.startsWith(ENCRYPTED_PREFIX) + ? Effect.succeed(decoded.slice(ENCRYPTED_PREFIX.length)) + : Effect.fail( + new ElectronSafeStorage.ElectronSafeStorageDecryptError({ + cause: new Error("not encrypted by this test"), + }), + ); + }, + selectedStorageBackend: Effect.succeed(Option.none()), + } satisfies ElectronSafeStorage.ElectronSafeStorage["Service"]); +} + +/** Only `stateDir` and `path` matter to the identity; the rest is a plausible desktop. */ +function makeEnvironmentLayer(baseDir: string) { + return DesktopEnvironment.layer({ + dirname: "/repo/apps/desktop/src", + homeDirectory: baseDir, + platform: "linux", + processArch: "x64", + appVersion: "1.2.3", + appPath: "/repo", + isPackaged: true, + resourcesPath: "/missing/resources", + runningUnderArm64Translation: false, + }).pipe( + Layer.provide( + Layer.mergeAll(NodeServices.layer, DesktopConfig.layerTest({ T3CODE_HOME: baseDir })), + ), + ); +} + +/** A tailcat that writes a fixed private key wherever it is asked to. */ +function makeRuntimeLayer(generatedKeyPaths: Array) { + return Layer.unwrap( + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + return Layer.mock(TailcatRuntime.TailcatRuntime)({ + generateClientIdentity: ({ keyPath }) => + Effect.gen(function* () { + generatedKeyPaths.push(keyPath); + yield* fileSystem + .writeFileString(keyPath, KEY_FILE_TEXT, { mode: 0o600 }) + .pipe(Effect.orDie); + return { nodeKey: NODE_KEY }; + }), + }); + }), + ).pipe(Layer.provide(NodeServices.layer)); +} + +/** One fresh identity service instance over the desktop state below `baseDir`. */ +function makeIdentityLayer( + baseDir: string, + options: { + readonly encryptionAvailable: boolean; + readonly generatedKeyPaths: Array; + }, +) { + return DesktopTailcatIdentity.layer.pipe( + Layer.provide( + Layer.mergeAll( + makeEnvironmentLayer(baseDir), + makeSafeStorageLayer(options.encryptionAvailable), + makeRuntimeLayer(options.generatedKeyPaths), + NodeServices.layer, + ), + ), + ); +} + +const readIdentity = Effect.gen(function* () { + const identity = yield* DesktopTailcatIdentity.DesktopTailcatIdentity; + return { nodeKey: yield* identity.nodeKey, encrypted: yield* identity.encrypted }; +}); + +const withTempStateDirectory = ( + use: (paths: { + readonly baseDir: string; + readonly identityDir: string; + readonly tempDir: string; + readonly encryptedPath: string; + readonly plaintextPath: string; + }) => Effect.Effect, +) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const baseDir = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-tailcat-identity-" }); + // The desktop keeps packaged state under `/userdata`. + const identityDir = path.join( + baseDir, + "userdata", + DesktopTailcatIdentity.DESKTOP_TAILCAT_IDENTITY_DIRECTORY, + ); + return yield* use({ + baseDir, + identityDir, + tempDir: path.join(identityDir, "tmp"), + encryptedPath: path.join(identityDir, "client-identity.enc"), + plaintextPath: path.join(identityDir, "client-identity.private.json"), + }); + }).pipe(Effect.provide(NodeServices.layer)); + +describe("DesktopTailcatIdentity", () => { + it.effect("generates the identity once and stores it encrypted", () => + withTempStateDirectory((paths) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const generatedKeyPaths: Array = []; + const options = { encryptionAvailable: true, generatedKeyPaths }; + + const first = yield* readIdentity.pipe( + Effect.provide(makeIdentityLayer(paths.baseDir, options)), + ); + + assert.equal(first.nodeKey, NODE_KEY); + assert.isTrue(first.encrypted); + const [generatedKeyPath] = generatedKeyPaths; + assert(generatedKeyPath !== undefined); + assert.equal(generatedKeyPaths.length, 1); + assert.equal(path.dirname(generatedKeyPath), paths.tempDir); + assert.isFalse(yield* fileSystem.exists(generatedKeyPath)); + assert.isTrue(yield* fileSystem.exists(paths.encryptedPath)); + assert.isFalse(yield* fileSystem.exists(paths.plaintextPath)); + assert.deepEqual(yield* fileSystem.readDirectory(paths.tempDir), []); + + const stored = textDecoder.decode(yield* fileSystem.readFile(paths.encryptedPath)); + assert.isTrue(stored.startsWith(ENCRYPTED_PREFIX)); + const record = yield* decodeIdentityRecord(stored.slice(ENCRYPTED_PREFIX.length)); + assert.deepEqual(record, { + version: 1, + nodeKey: NODE_KEY, + keyFile: KEY_FILE_TEXT, + createdAt: EPOCH_ISO, + }); + + // A key file left behind by a crashed process is swept when the next instance starts. + yield* fileSystem.writeFileString(path.join(paths.tempDir, "stale.key"), "privkey:stale"); + const second = yield* readIdentity.pipe( + Effect.provide(makeIdentityLayer(paths.baseDir, options)), + ); + + assert.deepEqual(second, first); + assert.equal(generatedKeyPaths.length, 1); + assert.deepEqual(yield* fileSystem.readDirectory(paths.tempDir), []); + }), + ), + ); + + it.effect("materializes a private key file only for the duration of use", () => + withTempStateDirectory((paths) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const identity = yield* DesktopTailcatIdentity.DesktopTailcatIdentity; + + const observed = yield* identity.withKeyFile((keyPath) => + Effect.gen(function* () { + const info = yield* fileSystem.stat(keyPath); + return { + keyPath, + contents: yield* fileSystem.readFileString(keyPath), + mode: info.mode & 0o777, + }; + }), + ); + + assert.equal(path.dirname(observed.keyPath), paths.tempDir); + assert.equal(observed.contents, KEY_FILE_TEXT); + assert.equal(observed.mode, 0o600); + assert.isFalse(yield* fileSystem.exists(observed.keyPath)); + + const failure = yield* identity + .withKeyFile((keyPath) => Effect.fail({ _tag: "UseFailed" as const, keyPath })) + .pipe(Effect.flip); + + assert(failure._tag === "UseFailed"); + assert.notEqual(failure.keyPath, observed.keyPath); + assert.isFalse(yield* fileSystem.exists(failure.keyPath)); + assert.deepEqual(yield* fileSystem.readDirectory(paths.tempDir), []); + }).pipe( + Effect.provide( + makeIdentityLayer(paths.baseDir, { encryptionAvailable: true, generatedKeyPaths: [] }), + ), + ), + ), + ); + + it.effect("falls back to a private plaintext file when OS encryption is unavailable", () => + withTempStateDirectory((paths) => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const generatedKeyPaths: Array = []; + const options = { encryptionAvailable: false, generatedKeyPaths }; + + const first = yield* readIdentity.pipe( + Effect.provide(makeIdentityLayer(paths.baseDir, options)), + ); + + assert.equal(first.nodeKey, NODE_KEY); + assert.isFalse(first.encrypted); + assert.isFalse(yield* fileSystem.exists(paths.encryptedPath)); + assert.isTrue(yield* fileSystem.exists(paths.plaintextPath)); + const info = yield* fileSystem.stat(paths.plaintextPath); + assert.equal(info.mode & 0o777, 0o600); + const record = yield* decodeIdentityRecord( + yield* fileSystem.readFileString(paths.plaintextPath), + ); + assert.equal(record.nodeKey, NODE_KEY); + assert.equal(record.keyFile, KEY_FILE_TEXT); + + const second = yield* readIdentity.pipe( + Effect.provide(makeIdentityLayer(paths.baseDir, options)), + ); + + assert.deepEqual(second, first); + assert.equal(generatedKeyPaths.length, 1); + }), + ), + ); +}); diff --git a/apps/desktop/src/tailcat/DesktopTailcatIdentity.ts b/apps/desktop/src/tailcat/DesktopTailcatIdentity.ts new file mode 100644 index 000000000000..85eeef028282 --- /dev/null +++ b/apps/desktop/src/tailcat/DesktopTailcatIdentity.ts @@ -0,0 +1,272 @@ +import { type TailcatNodeKey, tailcatNodeKeyFingerprint } from "@t3tools/contracts"; +import * as TailcatRuntime from "@t3tools/tailcat/runtime"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +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 Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; + +import * as DesktopEnvironment from "../app/DesktopEnvironment.ts"; +import * as ElectronSafeStorage from "../electron/ElectronSafeStorage.ts"; + +/** + * The desktop's Tailcat client identity: the private key T3 servers trust + * after a connection code is redeemed. It is stored encrypted with Electron's + * safeStorage (OS keychain / DPAPI / libsecret) and only materialized as a + * 0600 temp file for the moments a `tailcat` process needs to read it. + * + * When the OS offers no encryption backend, the key falls back to a 0600 + * plaintext file inside the desktop state directory, which is still private to + * the user account; the fallback is logged so support can see it. + */ + +export const DESKTOP_TAILCAT_IDENTITY_DIRECTORY = "tailcat"; +const ENCRYPTED_IDENTITY_FILE = "client-identity.enc"; +const PLAINTEXT_IDENTITY_FILE = "client-identity.private.json"; +const TEMP_DIRECTORY = "tmp"; + +const IdentityRecord = Schema.Struct({ + version: Schema.Literal(1), + nodeKey: Schema.String, + keyFile: Schema.String, + createdAt: Schema.String, +}); +type IdentityRecord = typeof IdentityRecord.Type; +const IdentityRecordJson = Schema.fromJsonString(IdentityRecord); +const decodeIdentityRecord = Schema.decodeUnknownEffect(IdentityRecordJson); +const encodeIdentityRecord = Schema.encodeEffect(IdentityRecordJson); + +export class DesktopTailcatIdentityError extends Schema.TaggedErrorClass()( + "DesktopTailcatIdentityError", + { + operation: Schema.Literals(["load", "generate", "store", "materialize"]), + detail: Schema.String, + cause: Schema.optionalKey(Schema.Defect()), + }, +) { + override get message(): string { + return `Tailcat identity ${this.operation} failed: ${this.detail}`; + } +} + +export class DesktopTailcatIdentity extends Context.Service< + DesktopTailcatIdentity, + { + /** Public node key of this device, generating the identity on first use. */ + readonly nodeKey: Effect.Effect; + /** Whether the private key is protected by the OS encryption backend. */ + readonly encrypted: Effect.Effect; + /** + * Runs `use` with a temporary 0600 key file that is deleted afterwards, + * whatever the outcome. + */ + readonly withKeyFile: ( + use: (keyPath: string) => Effect.Effect, + ) => Effect.Effect; + } +>()("@t3tools/desktop/tailcat/DesktopTailcatIdentity") {} + +const describe = (cause: unknown) => (cause instanceof Error ? cause.message : String(cause)); + +export const make = Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const safeStorage = yield* ElectronSafeStorage.ElectronSafeStorage; + const runtime = yield* TailcatRuntime.TailcatRuntime; + const crypto = yield* Crypto.Crypto; + const lock = yield* Semaphore.make(1); + + const directory = path.join(environment.stateDir, DESKTOP_TAILCAT_IDENTITY_DIRECTORY); + const tempDirectory = path.join(directory, TEMP_DIRECTORY); + const encryptedPath = path.join(directory, ENCRYPTED_IDENTITY_FILE); + const plaintextPath = path.join(directory, PLAINTEXT_IDENTITY_FILE); + const cached = yield* Ref.make>( + Option.none(), + ); + + const ensureDirectories = Effect.gen(function* () { + yield* fileSystem.makeDirectory(tempDirectory, { recursive: true }).pipe(Effect.ignore); + yield* fileSystem.chmod(directory, 0o700).pipe(Effect.ignore); + yield* fileSystem.chmod(tempDirectory, 0o700).pipe(Effect.ignore); + }); + + // Temp key files from a previous crash must not outlive the process that + // needed them. + const sweepTempFiles = fileSystem.readDirectory(tempDirectory).pipe( + Effect.flatMap((entries) => + Effect.forEach( + entries, + (entry) => fileSystem.remove(path.join(tempDirectory, entry)).pipe(Effect.ignore), + { discard: true }, + ), + ), + Effect.ignore, + ); + yield* ensureDirectories; + yield* sweepTempFiles; + + const encryptionAvailable = safeStorage.isEncryptionAvailable.pipe( + Effect.orElseSucceed(() => false), + ); + + const readStored = Effect.gen(function* () { + const encryptedExists = yield* fileSystem + .exists(encryptedPath) + .pipe(Effect.orElseSucceed(() => false)); + if (encryptedExists) { + const bytes = yield* fileSystem.readFile(encryptedPath); + const json = yield* safeStorage.decryptString(bytes); + const record = yield* decodeIdentityRecord(json); + return Option.some({ record, encrypted: true }); + } + const plaintextExists = yield* fileSystem + .exists(plaintextPath) + .pipe(Effect.orElseSucceed(() => false)); + if (plaintextExists) { + const json = yield* fileSystem.readFileString(plaintextPath); + const record = yield* decodeIdentityRecord(json); + return Option.some({ record, encrypted: false }); + } + return Option.none<{ record: IdentityRecord; encrypted: boolean }>(); + }).pipe( + Effect.mapError( + (cause) => + new DesktopTailcatIdentityError({ + operation: "load", + detail: describe(cause), + cause, + }), + ), + ); + + const tempPath = crypto.randomUUIDv4.pipe( + Effect.map((uuid) => path.join(tempDirectory, `${uuid.replace(/-/g, "")}.key`)), + Effect.mapError( + (cause) => + new DesktopTailcatIdentityError({ + operation: "materialize", + detail: "Secure randomness is unavailable.", + cause, + }), + ), + ); + + const writePrivate = (filePath: string, contents: string) => + fileSystem + .writeFileString(filePath, contents, { mode: 0o600 }) + .pipe(Effect.andThen(fileSystem.chmod(filePath, 0o600).pipe(Effect.ignore))); + + const store = (record: IdentityRecord) => + Effect.gen(function* () { + const json = yield* encodeIdentityRecord(record); + if (yield* encryptionAvailable) { + const bytes = yield* safeStorage.encryptString(json); + yield* fileSystem.writeFile(encryptedPath, bytes, { mode: 0o600 }); + yield* fileSystem.chmod(encryptedPath, 0o600).pipe(Effect.ignore); + yield* fileSystem.remove(plaintextPath).pipe(Effect.ignore); + return true; + } + yield* Effect.logWarning( + "OS encryption is unavailable; the Tailcat client identity is stored as a private file.", + { path: plaintextPath }, + ); + yield* writePrivate(plaintextPath, json); + return false; + }).pipe( + Effect.mapError( + (cause) => + new DesktopTailcatIdentityError({ + operation: "store", + detail: describe(cause), + cause, + }), + ), + ); + + const generate = Effect.gen(function* () { + const keyPath = yield* tempPath; + const generated = yield* runtime.generateClientIdentity({ keyPath }).pipe( + Effect.mapError( + (cause) => + new DesktopTailcatIdentityError({ + operation: "generate", + detail: cause.message, + cause, + }), + ), + ); + const keyFile = yield* fileSystem.readFileString(keyPath).pipe( + Effect.mapError( + (cause) => + new DesktopTailcatIdentityError({ + operation: "generate", + detail: describe(cause), + cause, + }), + ), + Effect.ensuring(fileSystem.remove(keyPath).pipe(Effect.ignore)), + ); + const record: IdentityRecord = { + version: 1, + nodeKey: generated.nodeKey, + keyFile, + createdAt: yield* DateTime.now.pipe(Effect.map(DateTime.formatIso)), + }; + const encrypted = yield* store(record); + yield* Effect.logInfo("Created the Tailcat client identity.", { + nodeKeyFingerprint: tailcatNodeKeyFingerprint(generated.nodeKey), + encrypted, + }); + return { record, encrypted }; + }); + + const load = lock.withPermits(1)( + Effect.gen(function* () { + const current = yield* Ref.get(cached); + if (Option.isSome(current)) { + return current.value; + } + const stored = yield* readStored; + const identity = Option.isSome(stored) ? stored.value : yield* generate; + yield* Ref.set(cached, Option.some(identity)); + return identity; + }), + ); + + const withKeyFile: DesktopTailcatIdentity["Service"]["withKeyFile"] = (use) => + Effect.gen(function* () { + const identity = yield* load; + const keyPath = yield* tempPath; + yield* writePrivate(keyPath, identity.record.keyFile).pipe( + Effect.mapError( + (cause) => + new DesktopTailcatIdentityError({ + operation: "materialize", + detail: describe(cause), + cause, + }), + ), + ); + return yield* use(keyPath).pipe( + Effect.ensuring(fileSystem.remove(keyPath).pipe(Effect.ignore)), + ); + }); + + return DesktopTailcatIdentity.of({ + nodeKey: load.pipe(Effect.map((identity) => identity.record.nodeKey as TailcatNodeKey)), + encrypted: load.pipe( + Effect.map((identity) => identity.encrypted), + Effect.orElseSucceed(() => false), + ), + withKeyFile, + }); +}); + +export const layer = Layer.effect(DesktopTailcatIdentity, make); diff --git a/apps/desktop/src/tailcat/DesktopTailcatRuntime.ts b/apps/desktop/src/tailcat/DesktopTailcatRuntime.ts new file mode 100644 index 000000000000..2a662fc2e15c --- /dev/null +++ b/apps/desktop/src/tailcat/DesktopTailcatRuntime.ts @@ -0,0 +1,68 @@ +import { tailcatExecutableName, tailcatPlatformKey } from "@t3tools/tailcat/manifest"; +import * as TailcatRuntime from "@t3tools/tailcat/runtime"; +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 DesktopEnvironment from "../app/DesktopEnvironment.ts"; + +/** + * Where the desktop app looks for the Tailcat executable, in preference order: + * the developer override, the packaged `resources/tailcat//` + * directory, the dev `prod-resources` staging directory, and the monorepo's + * `native/tailcat/dist` output. A `tailcat` on PATH is the last resort and is + * still version-checked against the pinned manifest. + */ +export function desktopTailcatBundledCandidates( + environment: DesktopEnvironment.DesktopEnvironment["Service"], +): ReadonlyArray { + const architecture = environment.processArch as NodeJS.Architecture; + const platformKey = tailcatPlatformKey(environment.platform, architecture); + if (platformKey === undefined) { + return []; + } + const packaged = TailcatRuntime.bundledTailcatCandidates({ + platform: environment.platform, + architecture, + joinPath: (...segments) => environment.path.join(...segments), + moduleDirectory: environment.resourcesPath, + repoRootCandidates: environment.isDevelopment ? [environment.rootDir] : [], + }); + const staged = environment.resolveResourcePathCandidates( + environment.path.join("tailcat", platformKey, tailcatExecutableName(environment.platform)), + ); + return Array.from(new Set([...packaged, ...staged])); +} + +/** First bundled candidate that exists on disk, for the backend bootstrap. */ +export const resolveDesktopTailcatBinaryPath = Effect.fn("desktop.tailcat.resolveBinaryPath")( + function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const override = yield* TailcatRuntime.tailcatOverridePathFromEnvironment; + if (override !== undefined) { + return Option.some(override); + } + for (const candidate of desktopTailcatBundledCandidates(environment)) { + if (yield* fileSystem.exists(candidate).pipe(Effect.orElseSucceed(() => false))) { + return Option.some(candidate); + } + } + return Option.none(); + }, +); + +export const layer = Layer.unwrap( + Effect.gen(function* () { + const environment = yield* DesktopEnvironment.DesktopEnvironment; + const overridePath = yield* TailcatRuntime.tailcatOverridePathFromEnvironment; + return TailcatRuntime.layer({ + resolution: { + overridePath, + bundledCandidates: desktopTailcatBundledCandidates(environment), + allowSystem: true, + }, + }); + }), +); diff --git a/apps/mobile/src/connection/platform.ts b/apps/mobile/src/connection/platform.ts index d6b50e50a6a7..981bb5cd4cb2 100644 --- a/apps/mobile/src/connection/platform.ts +++ b/apps/mobile/src/connection/platform.ts @@ -6,6 +6,7 @@ import { PrimaryEnvironmentAuth, RelayDeviceIdentity, SshEnvironmentGateway, + TailcatEnvironmentGateway, } from "@t3tools/client-runtime/platform"; import { ConnectionBlockedError, @@ -191,6 +192,28 @@ const capabilitiesLayer = Layer.effectContext( disconnect: () => Effect.void, }), ), + // A phone has no process to run the tailcat forwarder in, so Tailcat + // environments are set up (and saved) from the desktop app. + Context.add( + TailcatEnvironmentGateway, + TailcatEnvironmentGateway.of({ + provision: () => + Effect.fail( + new ConnectionBlockedError({ + reason: "unsupported", + detail: "Tailcat environments are managed from the desktop app.", + }), + ), + prepare: () => + Effect.fail( + new ConnectionBlockedError({ + reason: "unsupported", + detail: "Tailcat environments are managed from the desktop app.", + }), + ), + disconnect: () => Effect.void, + }), + ), ); }), ); diff --git a/apps/mobile/src/features/connection/pairing.test.ts b/apps/mobile/src/features/connection/pairing.test.ts index 193927684794..23fce3b440e2 100644 --- a/apps/mobile/src/features/connection/pairing.test.ts +++ b/apps/mobile/src/features/connection/pairing.test.ts @@ -3,10 +3,15 @@ import { describe, expect, it } from "vite-plus/test"; import { buildPairingUrl, extractPairingUrlFromQrPayload, + PairingInputNotPairableError, PairingQrPayloadEmptyError, parsePairingUrl, + unsupportedPairingInputMessage, } from "./pairing"; +const TAILCAT_CODE = "t3c://tailcat/eyJ2IjoxfQ"; +const PEER_CODE = "t3c://peer/eyJ2IjoxfQ"; + describe("buildPairingUrl", () => { it("uses HTTP for a schemeless IP address", () => { expect(buildPairingUrl("192.168.1.100:3773", "pairing-token")).toBe( @@ -42,6 +47,15 @@ describe("extractPairingUrlFromQrPayload", () => { ).toBe("https://remote.example.com/pair#token=pairing-token"); }); + it("explains where a scanned Tailcat connection code belongs", () => { + expect(() => extractPairingUrlFromQrPayload(TAILCAT_CODE)).toThrowError( + PairingInputNotPairableError, + ); + expect(() => extractPairingUrlFromQrPayload(TAILCAT_CODE)).toThrowError( + "This is a Tailcat connection code. Paste it in the desktop app under Add environment → Tailcat.", + ); + }); + it("rejects empty qr payloads", () => { expect(() => extractPairingUrlFromQrPayload(" ")).toThrowError(PairingQrPayloadEmptyError); expect(() => extractPairingUrlFromQrPayload(" ")).toThrowError( @@ -62,3 +76,27 @@ describe("parsePairingUrl", () => { }); }); }); + +describe("unsupportedPairingInputMessage", () => { + it("guides Tailcat and peer codes to the desktop app", () => { + expect(unsupportedPairingInputMessage(` ${TAILCAT_CODE} `)).toBe( + "This is a Tailcat connection code. Paste it in the desktop app under Add environment → Tailcat.", + ); + expect(unsupportedPairingInputMessage(PEER_CODE)).toBe( + "This is a federation peer code. Add it in the desktop app under Settings → Connections → Federation.", + ); + expect(unsupportedPairingInputMessage("t3c://mystery/abc")).toBe( + "This is a T3 connection code, not a pairing URL. Use it in the desktop app.", + ); + }); + + it("leaves pairing urls and hosts alone", () => { + expect(unsupportedPairingInputMessage("https://remote.example.com/#token=abc")).toBeNull(); + expect(unsupportedPairingInputMessage("192.168.1.100:3773")).toBeNull(); + expect(unsupportedPairingInputMessage("")).toBeNull(); + }); + + it("keeps a pasted connection code intact instead of mangling it into a host", () => { + expect(parsePairingUrl(TAILCAT_CODE)).toEqual({ host: TAILCAT_CODE, code: "" }); + }); +}); diff --git a/apps/mobile/src/features/connection/pairing.ts b/apps/mobile/src/features/connection/pairing.ts index 569d00cbdd36..16d773d5cbf9 100644 --- a/apps/mobile/src/features/connection/pairing.ts +++ b/apps/mobile/src/features/connection/pairing.ts @@ -1,4 +1,5 @@ import { readHostedPairingRequest } from "@t3tools/shared/remote"; +import { isT3ConnectionCode, peekT3ConnectionCodeKind } from "@t3tools/shared/t3ConnectionCode"; import * as Schema from "effect/Schema"; const MOBILE_PAIRING_URL_PARAM = "pairingUrl"; @@ -27,6 +28,36 @@ export class PairingQrPayloadEmptyError extends Schema.TaggedErrorClass()( + "PairingInputNotPairableError", + { + kind: Schema.NullOr(Schema.String), + }, +) { + override get message(): string { + switch (this.kind) { + case "tailcat": + return "This is a Tailcat connection code. Paste it in the desktop app under Add environment → Tailcat."; + case "peer": + return "This is a federation peer code. Add it in the desktop app under Settings → Connections → Federation."; + default: + return "This is a T3 connection code, not a pairing URL. Use it in the desktop app."; + } + } +} + +/** Guidance for inputs that are T3 connection codes rather than pairing URLs; null for everything else. */ +export function unsupportedPairingInputMessage(input: string): string | null { + const trimmed = input.trim(); + if (!isT3ConnectionCode(trimmed)) return null; + return new PairingInputNotPairableError({ kind: peekT3ConnectionCodeKind(trimmed) }).message; +} + export function buildPairingUrl(host: string, code: string): string { const h = host.trim(); const c = code.trim(); @@ -45,6 +76,8 @@ export function buildPairingUrl(host: string, code: string): string { export function parsePairingUrl(url: string): { host: string; code: string } { const trimmed = url.trim(); if (!trimmed) return { host: "", code: "" }; + // Keep a pasted connection code intact so the guidance error matches what the user sees. + if (isT3ConnectionCode(trimmed)) return { host: trimmed, code: "" }; try { const parsed = new URL(trimmed); @@ -75,6 +108,9 @@ export function extractPairingUrlFromQrPayload(payload: string): string { if (!trimmed) { throw new PairingQrPayloadEmptyError({}); } + if (isT3ConnectionCode(trimmed)) { + throw new PairingInputNotPairableError({ kind: peekT3ConnectionCodeKind(trimmed) }); + } try { const url = new URL(trimmed); diff --git a/apps/mobile/src/state/use-remote-environment-registry.ts b/apps/mobile/src/state/use-remote-environment-registry.ts index 4f5f455522bc..89119c61b2cb 100644 --- a/apps/mobile/src/state/use-remote-environment-registry.ts +++ b/apps/mobile/src/state/use-remote-environment-registry.ts @@ -1,10 +1,16 @@ import { useAtomValue } from "@effect/atom-react"; +import { + type ConnectionAttemptError, + ConnectionBlockedError, +} from "@t3tools/client-runtime/connection"; +import type { ConnectionPersistenceError } from "@t3tools/client-runtime/platform"; import type { EnvironmentId } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; import { useCallback, useMemo } from "react"; import { Alert } from "react-native"; +import { unsupportedPairingInputMessage } from "../features/connection/pairing"; import { useConnectionController } from "../features/connection/useConnectionController"; import { environmentPresentations } from "./presentation"; import { useWorkspaceState } from "../state/workspace"; @@ -122,6 +128,16 @@ export function useRemoteConnections() { async (pairingUrl?: string) => { const nextPairingUrl = pairingUrl ?? connectionPairingUrl; setPendingConnectionError(null); + // Tailcat and peer codes are redeemed by the desktop app; say so instead + // of letting the pairing resolver report an invalid URL. + const guidance = unsupportedPairingInputMessage(nextPairingUrl); + if (guidance !== null) { + setPendingConnectionError(guidance); + return AsyncResult.failure< + EnvironmentId, + ConnectionAttemptError | ConnectionPersistenceError + >(Cause.fail(new ConnectionBlockedError({ reason: "configuration", detail: guidance }))); + } const result = await controller.connectPairingUrl(nextPairingUrl); if (AsyncResult.isFailure(result)) { const error = Cause.squash(result.cause); diff --git a/apps/server/package.json b/apps/server/package.json index ca74368348ea..3574b144afe8 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -39,6 +39,7 @@ "@effect/vitest": "catalog:", "@t3tools/contracts": "workspace:*", "@t3tools/shared": "workspace:*", + "@t3tools/tailcat": "workspace:*", "@t3tools/tailscale": "workspace:*", "@t3tools/web": "workspace:*", "@types/bun": "1.3.14", diff --git a/apps/server/scripts/cli.ts b/apps/server/scripts/cli.ts index 2de5b702a286..299f1a9b5571 100644 --- a/apps/server/scripts/cli.ts +++ b/apps/server/scripts/cli.ts @@ -16,6 +16,13 @@ import { resolveWebIconOverrides, } from "../../../scripts/lib/brand-assets.ts"; import { resolveCatalogDependencies } from "../../../scripts/lib/resolve-catalog.ts"; +import { stageTailcatDist } from "../../../scripts/lib/tailcat-dist.ts"; +import { + TAILCAT_DIST_RELATIVE_PATH, + TAILCAT_MANIFEST_RELATIVE_PATH, + readTailcatManifest, + tailcatManifestPlatformKeys, +} from "../../../scripts/lib/tailcat-manifest.ts"; import { fromJsonStringPretty } from "@t3tools/shared/schemaJson"; import { fromYaml } from "@t3tools/shared/schemaYaml"; import { resolveSpawnCommand } from "@t3tools/shared/shell"; @@ -136,6 +143,34 @@ const applyDevelopmentIconOverrides = Effect.fn("applyDevelopmentIconOverrides") yield* Effect.log("[cli] Applied development icon overrides to dist/client"); }); +/** + * The npm package is platform-independent, so it carries the Tailcat runtime + * for every platform key the manifest pins under dist/tailcat//, + * the same decision the resource monitor makes. Binaries come from + * native/tailcat/dist (`node scripts/fetch-tailcat.ts --all`) and are verified + * against native/tailcat/manifest.json before they are copied, so publishing + * with a missing or stale runtime fails here instead of shipping. + */ +const stageTailcatRuntimes = Effect.fn("stageTailcatRuntimes")(function* ( + repoRoot: string, + serverDir: string, +) { + const path = yield* Path.Path; + const manifest = yield* readTailcatManifest(path.join(repoRoot, TAILCAT_MANIFEST_RELATIVE_PATH)); + const platformKeys = tailcatManifestPlatformKeys(manifest); + for (const platformKey of platformKeys) { + yield* stageTailcatDist({ + distRoot: path.join(repoRoot, TAILCAT_DIST_RELATIVE_PATH), + platformKey, + manifest, + destinationRoot: path.join(serverDir, "dist/tailcat"), + }); + } + yield* Effect.log( + `[cli] Staged tailcat ${manifest.version} into dist/tailcat for ${platformKeys.join(", ")}`, + ); +}); + // --------------------------------------------------------------------------- // build subcommand // --------------------------------------------------------------------------- @@ -271,6 +306,7 @@ const publishCmd = Command.make( // config, including override selectors, is interpreted correctly. (resource) => Effect.gen(function* () { + yield* stageTailcatRuntimes(repoRoot, serverDir); yield* fs.writeFileString(packageJsonPath, `${resource.packageJsonString}\n`); for (const icon of resource.icons) { yield* fs.writeFile(icon.targetPath, icon.publish); @@ -297,6 +333,11 @@ const publishCmd = Command.make( for (const icon of resource.icons) { yield* fs.writeFile(icon.targetPath, icon.original); } + // dist/ is the build output; the staged runtimes are publish-only. + yield* fs.remove(path.join(serverDir, "dist/tailcat"), { + recursive: true, + force: true, + }); if (config.verbose) yield* Effect.log("[cli] Restored original publish assets"); }), ); diff --git a/apps/server/src/auth/EnvironmentAuth.ts b/apps/server/src/auth/EnvironmentAuth.ts index 2d0f02274de9..8177e5737158 100644 --- a/apps/server/src/auth/EnvironmentAuth.ts +++ b/apps/server/src/auth/EnvironmentAuth.ts @@ -61,6 +61,17 @@ export interface IssuedBearerSession { readonly expiresAt: DateTime.Utc; } +/** A completed bootstrap exchange with the facts callers need beyond the wire result. */ +export interface BootstrapCredentialExchange { + readonly result: AuthAccessTokenResult; + readonly sessionId: AuthSessionId; + readonly grant: { + readonly id?: string; + readonly subject: string; + readonly label?: string; + }; +} + export interface AuthenticatedSession { readonly sessionId: AuthSessionId; readonly subject: string; @@ -441,6 +452,18 @@ export class EnvironmentAuth extends Context.Service< AuthAccessTokenResult, ServerAuthInvalidCredentialError | ServerAuthInvalidRequestError | ServerAuthInternalError >; + /** Same exchange, also reporting which grant was consumed and the session it made. */ + readonly exchangeBootstrapCredential: ( + credential: string, + requestedScopes: ReadonlyArray | undefined, + requestMetadata: AuthClientMetadata, + input?: { + readonly proofKeyThumbprint?: string; + }, + ) => Effect.Effect< + BootstrapCredentialExchange, + ServerAuthInvalidCredentialError | ServerAuthInvalidRequestError | ServerAuthInternalError + >; readonly createPairingLink: (input?: { readonly ttl?: Duration.Duration; readonly label?: string; @@ -729,44 +752,50 @@ export const make = Effect.gen(function* () { Effect.withSpan("EnvironmentAuth.createBrowserSession"), ); - const exchangeBootstrapCredentialForAccessToken: EnvironmentAuth["Service"]["exchangeBootstrapCredentialForAccessToken"] = - (credential, requestedScopes, requestMetadata, input) => - bootstrapCredentials.consume(credential, input).pipe( - Effect.mapError(toBootstrapExchangeError), - Effect.flatMap((grant) => - Effect.gen(function* () { - const grantedScopes = requestedScopes ?? grant.scopes; - if (!grantedScopes.every((scope) => grant.scopes.includes(scope))) { - return yield* new ServerAuthScopeNotGrantedError({}); - } - return yield* sessions - .issue({ - method: input?.proofKeyThumbprint ? "dpop-access-token" : "bearer-access-token", - subject: grant.subject, - scopes: grantedScopes, - ...(input?.proofKeyThumbprint - ? { - proofKeyThumbprint: input.proofKeyThumbprint, - ttl: Duration.hours(1), - } - : {}), - client: { - ...requestMetadata, - ...(grant.label ? { label: grant.label } : {}), - }, - }) - .pipe( - Effect.mapError( - (cause) => new ServerAuthAuthenticatedAccessTokenIssueError({ cause }), - ), - ); - }), - ), - Effect.flatMap((session) => - DateTime.now.pipe( - Effect.map( - (now) => - ({ + const exchangeBootstrapCredential: EnvironmentAuth["Service"]["exchangeBootstrapCredential"] = ( + credential, + requestedScopes, + requestMetadata, + input, + ) => + bootstrapCredentials.consume(credential, input).pipe( + Effect.mapError(toBootstrapExchangeError), + Effect.flatMap((grant) => + Effect.gen(function* () { + const grantedScopes = requestedScopes ?? grant.scopes; + if (!grantedScopes.every((scope) => grant.scopes.includes(scope))) { + return yield* new ServerAuthScopeNotGrantedError({}); + } + const session = yield* sessions + .issue({ + method: input?.proofKeyThumbprint ? "dpop-access-token" : "bearer-access-token", + subject: grant.subject, + scopes: grantedScopes, + ...(input?.proofKeyThumbprint + ? { + proofKeyThumbprint: input.proofKeyThumbprint, + ttl: Duration.hours(1), + } + : {}), + client: { + ...requestMetadata, + ...(grant.label ? { label: grant.label } : {}), + }, + }) + .pipe( + Effect.mapError( + (cause) => new ServerAuthAuthenticatedAccessTokenIssueError({ cause }), + ), + ); + return { grant, session }; + }), + ), + Effect.flatMap(({ grant, session }) => + DateTime.now.pipe( + Effect.map( + (now) => + ({ + result: { access_token: session.token, issued_token_type: AuthAccessTokenType, token_type: input?.proofKeyThumbprint ? "DPoP" : "Bearer", @@ -777,10 +806,24 @@ export const make = Effect.gen(function* () { ), ), scope: encodeOAuthScope(session.scopes), - }) satisfies AuthAccessTokenResult, - ), + } satisfies AuthAccessTokenResult, + sessionId: session.sessionId, + grant: { + ...(grant.id === undefined ? {} : { id: grant.id }), + subject: grant.subject, + ...(grant.label === undefined ? {} : { label: grant.label }), + }, + }) satisfies BootstrapCredentialExchange, ), ), + ), + Effect.withSpan("EnvironmentAuth.exchangeBootstrapCredential"), + ); + + const exchangeBootstrapCredentialForAccessToken: EnvironmentAuth["Service"]["exchangeBootstrapCredentialForAccessToken"] = + (credential, requestedScopes, requestMetadata, input) => + exchangeBootstrapCredential(credential, requestedScopes, requestMetadata, input).pipe( + Effect.map((exchange) => exchange.result), Effect.withSpan("EnvironmentAuth.exchangeBootstrapCredentialForAccessToken"), ); @@ -1005,6 +1048,7 @@ export const make = Effect.gen(function* () { getSessionState, createBrowserSession, exchangeBootstrapCredentialForAccessToken, + exchangeBootstrapCredential, createPairingLink, issuePairingCredential, issueStartupPairingCredential, diff --git a/apps/server/src/auth/PairingGrantStore.ts b/apps/server/src/auth/PairingGrantStore.ts index 057a257ba664..46cbcedd4719 100644 --- a/apps/server/src/auth/PairingGrantStore.ts +++ b/apps/server/src/auth/PairingGrantStore.ts @@ -21,6 +21,8 @@ import * as ServerConfig from "../config.ts"; import * as AuthPairingLinks from "../persistence/AuthPairingLinks.ts"; export interface BootstrapGrant { + /** The pairing link id, when the grant came from a persisted link. */ + readonly id?: string; readonly method: ServerAuthBootstrapMethod; readonly scopes: ReadonlyArray; readonly subject: string; @@ -526,6 +528,7 @@ export const make = Effect.gen(function* () { if (Option.isSome(consumed)) { yield* emitRemoved(consumed.value.id); return { + id: consumed.value.id, method: consumed.value.method, scopes: consumed.value.scopes, subject: consumed.value.subject, diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 65f590d1a838..2d37f56733fa 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -1,5 +1,6 @@ import { AuthAccessReadScope, + AuthAccessWriteScope, AuthOrchestrationOperateScope, AuthOrchestrationReadScope, AuthRelayReadScope, @@ -140,6 +141,27 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.subscribeServerConfig]: AuthOrchestrationReadScope, [WS_METHODS.subscribeServerLifecycle]: AuthOrchestrationReadScope, [WS_METHODS.subscribeAuthAccess]: AuthAccessReadScope, + // Tailcat remote access is administrative: it changes who can reach this + // server at the transport layer. + [WS_METHODS.tailcatSubscribeRemoteAccess]: AuthAccessReadScope, + [WS_METHODS.tailcatSetRemoteAccessEnabled]: AuthAccessWriteScope, + [WS_METHODS.tailcatCreateConnectionCode]: AuthAccessWriteScope, + [WS_METHODS.tailcatRevokeTrustedPeer]: AuthAccessWriteScope, + [WS_METHODS.tailcatRenameTrustedPeer]: AuthAccessWriteScope, + [WS_METHODS.tailcatRegenerateIdentity]: AuthAccessWriteScope, + // Peer trust is administrative; using an already-trusted peer is ordinary + // orchestration work, read or operate like the local equivalent. + [WS_METHODS.federationSubscribePeers]: AuthAccessReadScope, + [WS_METHODS.federationCreatePeerCode]: AuthAccessWriteScope, + [WS_METHODS.federationAddPeer]: AuthAccessWriteScope, + [WS_METHODS.federationRemovePeer]: AuthAccessWriteScope, + [WS_METHODS.federationRefreshPeer]: AuthOrchestrationReadScope, + [WS_METHODS.federationListRemoteProjects]: AuthOrchestrationReadScope, + [WS_METHODS.federationStartRemoteRun]: AuthOrchestrationOperateScope, + [WS_METHODS.federationCancelRemoteRun]: AuthOrchestrationOperateScope, + [WS_METHODS.federationSubscribeRemoteRuns]: AuthOrchestrationReadScope, + [WS_METHODS.federationDescribeRemoteArtifacts]: AuthOrchestrationReadScope, + [WS_METHODS.federationFetchRemoteArtifact]: AuthOrchestrationReadScope, [WS_METHODS.subscribeBackgroundPolicy]: AuthOrchestrationReadScope, } as const satisfies Readonly>; diff --git a/apps/server/src/auth/http.ts b/apps/server/src/auth/http.ts index cc74966c41e2..63255e935709 100644 --- a/apps/server/src/auth/http.ts +++ b/apps/server/src/auth/http.ts @@ -36,6 +36,9 @@ import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; import * as EnvironmentAuth from "./EnvironmentAuth.ts"; import * as SessionStore from "./SessionStore.ts"; +import { isTailcatNodeKey } from "@t3tools/tailcat/address"; +import { TAILCAT_CONNECTION_CODE_PAIRING_SUBJECT } from "@t3tools/contracts"; +import * as TailcatRemoteAccess from "../tailcat/TailcatRemoteAccess.ts"; import { traceAuthenticatedRelayRequest, traceRelayRequest } from "../cloud/traceRelayRequest.ts"; import { deriveAuthClientMetadata } from "./utils.ts"; import { verifyRequestDpopProof } from "./dpop.ts"; @@ -233,6 +236,7 @@ export const authHttpApiLayer = HttpApiBuilder.group( Effect.fnUntraced(function* (handlers) { const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; const sessions = yield* SessionStore.SessionStore; + const tailcatRemoteAccess = yield* TailcatRemoteAccess.TailcatRemoteAccess; return handlers .handle( @@ -335,7 +339,7 @@ export const authHttpApiLayer = HttpApiBuilder.group( ) : undefined; yield* appendCredentialResponseHeaders; - return yield* serverAuth.exchangeBootstrapCredentialForAccessToken( + const exchange = yield* serverAuth.exchangeBootstrapCredential( args.payload.subject_token, requestedScopes, deriveAuthClientMetadata({ @@ -350,6 +354,28 @@ export const authHttpApiLayer = HttpApiBuilder.group( }), proofKeyThumbprint ? { proofKeyThumbprint } : undefined, ); + // A Tailcat connection code binds the pairing to the client's node + // key: only a grant minted as such may extend the transport + // allowlist, so a LAN pairing link cannot smuggle a key in. + const tailcatNodeKey = args.payload.client_tailcat_node_key?.trim(); + if ( + tailcatNodeKey !== undefined && + exchange.grant.subject === TAILCAT_CONNECTION_CODE_PAIRING_SUBJECT && + isTailcatNodeKey(tailcatNodeKey) + ) { + yield* tailcatRemoteAccess + .recordTrustedPeer({ + nodeKey: tailcatNodeKey, + label: args.payload.client_label, + sessionId: exchange.sessionId, + }) + .pipe( + Effect.catch((error) => + Effect.logWarning("Could not record the paired Tailcat peer.", { error }), + ), + ); + } + return exchange.result; }, traceRelayRequest, Effect.catchIf(EnvironmentAuth.isServerAuthCredentialError, (error) => diff --git a/apps/server/src/bin.test.ts b/apps/server/src/bin.test.ts index 0deb261dbf9e..83daaa40344c 100644 --- a/apps/server/src/bin.test.ts +++ b/apps/server/src/bin.test.ts @@ -116,6 +116,8 @@ const makeCliTestServerConfig = (baseDir: string) => logWebSocketEvents: false, tailscaleServeEnabled: false, tailscaleServePort: 443, + tailcatEnabled: undefined, + tailcatBinaryPath: undefined, } satisfies ServerConfig.ServerConfig["Service"]; }); diff --git a/apps/server/src/bin.ts b/apps/server/src/bin.ts index 0a2e4091560b..901355707f85 100644 --- a/apps/server/src/bin.ts +++ b/apps/server/src/bin.ts @@ -11,10 +11,12 @@ import { authCommand } from "./cli/auth.ts"; import { appCommand } from "./cli/app.ts"; import { connectCommand } from "./cli/connect.ts"; import { pairCommand } from "./cli/pair.ts"; +import { peerCommand } from "./cli/peer.ts"; import { hasCloudPublicConfig } from "./cloud/publicConfig.ts"; import { sharedServerCommandFlags } from "./cli/config.ts"; import { isEntrypoint } from "./entrypoint.ts"; import { projectCommand } from "./cli/project.ts"; +import { remoteCommand } from "./cli/remote.ts"; import { runServerCommand, serveCommand, startCommand } from "./cli/server.ts"; import { serviceCommand } from "./cli/service.ts"; import { servicePreflightCommand } from "./cli/servicePreflight.ts"; @@ -56,6 +58,8 @@ export const makeCli = ({ cloudEnabled = hasCloudPublicConfig } = {}) => serveCommand, appCommand, pairCommand, + remoteCommand, + peerCommand, authCommand, projectCommand, serviceCommand, diff --git a/apps/server/src/cli/auth.ts b/apps/server/src/cli/auth.ts index 1b349111811c..7e0487cb722f 100644 --- a/apps/server/src/cli/auth.ts +++ b/apps/server/src/cli/auth.ts @@ -24,6 +24,7 @@ import { type CliAuthLocationFlags, DurationFromString, resolveCliAuthConfig, + jsonFlag, } from "./config.ts"; const runWithEnvironmentAuth = ( @@ -56,11 +57,6 @@ const ttlFlag = Flag.string("ttl").pipe( Flag.optional, ); -const jsonFlag = Flag.boolean("json").pipe( - Flag.withDescription("Emit JSON instead of human-readable output."), - Flag.withDefault(false), -); - const labelFlag = Flag.string("label").pipe( Flag.withDescription("Optional human-readable label."), Flag.optional, diff --git a/apps/server/src/cli/config.test.ts b/apps/server/src/cli/config.test.ts index def63b61fafe..c7f94b80fcd3 100644 --- a/apps/server/src/cli/config.test.ts +++ b/apps/server/src/cli/config.test.ts @@ -132,6 +132,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { logWebSocketEvents: true, tailscaleServeEnabled: false, tailscaleServePort: 443, + tailcatEnabled: undefined, + tailcatBinaryPath: undefined, }); assert.equal(resolved.stateDir, join(baseDir, "userdata")); }), @@ -202,6 +204,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { logWebSocketEvents: true, tailscaleServeEnabled: true, tailscaleServePort: 8443, + tailcatEnabled: undefined, + tailcatBinaryPath: undefined, }); assert.equal(resolved.dbPath, join(baseDir, "userdata", "state.sqlite")); }), @@ -275,6 +279,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { logWebSocketEvents: false, tailscaleServeEnabled: false, tailscaleServePort: 443, + tailcatEnabled: undefined, + tailcatBinaryPath: undefined, }); }), ); @@ -354,6 +360,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { logWebSocketEvents: false, tailscaleServeEnabled: false, tailscaleServePort: 443, + tailcatEnabled: undefined, + tailcatBinaryPath: undefined, }); assert.equal(join(baseDir, "userdata"), resolved.stateDir); assert.equal(resolved.desktopTelemetryFd, 4); @@ -484,6 +492,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { logWebSocketEvents: true, tailscaleServeEnabled: false, tailscaleServePort: 443, + tailcatEnabled: undefined, + tailcatBinaryPath: undefined, }); }), ); @@ -553,6 +563,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { logWebSocketEvents: false, tailscaleServeEnabled: false, tailscaleServePort: 443, + tailcatEnabled: undefined, + tailcatBinaryPath: undefined, }); }), ); @@ -616,6 +628,8 @@ it.layer(NodeServices.layer)("cli config resolution", (it) => { logWebSocketEvents: false, tailscaleServeEnabled: false, tailscaleServePort: 443, + tailcatEnabled: undefined, + tailcatBinaryPath: undefined, }); }), ); diff --git a/apps/server/src/cli/config.ts b/apps/server/src/cli/config.ts index f739a4e2f22c..4b5e9b2fc413 100644 --- a/apps/server/src/cli/config.ts +++ b/apps/server/src/cli/config.ts @@ -30,6 +30,10 @@ const hostFlag = Flag.string("host").pipe( Flag.withDescription("Host/interface to bind (for example 127.0.0.1, 0.0.0.0, or a Tailnet IP)."), Flag.optional, ); +export const jsonFlag = Flag.boolean("json").pipe( + Flag.withDescription("Emit JSON instead of human-readable output."), + Flag.withDefault(false), +); export const baseDirFlag = Flag.string("base-dir").pipe( Flag.withDescription( "Explicit T3 Code data directory; runtime state is stored under userdata (equivalent to T3CODE_HOME).", @@ -74,6 +78,12 @@ export const tailscaleServePortFlag = Flag.integer("tailscale-serve-port").pipe( Flag.withDescription("HTTPS port for Tailscale Serve when --tailscale-serve is enabled."), Flag.optional, ); +const tailcatFlag = Flag.boolean("tailcat").pipe( + Flag.withDescription( + "Enable Tailcat remote access: serve this backend through an encrypted Tailcat tunnel and print a connection code.", + ), + Flag.optional, +); const EnvServerConfig = Config.all({ logLevel: Config.logLevel("T3CODE_LOG_LEVEL").pipe(Config.withDefault("Info")), @@ -139,6 +149,10 @@ const EnvServerConfig = Config.all({ Config.option, Config.map(Option.getOrUndefined), ), + tailcatEnabled: Config.boolean("T3CODE_TAILCAT").pipe( + Config.option, + Config.map(Option.getOrUndefined), + ), }); export interface CliServerFlags { @@ -154,6 +168,7 @@ export interface CliServerFlags { readonly logWebSocketEvents: Option.Option; readonly tailscaleServeEnabled: Option.Option; readonly tailscaleServePort: Option.Option; + readonly tailcatEnabled?: Option.Option; } export interface CliAuthLocationFlags { @@ -188,6 +203,7 @@ export const sharedServerCommandFlags = { logWebSocketEvents: logWebSocketEventsFlag, tailscaleServeEnabled: tailscaleServeFlag, tailscaleServePort: tailscaleServePortFlag, + tailcatEnabled: tailcatFlag, } as const; const resolveOptionPrecedence = ( @@ -231,6 +247,7 @@ export const resolveServerConfig = ( logWebSocketEvents: flags.logWebSocketEvents ?? Option.none(), tailscaleServeEnabled: flags.tailscaleServeEnabled ?? Option.none(), tailscaleServePort: flags.tailscaleServePort ?? Option.none(), + tailcatEnabled: flags.tailcatEnabled ?? Option.none(), } satisfies CliServerFlags; const bootstrapFd = Option.getOrUndefined(normalizedFlags.bootstrapFd) ?? env.bootstrapFd; const bootstrapEnvelope = @@ -336,6 +353,13 @@ export const resolveServerConfig = ( ), () => 443, ); + const tailcatEnabled = Option.getOrUndefined( + resolveOptionPrecedence( + normalizedFlags.tailcatEnabled ?? Option.none(), + Option.fromUndefinedOr(env.tailcatEnabled), + ), + ); + const tailcatBinaryPath = bootstrap?.tailcatBinaryPath; const staticDir = devUrl ? undefined : yield* ServerConfig.resolveStaticDir(); const host = Option.getOrElse( resolveOptionPrecedence( @@ -384,6 +408,8 @@ export const resolveServerConfig = ( logWebSocketEvents, tailscaleServeEnabled, tailscaleServePort, + tailcatEnabled, + tailcatBinaryPath, }; return config; diff --git a/apps/server/src/cli/connect.ts b/apps/server/src/cli/connect.ts index 3f8e1d123da5..ed5e4992f3ae 100644 --- a/apps/server/src/cli/connect.ts +++ b/apps/server/src/cli/connect.ts @@ -47,7 +47,7 @@ import * as ServerConfig from "../config.ts"; import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; import * as ExternalLauncher from "../process/externalLauncher.ts"; import { readPersistedServerRuntimeState } from "../serverRuntimeState.ts"; -import { projectLocationFlags, resolveCliAuthConfig } from "./config.ts"; +import { projectLocationFlags, resolveCliAuthConfig, jsonFlag } from "./config.ts"; import { resolveCliCommand } from "./invocation.ts"; import { bootServiceLayer, @@ -55,11 +55,6 @@ import { recoverServiceOnboardingOffer, } from "./service.ts"; -const jsonFlag = Flag.boolean("json").pipe( - Flag.withDescription("Emit JSON instead of human-readable output."), - Flag.withDefault(false), -); - const isCloudCliTokenManagerError = Schema.is(CliTokenManager.CloudCliTokenManagerError); const headlessFlag = Flag.boolean("headless").pipe( diff --git a/apps/server/src/cli/pair.ts b/apps/server/src/cli/pair.ts index d40e0d97e484..aa9e483d6408 100644 --- a/apps/server/src/cli/pair.ts +++ b/apps/server/src/cli/pair.ts @@ -230,14 +230,14 @@ const probeEnvironmentDescriptor = ( return { _tag: "descriptor", descriptor } as const; }).pipe(Effect.catch((outcome) => Effect.succeed(outcome))); -interface DiscoveredPairTarget { +export interface DiscoveredPairTarget { readonly baseDir: string; readonly variant: PairStateVariant; readonly state: PersistedServerRuntimeState; readonly descriptor: ExecutionEnvironmentDescriptor; } -const discoverPairTarget = Effect.fn("pair.discoverPairTarget")(function* ( +export const discoverPairTarget = Effect.fn("pair.discoverPairTarget")(function* ( explicitBaseDir: string | undefined, ) { const bases: Array = []; @@ -297,7 +297,7 @@ const discoverPairTarget = Effect.fn("pair.discoverPairTarget")(function* ( * choice pinned to where the runtime state was actually found, independent of * ambient environment variables. */ -const makePairServerConfig = Effect.fn(function* (input: { +export const makePairServerConfig = Effect.fn(function* (input: { readonly target: DiscoveredPairTarget; readonly logLevel: ServerConfig.ServerConfig["Service"]["logLevel"]; }) { @@ -341,6 +341,8 @@ const makePairServerConfig = Effect.fn(function* (input: { logWebSocketEvents: false, tailscaleServeEnabled: false, tailscaleServePort: DEFAULT_TAILSCALE_SERVE_PORT, + tailcatEnabled: undefined, + tailcatBinaryPath: undefined, }); }); diff --git a/apps/server/src/cli/peer.test.ts b/apps/server/src/cli/peer.test.ts new file mode 100644 index 000000000000..44c4567387a1 --- /dev/null +++ b/apps/server/src/cli/peer.test.ts @@ -0,0 +1,617 @@ +// @effect-diagnostics nodeBuiltinImport:off - CLI integration exercises Node HTTP and filesystem boundaries. +import * as NodeHttp from "node:http"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { + EnvironmentId, + FederationError, + FederationPeer, + type FederationPeerCodeResult, + type FederationRemoteRun, + type FederationRemoteRunsSnapshot, + type FederationRunEvent, + type FederationRunStatus, + type FederationSnapshot, + ProjectId, + ProviderInstanceId, + ThreadId, + WS_METHODS, + WsFederationAddPeerRpc, + WsFederationCreatePeerCodeRpc, + WsFederationListRemoteProjectsRpc, + WsFederationRemovePeerRpc, + WsFederationStartRemoteRunRpc, + WsFederationSubscribePeersRpc, + WsFederationSubscribeRemoteRunsRpc, +} from "@t3tools/contracts"; +import * as NetService from "@t3tools/shared/Net"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Ref from "effect/Ref"; +import * as References from "effect/References"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as TestConsole from "effect/testing/TestConsole"; +import { Command } from "effect/unstable/cli"; +import * as CliError from "effect/unstable/cli/CliError"; +import * as HttpRouter from "effect/unstable/http/HttpRouter"; +import * as HttpServer from "effect/unstable/http/HttpServer"; +import * as HttpServerRequest from "effect/unstable/http/HttpServerRequest"; +import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; +import { RpcGroup, RpcSerialization, RpcServer } from "effect/unstable/rpc"; + +import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import { cli } from "../bin.ts"; +import * as ServerConfig from "../config.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import { layerConfig as SqlitePersistenceLayerLive } from "../persistence/Layers/Sqlite.ts"; +import { + makePersistedServerRuntimeState, + persistServerRuntimeState, +} from "../serverRuntimeState.ts"; +import { runningServerWsUrl } from "./peer.ts"; + +const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer); + +const runCli = (args: ReadonlyArray) => Command.runWith(cli, { version: "0.0.0" })(args); + +const provideCliTestLayers = (effect: Effect.Effect) => + Effect.provide(effect, Layer.mergeAll(CliRuntimeLayer, TestConsole.layer)); + +// The test console is shared and accumulates across CLI runs, so each capture +// keeps only the entries its own run appended. +const captureNewLogLines = (args: ReadonlyArray) => + provideCliTestLayers( + Effect.gen(function* () { + const before = (yield* TestConsole.logLines).length; + yield* runCli(args); + return (yield* TestConsole.logLines) + .slice(before) + .filter((line): line is string => typeof line === "string"); + }), + ); + +/** Everything one CLI run logged, joined; `run --wait` logs as events arrive. */ +const captureStdout = (args: ReadonlyArray) => + Effect.map(captureNewLogLines(args), (lines) => lines.join("\n")); + +/** `--json` output has to be one clean entry: nothing logged before or after it. */ +const captureJson = (args: ReadonlyArray) => + Effect.map(captureNewLogLines(args), (lines) => { + assert.equal(lines.length, 1, `Expected exactly one JSON entry, got ${String(lines)}`); + return lines[0] ?? ""; + }); + +const flipCli = (args: ReadonlyArray) => + provideCliTestLayers(runCli(args).pipe(Effect.flip)); + +const expectShowHelpError = (error: unknown, expectedTag: string) => { + if (!CliError.isCliError(error) || error._tag !== "ShowHelp") { + assert.fail(`Expected ShowHelp, got ${String(error)}`); + } + assert.equal(error.errors[0]?._tag, expectedTag); + return error.errors[0]; +}; + +const makeTempBaseDir = (prefix: string) => + NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), `t3-peer-cli-${prefix}-`)); + +const testDescriptor = { + environmentId: "peer-test-environment", + label: "peer-test", + platform: { os: "linux", arch: "x64" }, + serverVersion: "0.0.1", + capabilities: { repositoryIdentity: true }, +}; + +const descriptorRouteLayer = HttpRouter.add( + "GET", + "/.well-known/t3/environment", + HttpServerResponse.jsonUnsafe(testDescriptor), +); + +const makeCliTestServerConfig = (baseDir: string) => + Effect.gen(function* () { + const derivedPaths = yield* ServerConfig.deriveServerPaths(baseDir, undefined); + return { + logLevel: "Warn", + traceMinLevel: "Info", + traceTimingEnabled: false, + traceBatchWindowMs: 200, + traceMaxBytes: 10 * 1024 * 1024, + traceMaxFiles: 10, + otlpTracesUrl: undefined, + otlpMetricsUrl: undefined, + otlpExportIntervalMs: 10_000, + otlpServiceName: "t3-server", + mode: "web", + port: 0, + host: "127.0.0.1", + cwd: process.cwd(), + baseDir, + ...derivedPaths, + staticDir: undefined, + devUrl: undefined, + devAllowedOrigins: [], + noBrowser: true, + startupPresentation: "headless", + desktopBootstrapToken: undefined, + autoBootstrapProjectFromCwd: false, + logWebSocketEvents: false, + tailscaleServeEnabled: false, + tailscaleServePort: 443, + tailcatEnabled: undefined, + tailcatBinaryPath: undefined, + } satisfies ServerConfig.ServerConfig["Service"]; + }); + +const LOCAL_ID = EnvironmentId.make("env-local"); +const PEER_ID = EnvironmentId.make("env-peer-1"); +const PROJECT_ID = ProjectId.make("project-1"); +const THREAD_ID = ThreadId.make("thread-remote-1"); +const UPDATED_AT = "2026-06-21T08:30:00.000Z"; + +const peer: FederationPeer = { + peerId: PEER_ID, + label: "Build box", + publicKeyFingerprint: "SHA256:peer-fingerprint", + grantedScopes: ["environment.read", "projects.read", "runs.read"], + allowedScopes: ["environment.read", "projects.read", "runs.read", "runs.start"], + transport: { tailcat: { address: "tcPeerAddressAbCdEfGhIj", port: 3773 } }, + remoteServerVersion: "0.9.0", + remoteProtocolVersion: 1, + remoteCapabilities: ["hello", "projects.list", "runs.start"], + status: "online", + lastSeenAt: UPDATED_AT, + lastError: null, + createdAt: "2026-06-20T00:00:00.000Z", +}; + +const peersSnapshot: FederationSnapshot = { + environmentId: LOCAL_ID, + publicKeyFingerprint: "SHA256:local-fingerprint", + protocolVersion: 1, + peers: [peer], + updatedAt: UPDATED_AT, +}; + +const peerCode: FederationPeerCodeResult = { + code: "t3c://peer/test-code", + payload: { + v: 1, + kind: "peer", + protocolVersion: 1, + environmentId: LOCAL_ID, + publicKey: "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEAtest\n-----END PUBLIC KEY-----", + label: "local", + transport: { tailcat: { address: "tcLocalAddressAbCdEfGhIj", port: 3773 } }, + token: "one-time-token", + scopes: ["environment.read", "projects.read", "runs.read"], + expiresAt: "2026-06-21T08:35:00.000Z", + }, + expiresAt: "2026-06-21T08:35:00.000Z", +}; + +const runEvent = (sequence: number, type: string, summary: string): FederationRunEvent => ({ + sequence, + at: UPDATED_AT, + type, + summary, +}); + +const remoteRun = ( + status: FederationRunStatus, + events: ReadonlyArray, + assistantPreview: string | null = null, +): FederationRemoteRun => ({ + peerId: PEER_ID, + peerLabel: peer.label, + run: { + environmentId: PEER_ID, + projectId: PROJECT_ID, + threadId: THREAD_ID, + turnId: null, + title: "Fix the flaky test", + status, + runtimeMode: "full-access", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5-codex" }, + requestedAt: UPDATED_AT, + startedAt: null, + completedAt: null, + assistantPreview, + turnCount: 0, + }, + events, + lastSyncedAt: null, + syncError: null, +}); + +const remoteRunsSnapshot = (run: FederationRemoteRun): FederationRemoteRunsSnapshot => ({ + runs: [run], + updatedAt: UPDATED_AT, +}); + +const turnStarted = runEvent(1, "turn.started", "Turn started"); +const assistantMessage = runEvent(2, "assistant.message", "Working on it"); +const turnCompleted = runEvent(3, "turn.completed", "Turn completed"); + +/** Only the federation RPCs the CLI drives; the client is built from the full group and dispatches by tag. */ +const PeerCliRpcs = RpcGroup.make( + WsFederationSubscribePeersRpc, + WsFederationCreatePeerCodeRpc, + WsFederationAddPeerRpc, + WsFederationRemovePeerRpc, + WsFederationListRemoteProjectsRpc, + WsFederationStartRemoteRunRpc, + WsFederationSubscribeRemoteRunsRpc, +); + +interface RecordedCall { + readonly method: string; + readonly input: unknown; +} + +const makeFederationHandlersLayer = (calls: Ref.Ref>) => { + const record = (method: string, input: unknown) => + Ref.update(calls, (recorded) => [...recorded, { method, input }]); + return PeerCliRpcs.toLayer({ + [WS_METHODS.federationSubscribePeers]: () => Stream.make(peersSnapshot), + [WS_METHODS.federationCreatePeerCode]: (input) => + record("createPeerCode", input).pipe( + Effect.as({ ...peerCode, payload: { ...peerCode.payload, scopes: input.scopes } }), + ), + [WS_METHODS.federationAddPeer]: (input) => + input.code === peerCode.code + ? record("addPeer", input).pipe(Effect.as({ ...peer, grantedScopes: input.grantedScopes })) + : Effect.fail( + new FederationError({ code: "code-invalid", message: "That peer code is not valid." }), + ), + [WS_METHODS.federationRemovePeer]: (input) => + input.peerId === PEER_ID + ? record("removePeer", input) + : Effect.fail( + new FederationError({ + code: "peer-unknown", + message: `No peer ${input.peerId} is paired with this server.`, + }), + ), + [WS_METHODS.federationListRemoteProjects]: () => + Effect.succeed({ + environmentId: PEER_ID, + projects: [ + { + id: PROJECT_ID, + title: "t3code", + workspaceRoot: "/srv/t3code", + repositoryIdentity: null, + defaultModelSelection: null, + }, + ], + }), + [WS_METHODS.federationStartRemoteRun]: (input) => + record("startRemoteRun", input).pipe(Effect.as(remoteRun("queued", []))), + [WS_METHODS.federationSubscribeRemoteRuns]: () => + Stream.make( + remoteRunsSnapshot(remoteRun("running", [turnStarted])), + remoteRunsSnapshot(remoteRun("running", [turnStarted, assistantMessage])), + remoteRunsSnapshot( + remoteRun("completed", [turnStarted, assistantMessage, turnCompleted], "Done."), + ), + ), + }); +}; + +// The production `/ws` route in miniature: authenticate the upgrade with the +// server's auth (the CLI sends its session as a bearer header), then hand the +// socket to an RPC server over the scripted federation handlers. +const wsRouteLayer = (calls: Ref.Ref>) => + HttpRouter.add( + "GET", + "/ws", + Effect.gen(function* () { + const request = yield* HttpServerRequest.HttpServerRequest; + const serverAuth = yield* EnvironmentAuth.EnvironmentAuth; + const authenticated = yield* Effect.result(serverAuth.authenticateWebSocketUpgrade(request)); + if (authenticated._tag === "Failure") { + return HttpServerResponse.empty({ status: 401 }); + } + return yield* RpcServer.toHttpEffectWebsocket(PeerCliRpcs, { disableTracing: true }).pipe( + Effect.provide( + makeFederationHandlersLayer(calls).pipe(Layer.provideMerge(RpcSerialization.layerJson)), + ), + Effect.flatMap((httpEffect) => httpEffect), + ); + }), + ); + +const withLiveFederationServer = ( + baseDir: string, + run: (calls: Ref.Ref>) => Effect.Effect, +) => + Effect.gen(function* () { + const config = yield* makeCliTestServerConfig(baseDir); + const calls = yield* Ref.make>([]); + const appLayer = HttpRouter.serve(Layer.mergeAll(descriptorRouteLayer, wsRouteLayer(calls)), { + disableListenLog: true, + disableLogger: true, + }).pipe( + Layer.provideMerge( + EnvironmentAuth.layer.pipe( + Layer.provideMerge(SqlitePersistenceLayerLive), + Layer.provide(ServerEnvironment.identityLayer), + Layer.provide(ServerSecretStore.layer), + ), + ), + Layer.provideMerge( + NodeHttpServer.layer(NodeHttp.createServer, { + host: "127.0.0.1", + port: 0, + }), + ), + Layer.provideMerge(NodeServices.layer), + Layer.provide(ServerConfig.layer(config)), + // The server shares the test console with the CLI under test; keep its + // own startup chatter out of the captured output. + Layer.provide(Layer.succeed(References.MinimumLogLevel, "Error")), + ); + + return yield* Effect.scoped( + Effect.gen(function* () { + const server = yield* HttpServer.HttpServer; + const address = server.address; + if (typeof address === "string" || !("port" in address)) { + return yield* Effect.die(new Error(`Expected TCP address, got ${String(address)}`)); + } + yield* persistServerRuntimeState({ + path: config.serverRuntimeStatePath, + state: yield* makePersistedServerRuntimeState({ config, port: address.port }), + }); + return yield* run(calls); + }).pipe(Effect.provide(Layer.mergeAll(appLayer, NodeServices.layer))), + ); + }); + +const decodePeersJson = Schema.decodeUnknownEffect( + Schema.fromJsonString(Schema.Array(FederationPeer)), +); +const isFederationError = Schema.is(FederationError); + +const countOccurrences = (haystack: string, needle: string) => haystack.split(needle).length - 1; + +it("derives the RPC socket URL from the server origin", () => { + assert.equal(runningServerWsUrl("http://127.0.0.1:3773"), "ws://127.0.0.1:3773/ws"); + assert.equal(runningServerWsUrl("https://[fd7a:115c::1]:3773"), "wss://[fd7a:115c::1]:3773/ws"); +}); + +it.layer(NodeServices.layer)("t3 peer", (it) => { + it.effect("registers every peer subcommand", () => + Effect.gen(function* () { + const output = yield* captureStdout(["peer", "--help"]); + + for (const subcommand of ["code", "add", "list", "remove", "projects", "run"]) { + assert.include(output, subcommand); + } + assert.include(output, "Pair with other T3 Code servers and delegate runs to them."); + }), + ); + + it.effect("validates arguments before contacting any server", () => + Effect.gen(function* () { + expectShowHelpError(yield* flipCli(["peer", "add"]), "MissingArgument"); + + const badGrant = expectShowHelpError( + yield* flipCli(["peer", "add", "t3c://peer/x", "--grant", "nope"]), + "InvalidValue", + ); + if (badGrant?._tag !== "InvalidValue") { + assert.fail("Expected InvalidValue"); + } + assert.equal(badGrant.option, "grant"); + + // A variadic argument with a minimum reports "0 occurrences" as an invalid value. + const noPrompt = expectShowHelpError( + yield* flipCli(["peer", "run", "env-peer-1", "project-1"]), + "InvalidValue", + ); + if (noPrompt?._tag !== "InvalidValue") { + assert.fail("Expected InvalidValue"); + } + assert.equal(noPrompt.option, "prompt"); + + expectShowHelpError(yield* flipCli(["peer", "remove", " "]), "InvalidValue"); + }), + ); + + it.effect("lists peers from the running server", () => + Effect.gen(function* () { + const baseDir = makeTempBaseDir("list"); + + yield* withLiveFederationServer(baseDir, () => + Effect.gen(function* () { + const output = yield* captureStdout(["peer", "list", "--base-dir", baseDir]); + assert.include( + output, + "This environment: env-local (fingerprint SHA256:local-fingerprint)", + ); + assert.include(output, "Build box (env-peer-1) online"); + assert.include(output, "fingerprint: SHA256:peer-fingerprint"); + assert.include( + output, + "granted (they may do here): environment.read projects.read runs.read", + ); + assert.include( + output, + "allowed (we may do there): environment.read projects.read runs.read runs.start", + ); + assert.include(output, "transport: tailcat tcPeerAddressAbCdEfGhIj:3773"); + assert.include(output, `last seen: ${UPDATED_AT}`); + + const json = yield* captureJson(["peer", "list", "--base-dir", baseDir, "--json"]); + assert.deepEqual(yield* decodePeersJson(json), [peer]); + }), + ); + }), + ); + + it.effect("issues and redeems peer codes, browses projects, and removes peers", () => + Effect.gen(function* () { + const baseDir = makeTempBaseDir("pairing"); + + yield* withLiveFederationServer(baseDir, (calls) => + Effect.gen(function* () { + const code = yield* captureStdout(["peer", "code", "--base-dir", baseDir]); + assert.include(code, "Peer code (expires 2026-06-21T08:35:00.000Z, single use):"); + assert.include(code, "t3c://peer/test-code"); + assert.include(code, "Offered scopes: environment.read projects.read runs.read"); + assert.include(code, "one-time pairing credential"); + + const scoped = yield* captureStdout([ + "peer", + "code", + "--base-dir", + baseDir, + "--scope", + "runs.start", + "--scope", + "runs.start", + "--ttl", + "10m", + ]); + assert.include(scoped, "Offered scopes: runs.start"); + + const added = yield* captureStdout([ + "peer", + "add", + peerCode.code, + "--base-dir", + baseDir, + "--grant", + "runs.start", + ]); + assert.include(added, "Paired with a new peer."); + assert.include(added, "Build box (env-peer-1) online"); + assert.include(added, "granted (they may do here): runs.start"); + + const rejected = yield* flipCli([ + "peer", + "add", + "t3c://peer/bogus", + "--base-dir", + baseDir, + ]); + if (!isFederationError(rejected)) { + assert.fail(`Expected FederationError, got ${String(rejected)}`); + } + assert.equal(rejected.code, "code-invalid"); + assert.equal(rejected.message, "That peer code is not valid."); + + const projects = yield* captureStdout([ + "peer", + "projects", + "env-peer-1", + "--base-dir", + baseDir, + ]); + assert.include(projects, "t3code (project-1)"); + assert.include(projects, "path: /srv/t3code"); + + const removed = yield* captureStdout([ + "peer", + "remove", + "env-peer-1", + "--base-dir", + baseDir, + ]); + assert.include(removed, "Removed peer env-peer-1."); + + const unknown = yield* flipCli(["peer", "remove", "env-other", "--base-dir", baseDir]); + if (!isFederationError(unknown)) { + assert.fail(`Expected FederationError, got ${String(unknown)}`); + } + assert.equal(unknown.code, "peer-unknown"); + + const recorded = yield* Ref.get(calls); + assert.deepEqual( + recorded.map((call) => call.method), + ["createPeerCode", "createPeerCode", "addPeer", "removePeer"], + ); + assert.deepEqual(recorded[0]?.input, { + scopes: ["environment.read", "projects.read", "runs.read"], + }); + // Repeated scopes collapse; --ttl arrives in whole seconds. + assert.deepEqual(recorded[1]?.input, { scopes: ["runs.start"], ttlSeconds: 600 }); + assert.deepEqual(recorded[2]?.input, { + code: peerCode.code, + grantedScopes: ["runs.start"], + }); + }), + ); + }), + ); + + it.effect("starts a remote run and can follow it to completion", () => + Effect.gen(function* () { + const baseDir = makeTempBaseDir("run"); + + yield* withLiveFederationServer(baseDir, (calls) => + Effect.gen(function* () { + const started = yield* captureStdout([ + "peer", + "run", + "env-peer-1", + "project-1", + "fix", + "the", + "flaky", + "test", + "--title", + "Flaky", + "--base-dir", + baseDir, + ]); + assert.include(started, "Run thread-remote-1 on Build box: queued"); + assert.include(started, "title: Fix the flaky test"); + assert.include(started, "model: codex/gpt-5-codex"); + + const followed = yield* captureStdout([ + "peer", + "run", + "env-peer-1", + "project-1", + "fix the flaky test", + "--wait", + "--base-dir", + baseDir, + ]); + assert.include(followed, "Started run thread-remote-1 on Build box (queued)."); + // Each event prints exactly once even though every snapshot repeats the history. + assert.equal(countOccurrences(followed, "turn.started: Turn started"), 1); + assert.equal(countOccurrences(followed, "assistant.message: Working on it"), 1); + assert.equal(countOccurrences(followed, "turn.completed: Turn completed"), 1); + assert.include(followed, "Run thread-remote-1 on Build box: completed"); + assert.include(followed, "assistant: Done."); + + const recorded = yield* Ref.get(calls); + assert.deepEqual( + recorded.map((call) => call.input), + [ + { + peerId: "env-peer-1", + projectId: "project-1", + prompt: "fix the flaky test", + title: "Flaky", + }, + { peerId: "env-peer-1", projectId: "project-1", prompt: "fix the flaky test" }, + ], + ); + }), + ); + }), + ); +}); diff --git a/apps/server/src/cli/peer.ts b/apps/server/src/cli/peer.ts new file mode 100644 index 000000000000..46b9f7bcbfe0 --- /dev/null +++ b/apps/server/src/cli/peer.ts @@ -0,0 +1,462 @@ +/** + * `t3 peer ` - federation between T3 Code servers: issue and + * redeem peer codes, list peers, browse a peer's projects, and start or follow + * runs on a peer. + * + * Federation management lives on the WebSocket RPC surface (the HTTP + * federation group is the peer-to-peer protocol, not the operator API), so + * this command opens an RPC connection to the running server with the same + * short-lived administrative session `t3 remote` uses, carried as a bearer + * header on the upgrade request. + */ +import * as NodeSocket from "@effect/platform-node/NodeSocket"; +import { + EnvironmentId, + FEDERATION_DEFAULT_SCOPES, + FederationError, + type FederationPeer, + type FederationPeerCodeResult, + type FederationProjectSummary, + type FederationRemoteRun, + type FederationRunEvent, + type FederationRunStatus, + FederationScope, + type FederationSnapshot, + ProjectId, + TrimmedNonEmptyString, + WS_METHODS, + WsRpcGroup, +} from "@t3tools/contracts"; +import * as Console from "effect/Console"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Predicate from "effect/Predicate"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import { Argument, Command, Flag } from "effect/unstable/cli"; +import { FetchHttpClient } from "effect/unstable/http"; +import { RpcClient, RpcSerialization } from "effect/unstable/rpc"; +import * as Socket from "effect/unstable/socket/Socket"; + +import { baseDirFlag, DurationFromString, jsonFlag } from "./config.ts"; +import { + RunningServerRequestError, + type RunningServerSession, + withRunningServerSession, + callRunningServer, +} from "./remote.ts"; + +const RPC_OPEN_TIMEOUT = Duration.seconds(10); + +const isFederationError = Schema.is(FederationError); + +const TERMINAL_RUN_STATUSES: ReadonlySet = new Set([ + "completed", + "interrupted", + "error", +]); + +const isTerminalRunStatus = (status: FederationRunStatus): boolean => + TERMINAL_RUN_STATUSES.has(status); + +/** The server's `/ws` route on the origin it recorded; the dev proxy is not involved on loopback. */ +export const runningServerWsUrl = (origin: string): string => { + const url = new URL("/ws", origin); + url.protocol = url.protocol === "https:" ? "wss:" : "ws:"; + return url.toString(); +}; + +// Node's `ws` client rather than the global WebSocket: the administrative +// bearer token has to ride on the upgrade request, and only `ws` takes headers. +const bearerWebSocketConstructorLayer = (token: string) => + Layer.succeed( + Socket.WebSocketConstructor, + (url, protocols) => + new NodeSocket.NodeWS.WebSocket(url, protocols, { + headers: { authorization: `Bearer ${token}` }, + }) as unknown as globalThis.WebSocket, + ); + +const rpcProtocolLayer = (session: RunningServerSession) => + RpcClient.layerProtocolSocket().pipe( + Layer.provide( + Socket.layerWebSocket(runningServerWsUrl(session.origin), { + openTimeout: RPC_OPEN_TIMEOUT, + }).pipe(Layer.provide(bearerWebSocketConstructorLayer(session.token))), + ), + Layer.provide(RpcSerialization.layerJson), + ); + +const makeRpcClient = RpcClient.make(WsRpcGroup); +type WsRpcClient = Effect.Success; + +const runPeerCommand = ( + flags: { readonly baseDir: Option.Option; readonly json?: boolean }, + run: (client: WsRpcClient) => Effect.Effect, +) => + withRunningServerSession({ + baseDir: flags.baseDir, + label: "t3 peer", + quietLogs: flags.json === true, + run: (session) => + Effect.scoped( + makeRpcClient.pipe(Effect.flatMap(run), Effect.provide(rpcProtocolLayer(session))), + ), + }).pipe(Effect.provide(FetchHttpClient.layer)); + +// Typed federation failures are worded for the user by the server; anything +// else (authorization, transport, no answer) gets the generic wrapper. +const call = (operation: string, request: Effect.Effect) => + callRunningServer(operation, request, isFederationError); + +const scopeList = (scopes: ReadonlyArray): string => + scopes.length === 0 ? "none" : scopes.join(" "); + +const uniqueScopesOrDefault = ( + scopes: ReadonlyArray, +): ReadonlyArray => + scopes.length === 0 ? FEDERATION_DEFAULT_SCOPES : Array.from(new Set(scopes)); + +export const formatPeer = (peer: FederationPeer): string => + [ + `${peer.label} (${peer.peerId}) ${peer.status}`, + ` fingerprint: ${peer.publicKeyFingerprint}`, + ` granted (they may do here): ${scopeList(peer.grantedScopes)}`, + ` allowed (we may do there): ${scopeList(peer.allowedScopes)}`, + ` transport: ${ + peer.transport === null + ? "none" + : `tailcat ${peer.transport.tailcat.address}:${String(peer.transport.tailcat.port)}` + }`, + ` server: ${peer.remoteServerVersion ?? "unknown"}`, + ` last seen: ${peer.lastSeenAt ?? "never"}`, + ...(peer.lastError === null ? [] : [` last error: ${peer.lastError}`]), + ].join("\n"); + +export const formatPairedPeer = ( + peer: FederationPeer, + options: { readonly json: boolean }, +): string => + options.json ? JSON.stringify(peer, null, 2) : `Paired with a new peer.\n\n${formatPeer(peer)}`; + +export const formatPeerList = ( + snapshot: FederationSnapshot, + options: { readonly json: boolean }, +): string => { + if (options.json) { + return JSON.stringify(snapshot.peers, null, 2); + } + const header = `This environment: ${snapshot.environmentId} (fingerprint ${snapshot.publicKeyFingerprint})`; + if (snapshot.peers.length === 0) { + return `${header}\n\nNo peers. Create a code with \`t3 peer code\` or redeem one with \`t3 peer add \`.`; + } + return [header, "", snapshot.peers.map(formatPeer).join("\n\n")].join("\n"); +}; + +export const formatPeerCode = ( + issued: FederationPeerCodeResult, + options: { readonly json: boolean }, +): string => { + if (options.json) { + return JSON.stringify(issued, null, 2); + } + return [ + `Peer code (expires ${issued.expiresAt}, single use):`, + issued.code, + "", + `Offered scopes: ${scopeList(issued.payload.scopes)}`, + "On the other server, run `t3 peer add ` to pair it with this one.", + "Warning: this code embeds a one-time pairing credential. Share it only with the server you are pairing.", + ].join("\n"); +}; + +export const formatRemoteProjects = ( + projects: ReadonlyArray, + options: { readonly json: boolean }, +): string => { + if (options.json) { + return JSON.stringify(projects, null, 2); + } + if (projects.length === 0) { + return "The peer has no projects."; + } + return projects + .map((project) => + [`${project.title} (${project.id})`, ` path: ${project.workspaceRoot}`].join("\n"), + ) + .join("\n\n"); +}; + +export const formatRunEvent = (event: FederationRunEvent): string => + `[${event.at}] ${event.type}${event.summary.length > 0 ? `: ${event.summary}` : ""}`; + +export const formatRemoteRun = ( + remoteRun: FederationRemoteRun, + options: { readonly json: boolean }, +): string => { + if (options.json) { + return JSON.stringify(remoteRun, null, 2); + } + return [ + `Run ${remoteRun.run.threadId} on ${remoteRun.peerLabel}: ${remoteRun.run.status}`, + ` title: ${remoteRun.run.title}`, + ` project: ${remoteRun.run.projectId}`, + ` model: ${remoteRun.run.modelSelection.instanceId}/${remoteRun.run.modelSelection.model}`, + ...(remoteRun.run.assistantPreview === null + ? [] + : [` assistant: ${remoteRun.run.assistantPreview}`]), + ...(remoteRun.syncError === null ? [] : [` sync error: ${remoteRun.syncError}`]), + ].join("\n"); +}; + +/** + * Follow one remote run through the remote-runs subscription, printing each + * event once as it lands, until the run reaches a terminal status. Resolves + * with the last snapshot of the run, or none if the server stopped tracking it. + */ +const followRemoteRun = Effect.fn("peer.followRemoteRun")(function* ( + client: WsRpcClient, + started: FederationRemoteRun, + options: { readonly json: boolean }, +) { + const printedThrough = yield* Ref.make(-1); + const latest = yield* Ref.make(Option.none()); + yield* client[WS_METHODS.federationSubscribeRemoteRuns]({}).pipe( + Stream.map((snapshot) => + snapshot.runs.find( + (candidate) => + candidate.peerId === started.peerId && candidate.run.threadId === started.run.threadId, + ), + ), + Stream.filter(Predicate.isNotUndefined), + Stream.takeUntil((remoteRun) => isTerminalRunStatus(remoteRun.run.status)), + Stream.runForEach((remoteRun) => + Effect.gen(function* () { + yield* Ref.set(latest, Option.some(remoteRun)); + if (options.json) { + return; + } + const seen = yield* Ref.get(printedThrough); + const fresh = remoteRun.events.filter((event) => event.sequence > seen); + const last = fresh.at(-1); + if (last === undefined) { + return; + } + yield* Ref.set(printedThrough, last.sequence); + yield* Console.log(fresh.map(formatRunEvent).join("\n")); + }), + ), + Effect.mapError((cause) => + isFederationError(cause) + ? cause + : new RunningServerRequestError({ operation: "federation.subscribeRemoteRuns", cause }), + ), + ); + return yield* Ref.get(latest); +}); + +const peerIdArgument = Argument.string("peer-id").pipe( + Argument.withDescription("Peer environment id, as listed by `t3 peer list`."), + Argument.withSchema(EnvironmentId), +); + +const scopeDescription = `Repeat for several; defaults to ${FEDERATION_DEFAULT_SCOPES.join(", ")}.`; + +const peerCodeCommand = Command.make("code", { + baseDir: baseDirFlag, + scope: Flag.choice("scope", FederationScope.literals).pipe( + Flag.withDescription(`Scope offered to the server that redeems the code. ${scopeDescription}`), + Flag.atLeast(0), + ), + ttl: Flag.string("ttl").pipe( + Flag.withSchema(DurationFromString), + Flag.withDescription( + "How long the code stays redeemable, for example `5m` or `1h`. Defaults to 5 minutes.", + ), + Flag.optional, + ), + json: jsonFlag, +}).pipe( + Command.withDescription("Create a one-time peer code another T3 Code server can redeem."), + Command.withHandler((flags) => + runPeerCommand(flags, (client) => + Effect.gen(function* () { + const issued = yield* call( + "federation.createPeerCode", + client[WS_METHODS.federationCreatePeerCode]({ + scopes: uniqueScopesOrDefault(flags.scope), + ...(Option.isSome(flags.ttl) + ? { ttlSeconds: Math.max(1, Math.round(Duration.toSeconds(flags.ttl.value))) } + : {}), + }), + ); + yield* Console.log(formatPeerCode(issued, { json: flags.json })); + }), + ), + ), +); + +const peerAddCommand = Command.make("add", { + baseDir: baseDirFlag, + code: Argument.string("code").pipe( + Argument.withDescription("Peer code issued by `t3 peer code` on the other server."), + Argument.withSchema(TrimmedNonEmptyString), + ), + grant: Flag.choice("grant", FederationScope.literals).pipe( + Flag.withDescription(`Scope this server grants the new peer. ${scopeDescription}`), + Flag.atLeast(0), + ), + json: jsonFlag, +}).pipe( + Command.withDescription("Redeem a peer code and pair this server with the one that issued it."), + Command.withHandler((flags) => + runPeerCommand(flags, (client) => + Effect.gen(function* () { + const peer = yield* call( + "federation.addPeer", + client[WS_METHODS.federationAddPeer]({ + code: flags.code, + grantedScopes: uniqueScopesOrDefault(flags.grant), + }), + ); + yield* Console.log(formatPairedPeer(peer, { json: flags.json })); + }), + ), + ), +); + +const peerListCommand = Command.make("list", { + baseDir: baseDirFlag, + json: jsonFlag, +}).pipe( + Command.withDescription("List the servers this environment is paired with."), + Command.withHandler((flags) => + runPeerCommand(flags, (client) => + Effect.gen(function* () { + const snapshot = yield* call( + "federation.subscribePeers", + Stream.runHead(client[WS_METHODS.federationSubscribePeers]({})), + ); + if (Option.isNone(snapshot)) { + return yield* new RunningServerRequestError({ + operation: "federation.subscribePeers", + cause: "The server closed the peer subscription before sending a snapshot.", + }); + } + yield* Console.log(formatPeerList(snapshot.value, { json: flags.json })); + }), + ), + ), +); + +const peerRemoveCommand = Command.make("remove", { + baseDir: baseDirFlag, + peerId: peerIdArgument, +}).pipe( + Command.withDescription( + "Remove a peer. Its sessions here end and runs it delegated stop syncing.", + ), + Command.withHandler((flags) => + runPeerCommand(flags, (client) => + Effect.gen(function* () { + yield* call( + "federation.removePeer", + client[WS_METHODS.federationRemovePeer]({ peerId: flags.peerId }), + ); + yield* Console.log(`Removed peer ${flags.peerId}.`); + }), + ), + ), +); + +const peerProjectsCommand = Command.make("projects", { + baseDir: baseDirFlag, + peerId: peerIdArgument, + json: jsonFlag, +}).pipe( + Command.withDescription("List the projects a peer exposes."), + Command.withHandler((flags) => + runPeerCommand(flags, (client) => + Effect.gen(function* () { + const response = yield* call( + "federation.listRemoteProjects", + client[WS_METHODS.federationListRemoteProjects]({ peerId: flags.peerId }), + ); + yield* Console.log(formatRemoteProjects(response.projects, { json: flags.json })); + }), + ), + ), +); + +const peerRunCommand = Command.make("run", { + baseDir: baseDirFlag, + peerId: peerIdArgument, + projectId: Argument.string("project-id").pipe( + Argument.withDescription("Project on the peer, as listed by `t3 peer projects`."), + Argument.withSchema(ProjectId), + ), + prompt: Argument.string("prompt").pipe( + Argument.withDescription("Prompt for the run; several words are joined with spaces."), + Argument.withSchema(TrimmedNonEmptyString), + Argument.variadic({ min: 1 }), + ), + title: Flag.string("title").pipe( + Flag.withDescription("Optional thread title on the peer."), + Flag.optional, + ), + wait: Flag.boolean("wait").pipe( + Flag.withDescription("Follow the run and print its events until it finishes."), + Flag.withDefault(false), + ), + json: jsonFlag, +}).pipe( + Command.withDescription("Start a run on a peer's project."), + Command.withHandler((flags) => + runPeerCommand(flags, (client) => + Effect.gen(function* () { + const started = yield* call( + "federation.startRemoteRun", + client[WS_METHODS.federationStartRemoteRun]({ + peerId: flags.peerId, + projectId: flags.projectId, + prompt: flags.prompt.join(" "), + ...(Option.isSome(flags.title) ? { title: flags.title.value } : {}), + }), + ); + if (!flags.wait) { + yield* Console.log(formatRemoteRun(started, { json: flags.json })); + return; + } + + if (!flags.json) { + yield* Console.log( + `Started run ${started.run.threadId} on ${started.peerLabel} (${started.run.status}). Following until it finishes; Ctrl-C stops following, not the run.`, + ); + } + const final = yield* followRemoteRun(client, started, { json: flags.json }); + if (Option.isNone(final)) { + return yield* new FederationError({ + code: "run-not-found", + message: `The server stopped tracking run ${started.run.threadId} before it finished.`, + }); + } + yield* Console.log(formatRemoteRun(final.value, { json: flags.json })); + }), + ), + ), +); + +export const peerCommand = Command.make("peer").pipe( + Command.withDescription("Pair with other T3 Code servers and delegate runs to them."), + Command.withSubcommands([ + peerCodeCommand, + peerAddCommand, + peerListCommand, + peerRemoveCommand, + peerProjectsCommand, + peerRunCommand, + ]), +); diff --git a/apps/server/src/cli/remote.test.ts b/apps/server/src/cli/remote.test.ts new file mode 100644 index 000000000000..2b4130056585 --- /dev/null +++ b/apps/server/src/cli/remote.test.ts @@ -0,0 +1,478 @@ +// @effect-diagnostics nodeBuiltinImport:off - CLI integration exercises Node HTTP and filesystem boundaries. +import * as NodeHttp from "node:http"; +import * as NodeFS from "node:fs"; +import * as NodeOS from "node:os"; +import * as NodePath from "node:path"; + +import * as NodeHttpServer from "@effect/platform-node/NodeHttpServer"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { + EnvironmentTailcatHttpApi, + type TailcatConnectionCodeResult, + TailcatRemoteAccessError, + TailcatRemoteAccessState, +} from "@t3tools/contracts"; +import * as NetService from "@t3tools/shared/Net"; +import { assert, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; +import * as References from "effect/References"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as TestConsole from "effect/testing/TestConsole"; +import { Command } from "effect/unstable/cli"; +import * as CliError from "effect/unstable/cli/CliError"; +import * as HttpRouter from "effect/unstable/http/HttpRouter"; +import * as HttpServer from "effect/unstable/http/HttpServer"; +import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; +import * as HttpApi from "effect/unstable/httpapi/HttpApi"; +import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; + +import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; +import { environmentAuthenticatedAuthLayer } from "../auth/http.ts"; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import { cli } from "../bin.ts"; +import * as ServerConfig from "../config.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import { layerConfig as SqlitePersistenceLayerLive } from "../persistence/Layers/Sqlite.ts"; +import { + makePersistedServerRuntimeState, + persistServerRuntimeState, +} from "../serverRuntimeState.ts"; +import { tailcatHttpApiLayer } from "../tailcat/http.ts"; +import * as TailcatRemoteAccess from "../tailcat/TailcatRemoteAccess.ts"; +import { NoRunningServerError } from "./pair.ts"; +import { TailcatUnavailableError } from "./remote.ts"; + +const CliRuntimeLayer = Layer.mergeAll(NodeServices.layer, NetService.layer); + +const runCli = (args: ReadonlyArray) => Command.runWith(cli, { version: "0.0.0" })(args); + +const provideCliTestLayers = (effect: Effect.Effect) => + Effect.provide(effect, Layer.mergeAll(CliRuntimeLayer, TestConsole.layer)); + +// The test console is shared and accumulates across CLI runs, so each capture +// keeps only the entries its own run appended. +const captureNewLogLines = (args: ReadonlyArray) => + provideCliTestLayers( + Effect.gen(function* () { + const before = (yield* TestConsole.logLines).length; + yield* runCli(args); + return (yield* TestConsole.logLines) + .slice(before) + .filter((line): line is string => typeof line === "string"); + }), + ); + +/** Everything one CLI run logged, joined; commands may log more than once (`enable` adds a hint). */ +const captureStdout = (args: ReadonlyArray) => + Effect.map(captureNewLogLines(args), (lines) => lines.join("\n")); + +/** `--json` output has to be one clean entry: nothing logged before or after it. */ +const captureJson = (args: ReadonlyArray) => + Effect.map(captureNewLogLines(args), (lines) => { + assert.equal(lines.length, 1, `Expected exactly one JSON entry, got ${String(lines)}`); + return lines[0] ?? ""; + }); + +const flipCli = (args: ReadonlyArray) => + provideCliTestLayers(runCli(args).pipe(Effect.flip)); + +const expectShowHelpError = (error: unknown, expectedTag: string) => { + if (!CliError.isCliError(error) || error._tag !== "ShowHelp") { + assert.fail(`Expected ShowHelp, got ${String(error)}`); + } + assert.equal(error.errors[0]?._tag, expectedTag); +}; + +const makeTempBaseDir = (prefix: string) => + NodeFS.mkdtempSync(NodePath.join(NodeOS.tmpdir(), `t3-remote-cli-${prefix}-`)); + +const testDescriptor = { + environmentId: "remote-test-environment", + label: "remote-test", + platform: { os: "linux", arch: "x64" }, + serverVersion: "0.0.1", + capabilities: { repositoryIdentity: true }, +}; + +// Discovery probes the well-known descriptor before trusting runtime state; +// the tailcat API itself is the real handler layer over a scripted service. +const descriptorRouteLayer = HttpRouter.add( + "GET", + "/.well-known/t3/environment", + HttpServerResponse.jsonUnsafe(testDescriptor), +); + +class RemoteCliHttpApi extends HttpApi.make("environment").add(EnvironmentTailcatHttpApi) {} + +const makeCliTestServerConfig = (baseDir: string) => + Effect.gen(function* () { + const derivedPaths = yield* ServerConfig.deriveServerPaths(baseDir, undefined); + return { + logLevel: "Warn", + traceMinLevel: "Info", + traceTimingEnabled: false, + traceBatchWindowMs: 200, + traceMaxBytes: 10 * 1024 * 1024, + traceMaxFiles: 10, + otlpTracesUrl: undefined, + otlpMetricsUrl: undefined, + otlpExportIntervalMs: 10_000, + otlpServiceName: "t3-server", + mode: "web", + port: 0, + host: "127.0.0.1", + cwd: process.cwd(), + baseDir, + ...derivedPaths, + staticDir: undefined, + devUrl: undefined, + devAllowedOrigins: [], + noBrowser: true, + startupPresentation: "headless", + desktopBootstrapToken: undefined, + autoBootstrapProjectFromCwd: false, + logWebSocketEvents: false, + tailscaleServeEnabled: false, + tailscaleServePort: 443, + tailcatEnabled: undefined, + tailcatBinaryPath: undefined, + } satisfies ServerConfig.ServerConfig["Service"]; + }); + +const TAILCAT_ADDRESS = "tcAbCdEfGhIjKlMnOpQrStUv"; +const NODE_KEY = `nodekey:${"0123456789abcdef".repeat(4)}`; + +const readyState: TailcatRemoteAccessState = { + enabled: true, + status: "ready", + address: TAILCAT_ADDRESS, + remotePort: 3773, + pairingOpen: false, + trustedPeers: [ + { + id: "peer-phone", + nodeKey: NODE_KEY, + label: "Phone", + createdAt: "2026-06-20T00:00:00.000Z", + lastSeenAt: "2026-06-21T08:30:00.000Z", + sessionIds: [], + }, + ], + runtime: { + executablePath: "/opt/t3/tailcat", + source: "bundled", + version: "1.4.0", + pinnedVersion: "1.4.0", + compatible: true, + }, + identityFingerprint: "SHA256:remote-test", + lastError: null, + updatedAt: "2026-06-21T08:30:00.000Z", +}; + +const disabledState: TailcatRemoteAccessState = { + ...readyState, + enabled: false, + status: "disabled", + address: null, + remotePort: null, +}; + +const unavailableState: TailcatRemoteAccessState = { + ...disabledState, + status: "unavailable", + runtime: null, + lastError: { + code: "binary-missing", + message: "The tailcat binary was not found.", + at: "2026-06-21T08:30:00.000Z", + }, +}; + +const connectionCode: TailcatConnectionCodeResult = { + code: "t3c://tailcat/remote-test-code", + payload: { v: 1, transport: "tailcat", address: TAILCAT_ADDRESS, port: 3773 }, + pairingLinkId: "pairing-link-1", + expiresAt: "2026-06-21T08:35:00.000Z", +}; + +const makeScriptedRemoteAccess = (initial: TailcatRemoteAccessState) => + Effect.map(Ref.make(initial), (stateRef) => ({ + stateRef, + service: TailcatRemoteAccess.TailcatRemoteAccess.of({ + state: Ref.get(stateRef), + changes: Stream.empty, + readyEndpoint: Effect.succeed(Option.none()), + start: () => Effect.void, + setEnabled: (enabled) => + Ref.updateAndGet(stateRef, (state) => + enabled + ? { + ...state, + enabled: true, + status: "ready", + address: TAILCAT_ADDRESS, + remotePort: 3773, + } + : { ...state, enabled: false, status: "disabled", address: null, remotePort: null }, + ), + createConnectionCode: () => Effect.succeed(connectionCode), + recordTrustedPeer: () => Effect.void, + revokeTrustedPeer: (peerId) => + Effect.gen(function* () { + const current = yield* Ref.get(stateRef); + if (!current.trustedPeers.some((peer) => peer.id === peerId)) { + return yield* new TailcatRemoteAccessError({ + code: "unknown", + message: "That device is no longer in the trusted list.", + }); + } + return yield* Ref.updateAndGet(stateRef, (state) => ({ + ...state, + trustedPeers: state.trustedPeers.filter((peer) => peer.id !== peerId), + })); + }), + renameTrustedPeer: () => Ref.get(stateRef), + regenerateIdentity: Ref.get(stateRef), + }), + })); + +/** + * A server the CLI can discover: descriptor route, the real tailcat HTTP + * handlers over a scripted service, and the real auth middleware backed by + * the sqlite database the CLI mints its session into. + */ +const withLiveTailcatServer = ( + baseDir: string, + remoteAccess: TailcatRemoteAccess.TailcatRemoteAccess["Service"], + run: () => Effect.Effect, +) => + Effect.gen(function* () { + const config = yield* makeCliTestServerConfig(baseDir); + const routesLayer = Layer.mergeAll( + HttpApiBuilder.layer(RemoteCliHttpApi).pipe( + Layer.provide( + tailcatHttpApiLayer.pipe( + Layer.provide(Layer.succeed(TailcatRemoteAccess.TailcatRemoteAccess, remoteAccess)), + ), + ), + Layer.provide(environmentAuthenticatedAuthLayer), + ), + descriptorRouteLayer, + ); + const appLayer = HttpRouter.serve(routesLayer, { + disableListenLog: true, + disableLogger: true, + }).pipe( + Layer.provideMerge( + EnvironmentAuth.layer.pipe( + Layer.provideMerge(SqlitePersistenceLayerLive), + Layer.provide(ServerEnvironment.identityLayer), + Layer.provide(ServerSecretStore.layer), + ), + ), + Layer.provideMerge( + NodeHttpServer.layer(NodeHttp.createServer, { + host: "127.0.0.1", + port: 0, + }), + ), + Layer.provideMerge(NodeServices.layer), + Layer.provide(ServerConfig.layer(config)), + // The server shares the test console with the CLI under test; keep its + // own startup chatter out of the captured output. + Layer.provide(Layer.succeed(References.MinimumLogLevel, "Error")), + ); + + return yield* Effect.scoped( + Effect.gen(function* () { + const server = yield* HttpServer.HttpServer; + const address = server.address; + if (typeof address === "string" || !("port" in address)) { + return yield* Effect.die(new Error(`Expected TCP address, got ${String(address)}`)); + } + yield* persistServerRuntimeState({ + path: config.serverRuntimeStatePath, + state: yield* makePersistedServerRuntimeState({ config, port: address.port }), + }); + return yield* run(); + }).pipe(Effect.provide(Layer.mergeAll(appLayer, NodeServices.layer))), + ); + }); + +const decodeStateJson = Schema.decodeUnknownEffect(Schema.fromJsonString(TailcatRemoteAccessState)); +const isTailcatRemoteAccessError = Schema.is(TailcatRemoteAccessError); +const isTailcatUnavailableError = Schema.is(TailcatUnavailableError); +const isNoRunningServerError = Schema.is(NoRunningServerError); + +it.layer(NodeServices.layer)("t3 remote tailcat", (it) => { + it.effect("registers every tailcat subcommand", () => + Effect.gen(function* () { + const output = yield* captureStdout(["remote", "tailcat", "--help"]); + + for (const subcommand of ["status", "enable", "disable", "code", "peers", "revoke"]) { + assert.include(output, subcommand); + } + assert.include(output, "Manage Tailcat remote access on the running server."); + }), + ); + + it.effect("rejects a missing or blank peer id before contacting any server", () => + Effect.gen(function* () { + expectShowHelpError(yield* flipCli(["remote", "tailcat", "revoke"]), "MissingArgument"); + expectShowHelpError(yield* flipCli(["remote", "tailcat", "revoke", " "]), "InvalidValue"); + }), + ); + + it.effect("reports remote access state and trusted peers from the running server", () => + Effect.gen(function* () { + const baseDir = makeTempBaseDir("status"); + const remoteAccess = yield* makeScriptedRemoteAccess(readyState); + + yield* withLiveTailcatServer(baseDir, remoteAccess.service, () => + Effect.gen(function* () { + const status = yield* captureStdout([ + "remote", + "tailcat", + "status", + "--base-dir", + baseDir, + ]); + assert.include(status, "Tailcat remote access"); + assert.include(status, "Enabled: yes"); + assert.include(status, "Status: ready"); + assert.include(status, `Address: ${TAILCAT_ADDRESS}`); + assert.include(status, "Pairing window: closed"); + assert.include(status, "Runtime: bundled 1.4.0 (compatible) at /opt/t3/tailcat"); + assert.include(status, "Trusted peers: 1"); + assert.include(status, "Last error: none"); + + const json = yield* captureJson([ + "remote", + "tailcat", + "status", + "--base-dir", + baseDir, + "--json", + ]); + assert.deepEqual(yield* decodeStateJson(json), readyState); + + const peers = yield* captureStdout(["remote", "tailcat", "peers", "--base-dir", baseDir]); + assert.include(peers, "peer-phone (Phone)"); + assert.include(peers, "node key: 0123·4567·cdef"); + assert.include(peers, "created: 2026-06-20T00:00:00.000Z"); + assert.include(peers, "last seen: 2026-06-21T08:30:00.000Z"); + }), + ); + }), + ); + + it.effect("enables, mints a connection code, revokes a peer, and disables again", () => + Effect.gen(function* () { + const baseDir = makeTempBaseDir("toggle"); + const remoteAccess = yield* makeScriptedRemoteAccess(disabledState); + + yield* withLiveTailcatServer(baseDir, remoteAccess.service, () => + Effect.gen(function* () { + const enabled = yield* captureStdout([ + "remote", + "tailcat", + "enable", + "--base-dir", + baseDir, + ]); + assert.include(enabled, "Status: ready"); + assert.include(enabled, `Address: ${TAILCAT_ADDRESS}`); + assert.include(enabled, "Next: run `t3 remote tailcat code`"); + assert.isTrue((yield* Ref.get(remoteAccess.stateRef)).enabled); + + const code = yield* captureStdout([ + "remote", + "tailcat", + "code", + "--base-dir", + baseDir, + "--label", + "Laptop", + ]); + assert.include(code, "Connection code (expires 2026-06-21T08:35:00.000Z, single use):"); + assert.include(code, "t3c://tailcat/remote-test-code"); + assert.isTrue(code.includes("█") || code.includes("▀") || code.includes("▄")); + assert.include(code, "one-time pairing credential"); + + const revoked = yield* captureStdout([ + "remote", + "tailcat", + "revoke", + "peer-phone", + "--base-dir", + baseDir, + ]); + assert.include(revoked, "Revoked trusted peer peer-phone. 0 trusted peer(s) remain."); + + // The server's typed failure surfaces with its own wording. + const revokedAgain = yield* flipCli([ + "remote", + "tailcat", + "revoke", + "peer-phone", + "--base-dir", + baseDir, + ]); + if (!isTailcatRemoteAccessError(revokedAgain)) { + assert.fail(`Expected TailcatRemoteAccessError, got ${String(revokedAgain)}`); + } + assert.equal(revokedAgain.message, "That device is no longer in the trusted list."); + + const disabled = yield* captureStdout([ + "remote", + "tailcat", + "disable", + "--base-dir", + baseDir, + ]); + assert.include(disabled, "Tailcat remote access is disabled."); + assert.isFalse((yield* Ref.get(remoteAccess.stateRef)).enabled); + }), + ); + }), + ); + + it.effect("fails with the binary override hint when the server reports Tailcat unavailable", () => + Effect.gen(function* () { + const baseDir = makeTempBaseDir("unavailable"); + const remoteAccess = yield* makeScriptedRemoteAccess(unavailableState); + + yield* withLiveTailcatServer(baseDir, remoteAccess.service, () => + Effect.gen(function* () { + const error = yield* flipCli(["remote", "tailcat", "status", "--base-dir", baseDir]); + + if (!isTailcatUnavailableError(error)) { + assert.fail(`Expected TailcatUnavailableError, got ${String(error)}`); + } + assert.equal(error.code, "binary-missing"); + assert.include(error.message, "The tailcat binary was not found."); + assert.include(error.message, "T3CODE_TAILCAT_BINARY"); + }), + ); + }), + ); + + it.effect("directs to t3 serve when no server is running", () => + Effect.gen(function* () { + const baseDir = makeTempBaseDir("none"); + + const error = yield* flipCli(["remote", "tailcat", "status", "--base-dir", baseDir]); + + if (!isNoRunningServerError(error)) { + assert.fail(`Expected NoRunningServerError, got ${String(error)}`); + } + assert.include(error.message, "No running T3 Code server found."); + assert.include(error.message, "npx t3 serve"); + }), + ); +}); diff --git a/apps/server/src/cli/remote.ts b/apps/server/src/cli/remote.ts new file mode 100644 index 000000000000..f6ed3cafe469 --- /dev/null +++ b/apps/server/src/cli/remote.ts @@ -0,0 +1,564 @@ +/** + * `t3 remote tailcat ` - manage Tailcat remote access on the + * running T3 Code server: status, enable/disable, connection codes, and the + * trusted device list. + * + * Discovery and credentials mirror `t3 pair`: the running server is found + * through the runtime state it persists next to its database, and every + * invocation mints a short-lived administrative session in that database, + * revoked when the command finishes. Calls go over the server's HTTP API, + * which exists for exactly this purpose; the UIs drive the same service over + * RPC. + */ +import { + AuthAdministrativeScopes, + EnvironmentAuthorizationError, + EnvironmentHttpApi, + EnvironmentHttpCommonError, + type TailcatConnectionCodeResult, + type TailcatCreateConnectionCodeInput, + TailcatFailureCode, + TailcatRemoteAccessError, + type TailcatRemoteAccessState, + TailcatServeStatus, + type TailcatTrustedPeer, + TrimmedNonEmptyString, + tailcatNodeKeyFingerprint, +} from "@t3tools/contracts"; +import { TAILCAT_BINARY_OVERRIDE_ENV } from "@t3tools/tailcat/runtime"; +import * as Cause from "effect/Cause"; +import * as Console from "effect/Console"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as References from "effect/References"; +import * as Schedule from "effect/Schedule"; +import * as Schema from "effect/Schema"; +import { Argument, Command, Flag, GlobalFlag } from "effect/unstable/cli"; +import { FetchHttpClient } from "effect/unstable/http"; +import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient"; +import { RpcClientError } from "effect/unstable/rpc"; + +import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; +import * as ServerConfig from "../config.ts"; +import { formatTailcatConnectionCodeLines } from "../tailcat/startupOutput.ts"; +import { baseDirFlag, DurationFromString, jsonFlag } from "./config.ts"; +import { type DiscoveredPairTarget, discoverPairTarget, makePairServerConfig } from "./pair.ts"; + +/** + * Bound for one unary call against the running server: generous for a busy + * disk, short enough that a wedged server does not hang the terminal. + */ +export const RUNNING_SERVER_REQUEST_TIMEOUT = Duration.seconds(10); + +// Enabling starts the tailcat process and waits for it to report an address; +// a cold start with a DERP handshake is a few seconds, so poll for up to 30s. +const ENABLE_POLL_INTERVAL = Duration.millis(500); +const ENABLE_POLL_ATTEMPTS = 60; + +const isEnvironmentHttpCommonError = Schema.is(EnvironmentHttpCommonError); +const isEnvironmentAuthorizationError = Schema.is(EnvironmentAuthorizationError); +const isRpcClientError = Schema.is(RpcClientError.RpcClientError); +const isTailcatRemoteAccessError = Schema.is(TailcatRemoteAccessError); + +/** Failure codes that mean the tailcat binary itself is the problem, not this server's state. */ +const TAILCAT_RUNTIME_FAILURE_CODES: ReadonlySet = new Set([ + "binary-missing", + "binary-not-executable", + "version-incompatible", +]); + +/** The running server plus the administrative session minted for one CLI invocation. */ +export interface RunningServerSession { + readonly target: DiscoveredPairTarget; + /** Origin the server listens on; HTTP and the RPC WebSocket both live here. */ + readonly origin: string; + readonly token: string; +} + +/** + * Anything the running server answered with that is not a typed Tailcat or + * federation failure: rejected credentials, an internal error, a transport + * failure, or no answer at all. The cause stays attached for logs. + */ +export class RunningServerRequestError extends Schema.TaggedErrorClass()( + "RunningServerRequestError", + { + operation: Schema.String, + cause: Schema.Defect(), + }, +) { + override get message(): string { + const cause = this.cause; + if (isEnvironmentHttpCommonError(cause) || isEnvironmentAuthorizationError(cause)) { + return `The running server rejected ${this.operation}: ${cause.message}`; + } + if (Cause.isTimeoutError(cause)) { + return `The running server did not answer ${this.operation} within ${Duration.format(RUNNING_SERVER_REQUEST_TIMEOUT)}.`; + } + if (isRpcClientError(cause)) { + return `Lost the connection to the running server during ${this.operation}.`; + } + return `Failed to call the running server (${this.operation}).`; + } +} + +export class TailcatUnavailableError extends Schema.TaggedErrorClass()( + "TailcatUnavailableError", + { + code: TailcatFailureCode, + detail: Schema.String, + }, +) { + override get message(): string { + return [ + `Tailcat is unavailable on this server (${this.code}): ${this.detail}`, + `Install tailcat and point ${TAILCAT_BINARY_OVERRIDE_ENV} at the binary, or reinstall T3 Code to restore the bundled runtime.`, + ].join("\n"); + } +} + +export class TailcatNotReadyError extends Schema.TaggedErrorClass()( + "TailcatNotReadyError", + { + status: TailcatServeStatus, + detail: Schema.String, + }, +) { + override get message(): string { + return `Tailcat remote access did not become ready (${this.status}): ${this.detail}`; + } +} + +/** + * Discover the running server, mint an administrative session in its database + * and run `run` with it. The session is revoked on the way out, including on + * interruption, so a Ctrl-C leaves nothing behind. + */ +export const withRunningServerSession = Effect.fn("remote.withRunningServerSession")(function* < + A, + E, + R, +>(input: { + readonly baseDir: Option.Option; + readonly label: string; + /** Machine-readable output must stay parseable, so `--json` raises the log floor to Error. */ + readonly quietLogs: boolean; + readonly run: (session: RunningServerSession) => Effect.Effect; +}) { + const cliLogLevel = yield* GlobalFlag.LogLevel; + // Default to Warn so storage/migration chatter cannot bury the output; an + // explicit --log-level still wins unless the output has to be JSON. + const logLevel = input.quietLogs + ? ("Error" as const) + : Option.getOrElse(cliLogLevel, () => "Warn" as const); + const target = yield* discoverPairTarget(Option.getOrUndefined(input.baseDir)); + const config = yield* makePairServerConfig({ target, logLevel }); + + return yield* Effect.gen(function* () { + const environmentAuth = yield* EnvironmentAuth.EnvironmentAuth; + return yield* Effect.acquireUseRelease( + environmentAuth.issueSession({ scopes: AuthAdministrativeScopes, label: input.label }), + (issued) => input.run({ target, origin: target.state.origin, token: issued.token }), + (issued) => + environmentAuth.revokeSession(issued.sessionId).pipe(Effect.ignore({ log: true })), + ); + }).pipe( + Effect.provide( + EnvironmentAuth.runtimeLayer.pipe( + Layer.provide(ServerConfig.layer(config)), + Layer.provide(Layer.succeed(References.MinimumLogLevel, logLevel)), + ), + ), + ); +}); + +type TailcatCliError = + | TailcatRemoteAccessError + | TailcatUnavailableError + | RunningServerRequestError; + +interface TailcatApi { + readonly state: Effect.Effect; + readonly setEnabled: ( + enabled: boolean, + ) => Effect.Effect; + readonly createConnectionCode: ( + input: TailcatCreateConnectionCodeInput, + ) => Effect.Effect; + readonly revokeTrustedPeer: ( + peerId: string, + ) => Effect.Effect; +} + +// A missing or incompatible binary is the one Tailcat failure the user fixes +// on their own machine, so it gets the override hint; everything else is +// already worded for them by the server. +const tailcatCliErrorFromServer = ( + error: TailcatRemoteAccessError, +): TailcatRemoteAccessError | TailcatUnavailableError => + TAILCAT_RUNTIME_FAILURE_CODES.has(error.code) + ? new TailcatUnavailableError({ code: error.code, detail: error.message }) + : error; + +const tailcatUnavailableFromState = ( + state: TailcatRemoteAccessState, +): Option.Option => { + if (state.status !== "unavailable") { + return Option.none(); + } + const runtimeDetail = + state.runtime !== null && !state.runtime.compatible + ? `tailcat ${state.runtime.version} at ${state.runtime.executablePath} is not compatible with this server (wants ${state.runtime.pinnedVersion}).` + : "The Tailcat runtime is not available."; + return Option.some( + new TailcatUnavailableError({ + code: state.lastError?.code ?? "unknown", + detail: state.lastError?.message ?? runtimeDetail, + }), + ); +}; + +/** + * Bounds a request to the running server and wraps anything that is not a + * typed server error (authorization, transport, no answer) so the user sees + * one consistent failure shape. + */ +export const callRunningServer = ( + operation: string, + request: Effect.Effect, + isTyped: (cause: E) => cause is T, +): Effect.Effect => + request.pipe( + Effect.timeout(RUNNING_SERVER_REQUEST_TIMEOUT), + Effect.mapError((cause) => + cause instanceof Error && !isTyped(cause as E) + ? new RunningServerRequestError({ operation, cause }) + : isTyped(cause as E) + ? (cause as T) + : new RunningServerRequestError({ operation, cause }), + ), + ); + +const makeTailcatApi = Effect.fn("remote.makeTailcatApi")(function* ( + session: RunningServerSession, +) { + const client = yield* HttpApiClient.make(EnvironmentHttpApi, { baseUrl: session.origin }); + const headers = { authorization: `Bearer ${session.token}` }; + const call = (operation: string, request: Effect.Effect) => + callRunningServer(operation, request, isTailcatRemoteAccessError).pipe( + Effect.mapError((cause) => + isTailcatRemoteAccessError(cause) ? tailcatCliErrorFromServer(cause) : cause, + ), + ); + + return { + state: call("tailcat.remoteAccess", client.tailcat.remoteAccess({ headers })), + setEnabled: (enabled) => + call( + "tailcat.setRemoteAccess", + client.tailcat.setRemoteAccess({ headers, payload: { enabled } }), + ), + createConnectionCode: (payload) => + call( + "tailcat.createConnectionCode", + client.tailcat.createConnectionCode({ headers, payload }), + ), + revokeTrustedPeer: (peerId) => + call( + "tailcat.revokeTrustedPeer", + client.tailcat.revokeTrustedPeer({ headers, payload: { peerId } }), + ), + } satisfies TailcatApi; +}); + +const runTailcatCommand = ( + flags: { readonly baseDir: Option.Option; readonly json?: boolean }, + run: (api: TailcatApi) => Effect.Effect, +) => + withRunningServerSession({ + baseDir: flags.baseDir, + label: "t3 remote tailcat", + quietLogs: flags.json === true, + run: (session) => Effect.flatMap(makeTailcatApi(session), run), + }).pipe(Effect.provide(FetchHttpClient.layer)); + +export const nodeKeyFingerprint = tailcatNodeKeyFingerprint; + +const formatRuntime = (state: TailcatRemoteAccessState): string => { + if (state.runtime === null) { + return "not detected"; + } + const compatibility = state.runtime.compatible + ? "compatible" + : `incompatible, wants ${state.runtime.pinnedVersion}`; + return `${state.runtime.source} ${state.runtime.version} (${compatibility}) at ${state.runtime.executablePath}`; +}; + +export const formatTailcatStatus = ( + state: TailcatRemoteAccessState, + options: { readonly json: boolean }, +): string => { + if (options.json) { + return JSON.stringify(state, null, 2); + } + const lastError = + state.lastError === null + ? "none" + : `${state.lastError.message} (${state.lastError.code}, ${state.lastError.at})`; + return [ + "Tailcat remote access", + ` Enabled: ${state.enabled ? "yes" : "no"}`, + ` Status: ${state.status}`, + ` Address: ${state.address ?? "none"}`, + ` Remote port: ${state.remotePort === null ? "none" : String(state.remotePort)}`, + ` Pairing window: ${state.pairingOpen ? "open (a connection code is active)" : "closed"}`, + ` Runtime: ${formatRuntime(state)}`, + ` Identity: ${state.identityFingerprint ?? "none"}`, + ` Trusted peers: ${String(state.trustedPeers.length)}`, + ` Last error: ${lastError}`, + ].join("\n"); +}; + +export const formatTrustedPeers = ( + peers: ReadonlyArray, + options: { readonly json: boolean }, +): string => { + if (options.json) { + return JSON.stringify( + peers.map((peer) => ({ + id: peer.id, + label: peer.label, + nodeKeyFingerprint: nodeKeyFingerprint(peer.nodeKey), + createdAt: peer.createdAt, + lastSeenAt: peer.lastSeenAt, + })), + null, + 2, + ); + } + if (peers.length === 0) { + return "No trusted peers."; + } + return peers + .map((peer) => + [ + `${peer.id} (${peer.label})`, + ` node key: ${nodeKeyFingerprint(peer.nodeKey)}`, + ` created: ${peer.createdAt}`, + ` last seen: ${peer.lastSeenAt ?? "never"}`, + ].join("\n"), + ) + .join("\n\n"); +}; + +// Same shape as the `t3 serve --tailcat` startup output, so the code reads +// the same wherever the user sees it. +export const formatConnectionCode = ( + issued: TailcatConnectionCodeResult, + options: { readonly json: boolean }, +): string => { + if (options.json) { + return JSON.stringify(issued, null, 2); + } + return formatTailcatConnectionCodeLines(issued).join("\n"); +}; + +// Right after enabling, the service still reports "disabled" until its +// reconcile debounce fires, so an enabled-but-disabled state is not settled. +const isSettledTailcatState = (state: TailcatRemoteAccessState): boolean => + state.status !== "starting" && + state.status !== "restarting" && + !(state.enabled && state.status === "disabled"); + +const awaitSettledTailcatState = (api: TailcatApi) => + api.state.pipe( + Effect.repeat({ + schedule: Schedule.max([ + Schedule.spaced(ENABLE_POLL_INTERVAL), + Schedule.recurs(ENABLE_POLL_ATTEMPTS), + ]), + until: isSettledTailcatState, + }), + ); + +const tailcatStatusCommand = Command.make("status", { + baseDir: baseDirFlag, + json: jsonFlag, +}).pipe( + Command.withDescription("Show Tailcat remote access state on the running server."), + Command.withHandler((flags) => + runTailcatCommand(flags, (api) => + Effect.gen(function* () { + const state = yield* api.state; + yield* Console.log(formatTailcatStatus(state, { json: flags.json })); + const unavailable = tailcatUnavailableFromState(state); + if (Option.isSome(unavailable)) { + return yield* unavailable.value; + } + }), + ), + ), +); + +const tailcatEnableCommand = Command.make("enable", { + baseDir: baseDirFlag, + json: jsonFlag, +}).pipe( + Command.withDescription( + "Enable Tailcat remote access and wait until the listener is ready or has failed.", + ), + Command.withHandler((flags) => + runTailcatCommand(flags, (api) => + Effect.gen(function* () { + const enabled = yield* api.setEnabled(true); + const settled = isSettledTailcatState(enabled) + ? enabled + : yield* awaitSettledTailcatState(api); + yield* Console.log(formatTailcatStatus(settled, { json: flags.json })); + + const unavailable = tailcatUnavailableFromState(settled); + if (Option.isSome(unavailable)) { + return yield* unavailable.value; + } + switch (settled.status) { + case "ready": + if (!flags.json) { + yield* Console.log( + "\nNext: run `t3 remote tailcat code` to pair a device through this address.", + ); + } + return; + case "error": + return yield* new TailcatNotReadyError({ + status: settled.status, + detail: settled.lastError?.message ?? "The server reported an error.", + }); + case "disabled": + return yield* new TailcatNotReadyError({ + status: settled.status, + detail: settled.enabled + ? "The listener has not started yet; check `t3 remote tailcat status` in a moment." + : "Remote access was disabled again before the listener came up.", + }); + case "starting": + case "restarting": + return yield* new TailcatNotReadyError({ + status: settled.status, + detail: `still ${settled.status} after ${Duration.format( + Duration.times(ENABLE_POLL_INTERVAL, ENABLE_POLL_ATTEMPTS), + )}; check \`t3 remote tailcat status\` in a moment.`, + }); + case "unavailable": + // Handled above; kept so the switch stays exhaustive. + return; + } + }), + ), + ), +); + +const tailcatDisableCommand = Command.make("disable", { + baseDir: baseDirFlag, + json: jsonFlag, +}).pipe( + Command.withDescription("Disable Tailcat remote access on the running server."), + Command.withHandler((flags) => + runTailcatCommand(flags, (api) => + Effect.gen(function* () { + const state = yield* api.setEnabled(false); + yield* Console.log( + flags.json + ? formatTailcatStatus(state, { json: true }) + : "Tailcat remote access is disabled. Trusted devices keep their entries and reconnect once it is enabled again.", + ); + }), + ), + ), +); + +const tailcatCodeCommand = Command.make("code", { + baseDir: baseDirFlag, + label: Flag.string("label").pipe( + Flag.withDescription("Optional label for the device that will redeem the code."), + Flag.optional, + ), + ttl: Flag.string("ttl").pipe( + Flag.withSchema(DurationFromString), + Flag.withDescription( + "How long the code stays redeemable, for example `5m` or `1h`. Defaults to 5 minutes.", + ), + Flag.optional, + ), + json: jsonFlag, +}).pipe( + Command.withDescription("Create a one-time Tailcat connection code and print it as a QR code."), + Command.withHandler((flags) => + runTailcatCommand(flags, (api) => + Effect.gen(function* () { + const issued = yield* api.createConnectionCode({ + ...(Option.isSome(flags.label) ? { label: flags.label.value } : {}), + ...(Option.isSome(flags.ttl) + ? { ttlSeconds: Math.max(1, Math.round(Duration.toSeconds(flags.ttl.value))) } + : {}), + }); + yield* Console.log(formatConnectionCode(issued, { json: flags.json })); + }), + ), + ), +); + +const tailcatPeersCommand = Command.make("peers", { + baseDir: baseDirFlag, + json: jsonFlag, +}).pipe( + Command.withDescription("List the devices trusted to reach this server over Tailcat."), + Command.withHandler((flags) => + runTailcatCommand(flags, (api) => + Effect.gen(function* () { + const state = yield* api.state; + yield* Console.log(formatTrustedPeers(state.trustedPeers, { json: flags.json })); + }), + ), + ), +); + +const tailcatRevokeCommand = Command.make("revoke", { + baseDir: baseDirFlag, + peerId: Argument.string("peer-id").pipe( + Argument.withDescription("Trusted peer id to revoke, as listed by `peers`."), + Argument.withSchema(TrimmedNonEmptyString), + ), +}).pipe( + Command.withDescription( + "Revoke a trusted device. Its Tailcat access and the sessions it paired with end together.", + ), + Command.withHandler((flags) => + runTailcatCommand(flags, (api) => + Effect.gen(function* () { + const state = yield* api.revokeTrustedPeer(flags.peerId); + yield* Console.log( + `Revoked trusted peer ${flags.peerId}. ${String(state.trustedPeers.length)} trusted peer(s) remain.`, + ); + }), + ), + ), +); + +const tailcatCommand = Command.make("tailcat").pipe( + Command.withDescription("Manage Tailcat remote access on the running server."), + Command.withSubcommands([ + tailcatStatusCommand, + tailcatEnableCommand, + tailcatDisableCommand, + tailcatCodeCommand, + tailcatPeersCommand, + tailcatRevokeCommand, + ]), +); + +export const remoteCommand = Command.make("remote").pipe( + Command.withDescription("Manage how remote devices reach the running T3 Code server."), + Command.withSubcommands([tailcatCommand]), +); diff --git a/apps/server/src/config.ts b/apps/server/src/config.ts index 42df3814b070..361d3a43c0f1 100644 --- a/apps/server/src/config.ts +++ b/apps/server/src/config.ts @@ -88,6 +88,10 @@ export class ServerConfig extends Context.Service< readonly logWebSocketEvents: boolean; readonly tailscaleServeEnabled: boolean; readonly tailscaleServePort: number; + /** Enable Tailcat remote access at startup (`t3 serve --tailcat`). */ + readonly tailcatEnabled: boolean | undefined; + /** Tailcat executable handed over by the desktop app's bootstrap. */ + readonly tailcatBinaryPath: string | undefined; } >()("t3/config/ServerConfig") { /** @deprecated Import and use `layerTest` from this module. */ @@ -200,6 +204,8 @@ const makeTest = Effect.fn("ServerConfig.makeTest")(function* ( logWebSocketEvents: false, tailscaleServeEnabled: false, tailscaleServePort: 443, + tailcatEnabled: undefined, + tailcatBinaryPath: undefined, port: 0, host: undefined, desktopBootstrapToken: undefined, diff --git a/apps/server/src/environment/ServerEnvironment.test.ts b/apps/server/src/environment/ServerEnvironment.test.ts index 91895fd5dcfc..7bd2b4218e12 100644 --- a/apps/server/src/environment/ServerEnvironment.test.ts +++ b/apps/server/src/environment/ServerEnvironment.test.ts @@ -61,6 +61,8 @@ const makeServerConfig = Effect.fn(function* (baseDir: string) { logWebSocketEvents: false, tailscaleServeEnabled: false, tailscaleServePort: 443, + tailcatEnabled: undefined, + tailcatBinaryPath: undefined, port: 0, host: undefined, desktopBootstrapToken: undefined, diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 1010011e90cd..05dec167f521 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -1,5 +1,6 @@ import { EnvironmentId, + FEDERATION_PROTOCOL_VERSION, PROVIDER_SEND_TURN_MAX_FILE_BYTES, type ExecutionEnvironmentDescriptor, } from "@t3tools/contracts"; @@ -226,6 +227,8 @@ export const make = Effect.gen(function* () { threadTitleRegeneration: true, threadPullRequestLinking: true, environmentIcon: true, + tailcatRemoteAccess: true, + federation: { protocolVersion: FEDERATION_PROTOCOL_VERSION }, ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), ...(serverSelfUpdate === "boot-service" || desktopAppUpdate ? { diff --git a/apps/server/src/federation/FederationIdentity.ts b/apps/server/src/federation/FederationIdentity.ts new file mode 100644 index 000000000000..600c8db96e15 --- /dev/null +++ b/apps/server/src/federation/FederationIdentity.ts @@ -0,0 +1,130 @@ +import { FEDERATION_AUTH_JWT_TYP, type EnvironmentId } from "@t3tools/contracts"; +import { signRelayJwt, verifyRelayJwt } from "@t3tools/shared/relayJwt"; +import * as NodeCrypto from "node:crypto"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Schema from "effect/Schema"; + +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import { getOrCreateEnvironmentKeyPairFromSecretStore } from "../cloud/environmentKeys.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; + +/** + * The environment's federation identity is its existing Ed25519 key pair from + * the secret store (also used for T3 Connect link proofs). Reusing it keeps one + * stable cryptographic identity per environment instead of a second key system. + */ +export const FEDERATION_ASSERTION_MAX_AGE_SECONDS = 120; + +export class FederationIdentity extends Context.Service< + FederationIdentity, + { + readonly environmentId: EnvironmentId; + /** SPKI PEM. Safe to share; it is what peers pin. */ + readonly publicKey: string; + readonly fingerprint: string; + /** Signs a challenge for `audience`, proving control of this environment's key. */ + readonly signChallenge: (input: { + readonly audience: EnvironmentId; + readonly challenge: string; + }) => Effect.Effect; + /** + * Verifies a peer's signed assertion against the public key pinned for it + * and returns the challenge it answers, for the caller to match against + * the challenges it issued. + */ + readonly verifyChallenge: (input: { + readonly assertion: string; + readonly issuer: EnvironmentId; + readonly publicKey: string; + }) => Effect.Effect; + } +>()("t3/federation/FederationIdentity") {} + +export class FederationIdentitySignError extends Schema.TaggedErrorClass()( + "FederationIdentitySignError", + { cause: Schema.Defect() }, +) { + override get message(): string { + return "Could not sign the federation challenge."; + } +} + +export class FederationIdentityVerifyError extends Schema.TaggedErrorClass()( + "FederationIdentityVerifyError", + { + reason: Schema.Literals(["signature", "challenge"]), + cause: Schema.optionalKey(Schema.Defect()), + }, +) { + override get message(): string { + return this.reason === "challenge" + ? "The federation assertion does not answer the issued challenge." + : "The federation assertion signature is invalid."; + } +} + +export function federationKeyFingerprint(publicKeyPem: string): string { + const normalized = publicKeyPem.replace(/\\n/gu, "\n").trim(); + const hex = NodeCrypto.createHash("sha256").update(normalized).digest("hex").slice(0, 16); + return `${hex.slice(0, 4)}·${hex.slice(4, 8)}·${hex.slice(8, 12)}·${hex.slice(12, 16)}`; +} + +export const make = Effect.gen(function* () { + const secrets = yield* ServerSecretStore.ServerSecretStore; + const environment = yield* ServerEnvironment.ServerEnvironment; + const environmentId = yield* environment.getEnvironmentId; + const keyPair = yield* getOrCreateEnvironmentKeyPairFromSecretStore(secrets); + + const signChallenge: FederationIdentity["Service"]["signChallenge"] = ({ audience, challenge }) => + DateTime.now.pipe( + Effect.flatMap((now) => { + const iat = Math.floor(DateTime.toEpochMillis(now) / 1000); + return signRelayJwt({ + privateKey: keyPair.privateKey, + typ: FEDERATION_AUTH_JWT_TYP, + payload: { + iss: environmentId, + aud: audience, + jti: challenge, + iat, + exp: iat + FEDERATION_ASSERTION_MAX_AGE_SECONDS, + }, + }); + }), + Effect.mapError((cause) => new FederationIdentitySignError({ cause })), + ); + + const verifyChallenge: FederationIdentity["Service"]["verifyChallenge"] = (input) => + DateTime.now.pipe( + Effect.flatMap((now) => + verifyRelayJwt({ + publicKey: input.publicKey, + token: input.assertion, + typ: FEDERATION_AUTH_JWT_TYP, + issuer: input.issuer, + audience: environmentId, + nowEpochSeconds: Math.floor(DateTime.toEpochMillis(now) / 1000), + maxTokenAge: `${FEDERATION_ASSERTION_MAX_AGE_SECONDS} seconds`, + }), + ), + Effect.mapError((cause) => new FederationIdentityVerifyError({ reason: "signature", cause })), + Effect.flatMap((payload) => + typeof payload.jti === "string" && payload.jti.length > 0 + ? Effect.succeed(payload.jti) + : Effect.fail(new FederationIdentityVerifyError({ reason: "challenge" })), + ), + ); + + return FederationIdentity.of({ + environmentId, + publicKey: keyPair.publicKey, + fingerprint: federationKeyFingerprint(keyPair.publicKey), + signChallenge, + verifyChallenge, + }); +}); + +export const layer = Layer.effect(FederationIdentity, make); diff --git a/apps/server/src/federation/FederationPeerStore.test.ts b/apps/server/src/federation/FederationPeerStore.test.ts new file mode 100644 index 000000000000..a2a972577e7f --- /dev/null +++ b/apps/server/src/federation/FederationPeerStore.test.ts @@ -0,0 +1,101 @@ +import { ThreadId } from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { assert, describe, it } from "@effect/vitest"; +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 ServerConfig from "../config.ts"; +import * as FederationPeerStore from "./FederationPeerStore.ts"; + +const epochMs = (iso: string) => DateTime.toEpochMillis(DateTime.makeUnsafe(iso)); + +/** + * Runs `body` against a fresh state directory. Every `FederationPeerStore.make` + * inside it is a new store instance over the same files, which is exactly what + * a server restart looks like to the store. + */ +const withStateDir = ( + body: Effect.Effect, +) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const baseDir = yield* fs.makeTempDirectoryScoped({ prefix: "t3code-federation-store-" }); + return yield* Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + yield* fs.makeDirectory(config.stateDir, { recursive: true }); + return yield* body; + }).pipe( + Effect.provide( + ServerConfig.layerTest(process.cwd(), baseDir).pipe(Layer.provideMerge(NodeServices.layer)), + ), + ); + }).pipe(Effect.scoped, Effect.provide(NodeServices.layer)); + +describe("FederationPeerStore pending peer codes", () => { + it.effect("offered codes survive a restart and settle on redemption or expiry", () => + withStateDir( + Effect.gen(function* () { + const before = yield* FederationPeerStore.make; + yield* before.addPendingPeerCode({ + linkId: "link-redeemed", + scopes: ["projects.read", "runs.start"], + expiresAt: "2026-09-04T00:10:00.000Z", + }); + yield* before.addPendingPeerCode({ + linkId: "link-expired", + scopes: ["environment.read"], + expiresAt: "2026-09-04T00:00:30.000Z", + }); + yield* before.addPendingPeerCode({ + linkId: "link-live", + scopes: ["runs.read"], + expiresAt: "2026-09-04T00:10:00.000Z", + }); + + const restarted = yield* FederationPeerStore.make; + assert.deepEqual( + (yield* restarted.pendingPeerCodes).map((code) => code.linkId), + ["link-redeemed", "link-expired", "link-live"], + ); + + yield* restarted.settlePendingPeerCodes({ + redeemedLinkId: "link-redeemed", + nowMs: epochMs("2026-09-04T00:01:00.000Z"), + }); + assert.deepEqual(yield* restarted.pendingPeerCodes, [ + { linkId: "link-live", scopes: ["runs.read"], expiresAt: "2026-09-04T00:10:00.000Z" }, + ]); + + const again = yield* FederationPeerStore.make; + assert.deepEqual( + (yield* again.pendingPeerCodes).map((code) => code.linkId), + ["link-live"], + ); + }), + ), + ); + + it.effect("state files written before pending codes existed still load", () => + withStateDir( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const config = yield* ServerConfig.ServerConfig; + yield* fs.writeFileString( + path.join(config.stateDir, FederationPeerStore.FEDERATION_STATE_FILE), + `{"version":1,"peers":[],"remoteRuns":[],"inboundRuns":[{"threadId":"thread-1","peerId":"environment-test","createdAt":"2026-09-03T00:00:00.000Z"}]}`, + ); + + const store = yield* FederationPeerStore.make; + assert.deepEqual(yield* store.pendingPeerCodes, []); + assert.deepEqual( + (yield* store.inboundRuns).map((run) => run.threadId), + [ThreadId.make("thread-1")], + ); + }), + ), + ); +}); diff --git a/apps/server/src/federation/FederationPeerStore.ts b/apps/server/src/federation/FederationPeerStore.ts new file mode 100644 index 000000000000..7b28cfda0881 --- /dev/null +++ b/apps/server/src/federation/FederationPeerStore.ts @@ -0,0 +1,299 @@ +import { + EnvironmentId, + FederationCapability, + type FederationPeer, + type FederationPeerStatus, + FederationRun, + FederationScopes, + FederationTransport, + IsoDateTime, + ThreadId, + TrimmedNonEmptyString, + FederationScope, +} from "@t3tools/contracts"; +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 Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Semaphore from "effect/Semaphore"; + +import { writeFileStringAtomically } from "../atomicWrite.ts"; +import * as ServerConfig from "../config.ts"; +import { federationKeyFingerprint } from "./FederationIdentity.ts"; + +/** + * Durable federation state: the peers this environment trusts, the runs it + * started on peers, and the runs peers started here. Pinned public keys live + * here in plain JSON on purpose: they are public, and pinning them is what + * makes a peer's identity stable across relabels and transport changes. + */ +export const FEDERATION_STATE_FILE = "federation.json"; + +export const PersistedFederationPeer = Schema.Struct({ + peerId: EnvironmentId, + label: TrimmedNonEmptyString, + publicKey: TrimmedNonEmptyString, + grantedScopes: FederationScopes, + allowedScopes: FederationScopes, + transport: Schema.NullOr(FederationTransport), + remoteServerVersion: Schema.NullOr(TrimmedNonEmptyString), + remoteProtocolVersion: Schema.NullOr(Schema.Int), + remoteCapabilities: Schema.Array(FederationCapability), + createdAt: IsoDateTime, + lastSeenAt: Schema.NullOr(IsoDateTime), +}); +export type PersistedFederationPeer = typeof PersistedFederationPeer.Type; + +/** A run a peer started here; only that peer may observe it. */ +export const PersistedInboundRun = Schema.Struct({ + threadId: ThreadId, + peerId: EnvironmentId, + createdAt: IsoDateTime, + /** Event-log sequence just before the run was created; its events all follow it. */ + startSequence: Schema.optionalKey(Schema.Int), +}); +export type PersistedInboundRun = typeof PersistedInboundRun.Type; + +/** + * A run this environment started on a peer. Event tails and sync bookkeeping + * are re-fetchable, so they stay in memory; only identity and the last known + * projection are written to disk. + */ +export const PersistedRemoteRun = Schema.Struct({ + peerId: EnvironmentId, + peerLabel: TrimmedNonEmptyString, + run: FederationRun, +}); +export type PersistedRemoteRun = typeof PersistedRemoteRun.Type; + +/** + * A peer code this environment offered and has not yet seen redeemed. The + * pairing token itself lives in the pairing-link store; this records which + * federation scopes the code promises, so a restart cannot orphan a live code. + */ +export const PersistedPendingPeerCode = Schema.Struct({ + linkId: Schema.String, + scopes: Schema.Array(FederationScope), + expiresAt: Schema.String, +}); +export type PersistedPendingPeerCode = typeof PersistedPendingPeerCode.Type; + +const PersistedFederationState = Schema.Struct({ + version: Schema.Literal(1), + peers: Schema.Array(PersistedFederationPeer), + remoteRuns: Schema.Array(PersistedRemoteRun), + inboundRuns: Schema.Array(PersistedInboundRun), + // Absent in files written before pending codes were persisted. + pendingPeerCodes: Schema.optionalKey(Schema.Array(PersistedPendingPeerCode)), +}); +type PersistedFederationState = typeof PersistedFederationState.Type; + +const PersistedFederationStateJson = Schema.fromJsonString(PersistedFederationState); +const decodeState = Schema.decodeUnknownEffect(PersistedFederationStateJson); +const encodeState = Schema.encodeEffect(PersistedFederationStateJson); + +const EMPTY_STATE: PersistedFederationState = { + version: 1, + peers: [], + remoteRuns: [], + inboundRuns: [], + pendingPeerCodes: [], +}; + +export interface PeerRuntimeStatus { + readonly status: FederationPeerStatus; + readonly lastError: string | null; +} + +export class FederationPeerStoreError extends Schema.TaggedErrorClass()( + "FederationPeerStoreError", + { operation: Schema.Literals(["read", "write"]), cause: Schema.Defect() }, +) { + override get message(): string { + return `Could not ${this.operation} federation state.`; + } +} + +export class FederationPeerStore extends Context.Service< + FederationPeerStore, + { + readonly peers: Effect.Effect>; + readonly getPeer: ( + peerId: EnvironmentId, + ) => Effect.Effect>; + readonly upsertPeer: ( + peer: PersistedFederationPeer, + ) => Effect.Effect; + readonly removePeer: (peerId: EnvironmentId) => Effect.Effect; + readonly setPeerStatus: ( + peerId: EnvironmentId, + status: PeerRuntimeStatus, + ) => Effect.Effect; + readonly remoteRuns: Effect.Effect>; + readonly upsertRemoteRun: ( + run: PersistedRemoteRun, + ) => Effect.Effect; + readonly inboundRuns: Effect.Effect>; + readonly recordInboundRun: ( + run: PersistedInboundRun, + ) => Effect.Effect; + /** Present-tense view for clients, merging pinned facts with runtime status. */ + readonly pendingPeerCodes: Effect.Effect>; + readonly addPendingPeerCode: ( + code: PersistedPendingPeerCode, + ) => Effect.Effect; + /** Drops the redeemed code, if given, and every code that expired before `nowMs`. */ + readonly settlePendingPeerCodes: (input: { + readonly redeemedLinkId?: string; + readonly nowMs: number; + }) => Effect.Effect; + readonly presentPeer: (peer: PersistedFederationPeer) => Effect.Effect; + } +>()("t3/federation/FederationPeerStore") {} + +export const make = Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const statePath = path.join(config.stateDir, FEDERATION_STATE_FILE); + const lock = yield* Semaphore.make(1); + + const initial = yield* fileSystem.readFileString(statePath).pipe( + Effect.option, + Effect.flatMap((raw) => + Option.isNone(raw) || raw.value.trim().length === 0 + ? Effect.succeed(EMPTY_STATE) + : decodeState(raw.value).pipe( + Effect.catch((cause) => + Effect.logWarning("Federation state is unreadable; starting from defaults.", { + statePath, + cause, + }).pipe(Effect.as(EMPTY_STATE)), + ), + ), + ), + ); + const state = yield* Ref.make(initial); + const statuses = yield* Ref.make>(new Map()); + + const update = (transform: (current: PersistedFederationState) => PersistedFederationState) => + lock.withPermits(1)( + Effect.gen(function* () { + const next = transform(yield* Ref.get(state)); + const encoded = yield* encodeState(next).pipe( + Effect.mapError((cause) => new FederationPeerStoreError({ operation: "write", cause })), + ); + yield* writeFileStringAtomically({ filePath: statePath, contents: `${encoded}\n` }).pipe( + Effect.mapError((cause) => new FederationPeerStoreError({ operation: "write", cause })), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ); + yield* Ref.set(state, next); + }), + ); + + const peerStatus = (peerId: EnvironmentId) => + Ref.get(statuses).pipe( + Effect.map( + (current) => current.get(peerId) ?? { status: "unknown" as const, lastError: null }, + ), + ); + + const presentPeer: FederationPeerStore["Service"]["presentPeer"] = (peer) => + peerStatus(peer.peerId).pipe( + Effect.map((status): FederationPeer => ({ + peerId: peer.peerId, + label: peer.label, + publicKeyFingerprint: federationKeyFingerprint(peer.publicKey), + grantedScopes: peer.grantedScopes, + allowedScopes: peer.allowedScopes, + transport: peer.transport, + remoteServerVersion: peer.remoteServerVersion, + remoteProtocolVersion: peer.remoteProtocolVersion, + remoteCapabilities: peer.remoteCapabilities, + status: status.status, + lastSeenAt: peer.lastSeenAt, + lastError: status.lastError, + createdAt: peer.createdAt, + })), + ); + + return FederationPeerStore.of({ + peers: Ref.get(state).pipe(Effect.map((current) => current.peers)), + getPeer: (peerId) => + Ref.get(state).pipe( + Effect.map((current) => + Option.fromUndefinedOr(current.peers.find((peer) => peer.peerId === peerId)), + ), + ), + upsertPeer: (peer) => + update((current) => ({ + ...current, + peers: [...current.peers.filter((existing) => existing.peerId !== peer.peerId), peer], + })), + removePeer: (peerId) => + update((current) => ({ + ...current, + peers: current.peers.filter((peer) => peer.peerId !== peerId), + remoteRuns: current.remoteRuns.filter((run) => run.peerId !== peerId), + inboundRuns: current.inboundRuns.filter((run) => run.peerId !== peerId), + })).pipe( + Effect.andThen( + Ref.update(statuses, (current) => { + const next = new Map(current); + next.delete(peerId); + return next; + }), + ), + ), + setPeerStatus: (peerId, status) => + Ref.update(statuses, (current) => new Map(current).set(peerId, status)), + remoteRuns: Ref.get(state).pipe(Effect.map((current) => current.remoteRuns)), + upsertRemoteRun: (run) => + update((current) => ({ + ...current, + remoteRuns: [ + ...current.remoteRuns.filter( + (existing) => + !(existing.peerId === run.peerId && existing.run.threadId === run.run.threadId), + ), + run, + ], + })), + inboundRuns: Ref.get(state).pipe(Effect.map((current) => current.inboundRuns)), + recordInboundRun: (run) => + update((current) => ({ + ...current, + inboundRuns: [ + ...current.inboundRuns.filter((existing) => existing.threadId !== run.threadId), + run, + ], + })), + pendingPeerCodes: Ref.get(state).pipe(Effect.map((current) => current.pendingPeerCodes ?? [])), + addPendingPeerCode: (code) => + update((current) => ({ + ...current, + pendingPeerCodes: [ + ...(current.pendingPeerCodes ?? []).filter((existing) => existing.linkId !== code.linkId), + code, + ], + })), + settlePendingPeerCodes: ({ redeemedLinkId, nowMs }) => + update((current) => ({ + ...current, + pendingPeerCodes: (current.pendingPeerCodes ?? []).filter( + (code) => + code.linkId !== redeemedLinkId && + DateTime.toEpochMillis(DateTime.makeUnsafe(code.expiresAt)) > nowMs, + ), + })), + presentPeer, + }); +}); + +export const layer = Layer.effect(FederationPeerStore, make); diff --git a/apps/server/src/federation/FederationService.ts b/apps/server/src/federation/FederationService.ts new file mode 100644 index 000000000000..cd518cfc7623 --- /dev/null +++ b/apps/server/src/federation/FederationService.ts @@ -0,0 +1,1438 @@ +import { + AuthFederationPeerScope, + type ClientOrchestrationCommand, + CommandId, + DEFAULT_MODEL, + DEFAULT_PROVIDER_INTERACTION_MODE, + EnvironmentAuthInvalidError, + EnvironmentId, + EnvironmentHttpApi, + FEDERATION_PEER_CODE_DEFAULT_TTL_SECONDS, + FEDERATION_PEER_CODE_PAIRING_SUBJECT, + FEDERATION_PROTOCOL_VERSION, + type FederationAddPeerInput, + type FederationArtifactFetchResponse, + type FederationArtifactsResponse, + type FederationCapability, + type FederationChallengeRequest, + type FederationChallengeResponse, + type FederationCreatePeerCodeInput, + FederationError, + type FederationHello, + type FederationPairRequest, + type FederationPairResponse, + type FederationPeer, + type FederationPeerCodeResult, + type FederationProjectsResponse, + type FederationRemoteArtifactInput, + type FederationRemoteRun, + type FederationRunEvent, + type FederationRemoteRunInput, + type FederationRemoteRunsSnapshot, + type FederationRun, + type FederationRunEventsResponse, + type FederationRunStartRequest, + type FederationScope, + type FederationSnapshot, + type FederationStartRemoteRunInput, + type FederationTokenRequest, + type FederationTokenResponse, + type ModelSelection, + MessageId, + ProviderInstanceId, + type ThreadId, + ThreadId as ThreadIdSchema, + type TurnId, +} from "@t3tools/contracts"; +import { + T3ConnectionCodeInvalidError, + decodeFederationPeerCode, + encodeFederationPeerCode, +} from "@t3tools/shared/t3ConnectionCode"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Encoding from "effect/Encoding"; +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 Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import { HttpClient } from "effect/unstable/http"; +import * as HttpApiClient from "effect/unstable/httpapi/HttpApiClient"; + +import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; +import * as PairingGrantStore from "../auth/PairingGrantStore.ts"; +import * as CheckpointDiffQuery from "../checkpointing/CheckpointDiffQuery.ts"; +import * as ServerConfig from "../config.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import { normalizeDispatchCommand } from "../orchestration/Normalizer.ts"; +import * as OrchestrationEngine from "../orchestration/Services/OrchestrationEngine.ts"; +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as TailcatRemoteAccess from "../tailcat/TailcatRemoteAccess.ts"; +import * as WorkspacePaths from "../workspace/WorkspacePaths.ts"; +import * as FederationIdentity from "./FederationIdentity.ts"; +import * as FederationPeerStore from "./FederationPeerStore.ts"; +import * as FederationTransport from "./FederationTransport.ts"; +import { + isFederationRunActive, + projectFederationArtifacts, + projectFederationRun, + summarizeFederationRunEvent, + truncatePreview, +} from "./runProjection.ts"; + +/** + * FederationService is the T3 federation protocol, both halves: + * + * - as an issuer/peer-facing server it pairs requesters that redeem a peer + * code, answers signed challenges with federation sessions, and serves the + * explicit, scope-checked federation endpoints; + * - as a requester it pairs with peers from their codes, keeps a session per + * peer, and coordinates runs that stay owned by the peer that executes them. + * + * Transport is Tailcat (FederationTransport). Authentication is this + * environment's Ed25519 identity (FederationIdentity) plus ordinary T3 sessions + * scoped to `federation:peer`. Authorization is the per-peer scope grant made + * at pairing time; a transport path never implies trust by itself. + */ + +export const FEDERATION_CAPABILITIES: ReadonlyArray = [ + "hello", + "projects.list", + "runs.start", + "runs.status", + "runs.cancel", + "runs.events", + "artifacts.describe", + "artifacts.fetch", +]; + +const FEDERATION_SESSION_TTL = Duration.hours(1); +const FEDERATION_SESSION_REFRESH_SKEW = Duration.minutes(2); +const CHALLENGE_TTL = Duration.minutes(2); +const REMOTE_RUN_POLL_INTERVAL = Duration.seconds(2); +const REMOTE_RUN_EVENT_LIMIT = 200; +const PEER_REFRESH_INTERVAL = Duration.minutes(5); +const PEER_REQUEST_TIMEOUT = Duration.seconds(20); +const DEFAULT_REMOTE_RUNTIME_MODE = "auto" as const; + +export class FederationService extends Context.Service< + FederationService, + { + // Local owner operations (driven over RPC by this environment's clients) + readonly snapshot: Effect.Effect; + readonly changes: Stream.Stream; + readonly remoteRuns: Effect.Effect; + readonly remoteRunChanges: Stream.Stream; + readonly createPeerCode: ( + input: FederationCreatePeerCodeInput, + ) => Effect.Effect; + readonly addPeer: ( + input: FederationAddPeerInput, + ) => Effect.Effect; + readonly removePeer: (peerId: EnvironmentId) => Effect.Effect; + readonly refreshPeer: (peerId: EnvironmentId) => Effect.Effect; + readonly listRemoteProjects: ( + peerId: EnvironmentId, + ) => Effect.Effect; + readonly startRemoteRun: ( + input: FederationStartRemoteRunInput, + ) => Effect.Effect; + readonly cancelRemoteRun: ( + input: FederationRemoteRunInput, + ) => Effect.Effect; + readonly describeRemoteArtifacts: ( + input: FederationRemoteRunInput, + ) => Effect.Effect; + readonly fetchRemoteArtifact: ( + input: FederationRemoteArtifactInput, + ) => Effect.Effect; + // Peer-facing protocol operations (driven by the federation HTTP group) + readonly acceptPair: ( + request: FederationPairRequest, + ) => Effect.Effect; + readonly issueChallenge: ( + request: FederationChallengeRequest, + ) => Effect.Effect; + readonly redeemChallenge: ( + request: FederationTokenRequest, + ) => Effect.Effect; + readonly authorizePeer: ( + principal: { readonly subject: string; readonly scopes: ReadonlySet }, + required: FederationScope, + ) => Effect.Effect; + readonly hello: Effect.Effect; + readonly localProjects: Effect.Effect; + readonly startLocalRun: ( + peer: FederationPeerStore.PersistedFederationPeer, + request: FederationRunStartRequest, + ) => Effect.Effect; + readonly localRunStatus: ( + peer: FederationPeerStore.PersistedFederationPeer, + threadId: ThreadId, + ) => Effect.Effect; + readonly cancelLocalRun: ( + peer: FederationPeerStore.PersistedFederationPeer, + threadId: ThreadId, + ) => Effect.Effect; + readonly localRunEvents: ( + peer: FederationPeerStore.PersistedFederationPeer, + threadId: ThreadId, + afterSequence: number, + ) => Effect.Effect; + readonly localRunArtifacts: ( + peer: FederationPeerStore.PersistedFederationPeer, + threadId: ThreadId, + ) => Effect.Effect; + readonly fetchLocalArtifact: ( + peer: FederationPeerStore.PersistedFederationPeer, + threadId: ThreadId, + turnId: TurnId, + ) => Effect.Effect; + } +>()("t3/federation/FederationService") {} + +interface PendingChallenge { + readonly peerId: EnvironmentId; + readonly expiresAtMs: number; +} + +interface PeerSession { + readonly token: string; + readonly expiresAtMs: number; +} + +const internalError = (message: string) => new FederationError({ code: "internal", message }); +const isFederationError = Schema.is(FederationError); +const isConnectionCodeInvalidError = Schema.is(T3ConnectionCodeInvalidError); +const isAuthRejection = Schema.is(EnvironmentAuthInvalidError); +const isEnvironmentId = Schema.is(EnvironmentId); + +const withoutKey = (map: ReadonlyMap, key: K): ReadonlyMap => { + const next = new Map(map); + next.delete(key); + return next; +}; + +/** Volatile per-run sync state; re-fetchable from the peer, so never persisted. */ +interface RemoteRunSync { + readonly events: ReadonlyArray; + readonly lastSyncedAt: string | null; + readonly syncError: string | null; +} +const EMPTY_SYNC: RemoteRunSync = { events: [], lastSyncedAt: null, syncError: null }; +const remoteRunKey = (peerId: EnvironmentId, threadId: ThreadId) => `${peerId}:${threadId}`; + +const shallowEqual = (a: unknown, b: unknown): boolean => + a === b || + (typeof a === "object" && + a !== null && + typeof b === "object" && + b !== null && + Object.keys(a).length === Object.keys(b).length && + Object.entries(a).every(([key, value]) => (b as Record)[key] === value)); + +/** Field-wise comparison of two run projections (nested values are one level deep). */ +const sameRun = (a: FederationRun, b: FederationRun): boolean => + Object.keys(a).length === Object.keys(b).length && + (Object.keys(a) as ReadonlyArray).every((key) => + shallowEqual(a[key], b[key]), + ); + +const describeCause = (cause: unknown): string => + cause instanceof Error ? cause.message : typeof cause === "string" ? cause : String(cause); + +function scopesIncludeAll( + granted: ReadonlyArray, + required: ReadonlyArray, +): boolean { + return required.every((scope) => granted.includes(scope)); +} + +export const make = Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const identity = yield* FederationIdentity.FederationIdentity; + const peers = yield* FederationPeerStore.FederationPeerStore; + const transport = yield* FederationTransport.FederationTransport; + const tailcat = yield* TailcatRemoteAccess.TailcatRemoteAccess; + const environmentAuth = yield* EnvironmentAuth.EnvironmentAuth; + const pairingLinks = yield* PairingGrantStore.PairingGrantStore; + const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; + const orchestrationEngine = yield* OrchestrationEngine.OrchestrationEngineService; + const projections = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const checkpointDiffs = yield* CheckpointDiffQuery.CheckpointDiffQuery; + const httpClient = yield* HttpClient.HttpClient; + const crypto = yield* Crypto.Crypto; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const workspacePaths = yield* WorkspacePaths.WorkspacePaths; + const serviceScope = yield* Scope.Scope; + + const remoteRunSync = yield* Ref.make>(new Map()); + const peerClients = new Map(); + const challenges = yield* Ref.make>(new Map()); + const peerSessions = yield* Ref.make>(new Map()); + const pollSignals = yield* Queue.unbounded<"poll">(); + + const nowIso = DateTime.now.pipe(Effect.map(DateTime.formatIso)); + const nowMs = DateTime.now.pipe(Effect.map(DateTime.toEpochMillis)); + + const buildSnapshot = Effect.gen(function* () { + const stored = yield* peers.peers; + const presented = yield* Effect.forEach(stored, peers.presentPeer); + return { + environmentId: identity.environmentId, + publicKeyFingerprint: identity.fingerprint, + protocolVersion: FEDERATION_PROTOCOL_VERSION, + peers: presented.toSorted((left, right) => left.label.localeCompare(right.label)), + updatedAt: yield* nowIso, + } satisfies FederationSnapshot; + }); + const snapshotRef = yield* SubscriptionRef.make(yield* buildSnapshot); + const publishPeers = buildSnapshot.pipe( + Effect.flatMap((next) => SubscriptionRef.set(snapshotRef, next)), + ); + + const presentRemoteRuns = Effect.gen(function* () { + const stored = yield* peers.remoteRuns; + const sync = yield* Ref.get(remoteRunSync); + return stored.map((record): FederationRemoteRun => ({ + ...record, + ...(sync.get(remoteRunKey(record.peerId, record.run.threadId)) ?? EMPTY_SYNC), + })); + }); + const buildRuns = Effect.gen(function* () { + const runs = yield* presentRemoteRuns; + return { + runs: runs.toSorted((left, right) => + right.run.requestedAt.localeCompare(left.run.requestedAt), + ), + updatedAt: yield* nowIso, + } satisfies FederationRemoteRunsSnapshot; + }); + const runsRef = yield* SubscriptionRef.make(yield* buildRuns); + const publishRuns = buildRuns.pipe(Effect.flatMap((next) => SubscriptionRef.set(runsRef, next))); + + const storeError = (error: FederationPeerStore.FederationPeerStoreError) => + internalError(error.message); + + const helloEffect: FederationService["Service"]["hello"] = serverEnvironment.getDescriptor.pipe( + Effect.map((descriptor): FederationHello => ({ + protocolVersion: FEDERATION_PROTOCOL_VERSION, + environmentId: descriptor.environmentId, + label: descriptor.label, + serverVersion: descriptor.serverVersion, + platform: descriptor.platform, + capabilities: FEDERATION_CAPABILITIES, + })), + ); + + const ourTransport = tailcat.readyEndpoint.pipe( + Effect.map((endpoint) => + Option.match(endpoint, { + onNone: () => null, + onSome: ({ address, port }) => ({ tailcat: { address, port } }), + }), + ), + ); + + /** How this environment introduces itself to a peer, in both pairing directions. */ + const describeSelf = Effect.gen(function* () { + const descriptor = yield* serverEnvironment.getDescriptor; + const ourNodeKey = yield* transport.clientNodeKey.pipe(Effect.option); + return { + protocolVersion: FEDERATION_PROTOCOL_VERSION, + environmentId: identity.environmentId, + publicKey: identity.publicKey, + label: descriptor.label, + serverVersion: descriptor.serverVersion, + capabilities: FEDERATION_CAPABILITIES, + transport: yield* ourTransport, + ...(Option.isSome(ourNodeKey) ? { tailcatNodeKey: ourNodeKey.value } : {}), + }; + }); + + const peerTimeout = + (message: string) => + (effect: Effect.Effect) => + effect.pipe( + Effect.timeoutOrElse({ + duration: PEER_REQUEST_TIMEOUT, + orElse: () => Effect.fail(new FederationError({ code: "peer-unreachable", message })), + }), + ); + + const lookupPeer = (peerId: EnvironmentId, missing: () => FederationError) => + peers + .getPeer(peerId) + .pipe( + Effect.flatMap( + Option.match({ onNone: () => Effect.fail(missing()), onSome: Effect.succeed }), + ), + ); + + // ── Peer-facing protocol ──────────────────────────────────────────── + + // Offered scopes are persisted next to the peers, so a code minted before a + // restart still pairs. Every settle also drops codes that have expired. + const settlePendingPeerCodes = (redeemedLinkId?: string) => + nowMs.pipe( + Effect.flatMap((current) => + peers.settlePendingPeerCodes( + redeemedLinkId === undefined ? { nowMs: current } : { redeemedLinkId, nowMs: current }, + ), + ), + Effect.mapError(storeError), + ); + + const acceptPair: FederationService["Service"]["acceptPair"] = Effect.fn( + "FederationService.acceptPair", + )(function* (request) { + if (request.protocolVersion !== FEDERATION_PROTOCOL_VERSION) { + return yield* new FederationError({ + code: "protocol-incompatible", + message: `The peer speaks federation protocol v${request.protocolVersion}; this environment speaks v${FEDERATION_PROTOCOL_VERSION}. Update the older side.`, + }); + } + if (request.environmentId === identity.environmentId) { + return yield* new FederationError({ + code: "code-invalid", + message: "An environment cannot federate with itself.", + }); + } + const grant = yield* pairingLinks.consume(request.token).pipe( + Effect.mapError((error) => + PairingGrantStore.isBootstrapCredentialInvalidError(error) + ? new FederationError({ + code: + error._tag === "ExpiredBootstrapCredentialError" ? "code-expired" : "code-invalid", + message: + error._tag === "ExpiredBootstrapCredentialError" + ? "This peer code has expired. Create a new one on the other machine." + : "This peer code is not valid or was already used.", + }) + : internalError(`Could not validate the peer code: ${error.message}`), + ), + ); + if (grant.subject !== FEDERATION_PEER_CODE_PAIRING_SUBJECT) { + return yield* new FederationError({ + code: "code-invalid", + message: "This code is a device pairing code, not a federation peer code.", + }); + } + yield* settlePendingPeerCodes(); + const linkId = grant.id; + const pendingCode = + linkId === undefined + ? undefined + : (yield* peers.pendingPeerCodes).find((code) => code.linkId === linkId); + if (pendingCode === undefined) { + return yield* new FederationError({ + code: "code-expired", + message: "This peer code is no longer offered by this environment. Create a new one.", + }); + } + const offered = pendingCode.scopes; + yield* settlePendingPeerCodes(pendingCode.linkId); + const at = yield* nowIso; + const existing = yield* peers.getPeer(request.environmentId); + yield* peers + .upsertPeer({ + peerId: request.environmentId, + label: request.label, + publicKey: request.publicKey, + grantedScopes: offered, + allowedScopes: request.grantedScopes, + transport: request.transport, + remoteServerVersion: request.serverVersion, + remoteProtocolVersion: request.protocolVersion, + remoteCapabilities: request.capabilities, + createdAt: Option.isSome(existing) ? existing.value.createdAt : at, + lastSeenAt: at, + }) + .pipe(Effect.mapError(storeError)); + yield* peers.setPeerStatus(request.environmentId, { status: "online", lastError: null }); + if (request.tailcatNodeKey !== undefined) { + yield* tailcat + .recordTrustedPeer({ + nodeKey: request.tailcatNodeKey, + label: `Federation: ${request.label}`, + }) + .pipe( + Effect.catch((error) => + Effect.logWarning("Could not trust the federation peer's Tailcat key.", { error }), + ), + ); + } + yield* Effect.logInfo("Federation peer paired.", { + peerId: request.environmentId, + grantedScopes: offered, + allowedScopes: request.grantedScopes, + }); + yield* publishPeers; + return { ...(yield* describeSelf), grantedScopes: offered } satisfies FederationPairResponse; + }); + + const pruneChallenges = nowMs.pipe( + Effect.flatMap((current) => + Ref.update(challenges, (pending) => { + const next = new Map(); + for (const [nonce, entry] of pending) { + if (entry.expiresAtMs > current) next.set(nonce, entry); + } + return next; + }), + ), + ); + + const requirePeer = (peerId: EnvironmentId) => + lookupPeer( + peerId, + () => + new FederationError({ + code: "peer-unknown", + message: "This environment is not paired with the requesting environment.", + }), + ); + + const issueChallenge: FederationService["Service"]["issueChallenge"] = Effect.fn( + "FederationService.issueChallenge", + )(function* (request) { + yield* requirePeer(request.environmentId); + yield* pruneChallenges; + const bytes = yield* crypto + .randomBytes(32) + .pipe(Effect.mapError((cause) => internalError(describeCause(cause)))); + const challenge = Encoding.encodeBase64Url(bytes); + const expiresAtMs = (yield* nowMs) + Duration.toMillis(CHALLENGE_TTL); + yield* Ref.update(challenges, (pending) => + new Map(pending).set(challenge, { peerId: request.environmentId, expiresAtMs }), + ); + return { + challenge, + expiresAt: DateTime.formatIso(DateTime.makeUnsafe(expiresAtMs)), + } satisfies FederationChallengeResponse; + }); + + const redeemChallenge: FederationService["Service"]["redeemChallenge"] = Effect.fn( + "FederationService.redeemChallenge", + )(function* (request) { + const peer = yield* requirePeer(request.environmentId); + const answered = yield* identity + .verifyChallenge({ + assertion: request.assertion, + issuer: request.environmentId, + publicKey: peer.publicKey, + }) + .pipe( + Effect.mapError( + (error) => + new FederationError({ + code: "peer-rejected", + message: error.message, + }), + ), + ); + yield* pruneChallenges; + const pending = (yield* Ref.get(challenges)).get(answered); + if (pending === undefined || pending.peerId !== request.environmentId) { + return yield* new FederationError({ + code: "peer-rejected", + message: "The federation challenge is unknown or expired. Request a new one.", + }); + } + yield* Ref.update(challenges, (current) => withoutKey(current, answered)); + const session = yield* environmentAuth + .issueSession({ + ttl: FEDERATION_SESSION_TTL, + subject: peer.peerId, + scopes: [AuthFederationPeerScope], + label: `Federation: ${peer.label}`, + }) + .pipe(Effect.mapError((error) => internalError(error.message))); + const at = yield* nowIso; + yield* peers.upsertPeer({ ...peer, lastSeenAt: at }).pipe(Effect.ignore); + yield* peers.setPeerStatus(peer.peerId, { status: "online", lastError: null }); + yield* publishPeers; + return { + accessToken: session.token, + expiresAt: DateTime.formatIso(session.expiresAt), + scopes: peer.grantedScopes, + protocolVersion: FEDERATION_PROTOCOL_VERSION, + } satisfies FederationTokenResponse; + }); + + const authorizePeer: FederationService["Service"]["authorizePeer"] = Effect.fn( + "FederationService.authorizePeer", + )(function* (principal, required) { + if (!principal.scopes.has(AuthFederationPeerScope) || !isEnvironmentId(principal.subject)) { + return yield* new FederationError({ + code: "peer-unknown", + message: "This session is not a federation peer session.", + }); + } + const peer = yield* lookupPeer( + principal.subject, + () => + new FederationError({ + code: "peer-revoked", + message: "This environment no longer trusts the requesting environment.", + }), + ); + if (!peer.grantedScopes.includes(required)) { + return yield* new FederationError({ + code: "scope-denied", + message: `The requesting environment was not granted ${required}.`, + }); + } + yield* peers.setPeerStatus(peer.peerId, { status: "online", lastError: null }); + return peer; + }); + + const localProjects: FederationService["Service"]["localProjects"] = projections + .getShellSnapshot() + .pipe( + Effect.map((snapshot): FederationProjectsResponse => ({ + environmentId: identity.environmentId, + projects: snapshot.projects.map((project) => ({ + id: project.id, + title: project.title, + workspaceRoot: project.workspaceRoot, + repositoryIdentity: project.repositoryIdentity ?? null, + defaultModelSelection: project.defaultModelSelection, + })), + })), + Effect.mapError((error) => internalError(`Could not list projects: ${error.message}`)), + ); + + const requireInboundRun = ( + peer: FederationPeerStore.PersistedFederationPeer, + threadId: ThreadId, + ) => + peers.inboundRuns.pipe( + Effect.flatMap((runs) => { + const record = runs.find((run) => run.threadId === threadId && run.peerId === peer.peerId); + return record === undefined + ? Effect.fail( + new FederationError({ + code: "run-not-found", + message: "No federated run with that id was started by this peer.", + }), + ) + : Effect.succeed(record); + }), + ); + + const projectLocalRun = (threadId: ThreadId) => + Effect.gen(function* () { + const shell = yield* projections + .getThreadShellById(threadId) + .pipe(Effect.mapError((error) => internalError(error.message))); + if (Option.isNone(shell)) { + return yield* new FederationError({ + code: "run-not-found", + message: "The federated run no longer exists on this environment.", + }); + } + const detail = yield* projections + .getThreadDetailById(threadId, { activityKinds: [] }) + .pipe(Effect.orElseSucceed(() => Option.none())); + const assistantPreview = Option.match(detail, { + onNone: () => null, + onSome: (thread) => { + const lastAssistant = thread.messages + .toReversed() + .find((message) => message.role === "assistant" && message.text.trim().length > 0); + return lastAssistant === undefined ? null : truncatePreview(lastAssistant.text); + }, + }); + const checkpoints = yield* projections + .getThreadCheckpointContext(threadId) + .pipe(Effect.orElseSucceed(() => Option.none())); + const turnCount = Option.match(checkpoints, { + onNone: () => 0, + onSome: (context) => + context.checkpoints.reduce( + (max, checkpoint) => Math.max(max, checkpoint.checkpointTurnCount), + 0, + ), + }); + return projectFederationRun({ + environmentId: identity.environmentId, + thread: shell.value, + assistantPreview, + turnCount, + }); + }); + + const dispatchClientCommand = (command: ClientOrchestrationCommand) => + normalizeDispatchCommand(command).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + Effect.provideService(ServerConfig.ServerConfig, config), + Effect.provideService(WorkspacePaths.WorkspacePaths, workspacePaths), + Effect.mapError((error) => + internalError(`Invalid federation command: ${describeCause(error)}`), + ), + Effect.flatMap((normalized) => + orchestrationEngine + .dispatch(normalized) + .pipe(Effect.mapError((error) => internalError(describeCause(error)))), + ), + ); + + const newId = crypto.randomUUIDv4.pipe( + Effect.mapError((cause) => internalError(describeCause(cause))), + ); + + const startLocalRun: FederationService["Service"]["startLocalRun"] = Effect.fn( + "FederationService.startLocalRun", + )(function* (peer, request) { + const project = yield* projections + .getProjectShellById(request.projectId) + .pipe(Effect.mapError((error) => internalError(error.message))); + if (Option.isNone(project)) { + return yield* new FederationError({ + code: "run-not-found", + message: "That project does not exist on this environment.", + }); + } + const modelSelection: ModelSelection = request.modelSelection ?? + project.value.defaultModelSelection ?? { + instanceId: ProviderInstanceId.make("codex"), + model: DEFAULT_MODEL, + }; + const runtimeMode = request.runtimeMode ?? DEFAULT_REMOTE_RUNTIME_MODE; + const title = request.title ?? truncatePreview(request.prompt, 60); + const threadId = ThreadIdSchema.make(yield* newId); + const createdAt = yield* nowIso; + yield* dispatchClientCommand({ + type: "thread.create", + commandId: CommandId.make(yield* newId), + threadId, + projectId: request.projectId, + title, + modelSelection, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode, + branch: null, + worktreePath: null, + createdAt, + }); + // Events of this run all follow the sequence the log was at before it was created, + // so followers never have to scan older history. + const startSequence = yield* orchestrationEngine.latestSequence; + yield* peers + .recordInboundRun({ threadId, peerId: peer.peerId, createdAt, startSequence }) + .pipe(Effect.mapError(storeError)); + yield* dispatchClientCommand({ + type: "thread.turn.start", + commandId: CommandId.make(yield* newId), + threadId, + message: { + messageId: MessageId.make(yield* newId), + role: "user", + text: request.prompt, + attachments: [], + }, + modelSelection, + runtimeMode, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + createdAt: yield* nowIso, + }); + yield* Effect.logInfo("Federated run started for a peer.", { + peerId: peer.peerId, + threadId, + projectId: request.projectId, + runtimeMode, + }); + return yield* projectLocalRun(threadId); + }); + + const localRunStatus: FederationService["Service"]["localRunStatus"] = (peer, threadId) => + requireInboundRun(peer, threadId).pipe(Effect.andThen(projectLocalRun(threadId))); + + const cancelLocalRun: FederationService["Service"]["cancelLocalRun"] = Effect.fn( + "FederationService.cancelLocalRun", + )(function* (peer, threadId) { + yield* requireInboundRun(peer, threadId); + const run = yield* projectLocalRun(threadId); + if (isFederationRunActive(run)) { + yield* dispatchClientCommand({ + type: "thread.turn.interrupt", + commandId: CommandId.make(yield* newId), + threadId, + ...(run.turnId === null ? {} : { turnId: run.turnId }), + createdAt: yield* nowIso, + }); + } + return yield* projectLocalRun(threadId); + }); + + const localRunEvents: FederationService["Service"]["localRunEvents"] = Effect.fn( + "FederationService.localRunEvents", + )(function* (peer, threadId, afterSequence) { + const inbound = yield* requireInboundRun(peer, threadId); + const run = yield* projectLocalRun(threadId); + const latestSequence = yield* orchestrationEngine.latestSequence; + const fromSequence = Math.max(afterSequence, inbound.startSequence ?? 0); + const events = yield* orchestrationEngine + .readEvents(fromSequence, Math.max(1, latestSequence - fromSequence)) + .pipe( + Stream.map((event) => summarizeFederationRunEvent(event, threadId)), + Stream.filter((event) => event !== null), + Stream.runCollect, + Effect.mapError((error) => internalError(`Could not read run events: ${error.message}`)), + ); + return { + run, + events: events.slice(-REMOTE_RUN_EVENT_LIMIT), + latestSequence, + } satisfies FederationRunEventsResponse; + }); + + const localArtifactRefs = (threadId: ThreadId) => + projections.getThreadCheckpointContext(threadId).pipe( + Effect.mapError((error) => internalError(error.message)), + Effect.map((context) => + Option.match(context, { + onNone: () => [], + onSome: (value) => + projectFederationArtifacts({ + environmentId: identity.environmentId, + threadId, + checkpoints: value.checkpoints, + }), + }), + ), + ); + + const localRunArtifacts: FederationService["Service"]["localRunArtifacts"] = Effect.fn( + "FederationService.localRunArtifacts", + )(function* (peer, threadId) { + yield* requireInboundRun(peer, threadId); + const run = yield* projectLocalRun(threadId); + const artifacts = yield* localArtifactRefs(threadId); + return { run, artifacts } satisfies FederationArtifactsResponse; + }); + + const fetchLocalArtifact: FederationService["Service"]["fetchLocalArtifact"] = Effect.fn( + "FederationService.fetchLocalArtifact", + )(function* (peer, threadId, turnId) { + yield* requireInboundRun(peer, threadId); + const artifacts = yield* localArtifactRefs(threadId); + const ref = artifacts.find((artifact) => artifact.turnId === turnId); + if (ref === undefined) { + return yield* new FederationError({ + code: "artifact-unavailable", + message: "That turn has no recorded changes yet.", + }); + } + const diff = yield* checkpointDiffs + .getTurnDiff({ threadId, fromTurnCount: ref.fromTurnCount, toTurnCount: ref.toTurnCount }) + .pipe( + Effect.mapError( + (error) => + new FederationError({ + code: "artifact-unavailable", + message: `Could not compute the diff: ${describeCause(error)}`, + }), + ), + ); + return { + ref, + contentType: "text/x-diff", + diff: diff.diff, + fetchedAt: yield* nowIso, + } satisfies FederationArtifactFetchResponse; + }); + + // ── Requester side ────────────────────────────────────────────────── + + const makeClient = (httpBaseUrl: string) => + HttpApiClient.make(EnvironmentHttpApi, { baseUrl: httpBaseUrl }).pipe( + Effect.provideService(HttpClient.HttpClient, httpClient), + ); + type PeerClient = Effect.Success>; + // Building an HttpApi client reflects the whole API; one per forward endpoint is plenty. + const clientFor = (httpBaseUrl: string) => + Effect.suspend(() => { + const cached = peerClients.get(httpBaseUrl); + if (cached !== undefined) return Effect.succeed(cached); + return makeClient(httpBaseUrl).pipe( + Effect.tap((client) => + Effect.sync(() => { + if (peerClients.size >= 32) peerClients.clear(); + peerClients.set(httpBaseUrl, client); + }), + ), + ); + }); + + const mapPeerCallError = (peerId: EnvironmentId) => (cause: unknown) => + Effect.gen(function* () { + if (isFederationError(cause)) { + if (cause.code === "peer-unknown" || cause.code === "peer-revoked") { + yield* peers.setPeerStatus(peerId, { status: "offline", lastError: cause.message }); + } + return cause; + } + const message = describeCause(cause); + yield* peers.setPeerStatus(peerId, { status: "offline", lastError: message }); + return new FederationError({ code: "peer-unreachable", message }); + }).pipe(Effect.flatMap(Effect.fail)); + + const requestSession = (peer: FederationPeerStore.PersistedFederationPeer, client: PeerClient) => + Effect.gen(function* () { + const challenge = yield* client.federation.challenge({ + payload: { environmentId: identity.environmentId }, + }); + const assertion = yield* identity + .signChallenge({ audience: peer.peerId, challenge: challenge.challenge }) + .pipe(Effect.mapError((error) => internalError(error.message))); + const token = yield* client.federation.token({ + payload: { environmentId: identity.environmentId, assertion }, + }); + const expiresAtMs = DateTime.toEpochMillis(DateTime.makeUnsafe(token.expiresAt)); + yield* Ref.update(peerSessions, (current) => + new Map(current).set(peer.peerId, { token: token.accessToken, expiresAtMs }), + ); + return token; + }); + + const sessionFor = (peer: FederationPeerStore.PersistedFederationPeer, client: PeerClient) => + Effect.gen(function* () { + const cached = (yield* Ref.get(peerSessions)).get(peer.peerId); + const current = yield* nowMs; + if ( + cached !== undefined && + cached.expiresAtMs - Duration.toMillis(FEDERATION_SESSION_REFRESH_SKEW) > current + ) { + return cached.token; + } + return (yield* requestSession(peer, client)).accessToken; + }); + + const callPeer = ( + peer: FederationPeerStore.PersistedFederationPeer, + call: (client: PeerClient, headers: { readonly authorization: string }) => Effect.Effect, + ): Effect.Effect => + Effect.gen(function* () { + if (peer.transport === null) { + return yield* new FederationError({ + code: "transport-unavailable", + message: `${peer.label} did not share a Tailcat address, so this environment cannot reach it.`, + }); + } + const endpoint = yield* transport.endpointFor({ + peerId: peer.peerId, + transport: peer.transport, + }); + const client = yield* clientFor(endpoint.httpBaseUrl).pipe( + Effect.mapError((cause) => internalError(describeCause(cause))), + ); + const attempt = Effect.gen(function* () { + const token = yield* sessionFor(peer, client); + return yield* call(client, { authorization: `Bearer ${token}` }); + }); + return yield* attempt.pipe( + Effect.catch((cause) => + // One retry after a session refresh covers a revoked or expired + // token; anything else is a real failure. + isAuthRejection(cause) + ? Ref.update(peerSessions, (current) => withoutKey(current, peer.peerId)).pipe( + Effect.andThen(attempt), + ) + : Effect.fail(cause), + ), + peerTimeout(`${peer.label} did not answer in time.`), + Effect.catch((cause) => mapPeerCallError(peer.peerId)(cause)), + Effect.tap(() => peers.setPeerStatus(peer.peerId, { status: "online", lastError: null })), + ); + }); + + const requireAllowed = ( + peer: FederationPeerStore.PersistedFederationPeer, + scopes: ReadonlyArray, + ) => + scopesIncludeAll(peer.allowedScopes, scopes) + ? Effect.void + : Effect.fail( + new FederationError({ + code: "scope-denied", + message: `${peer.label} has not granted this environment ${scopes.join(", ")}.`, + }), + ); + + const requireLocalPeer = (peerId: EnvironmentId) => + lookupPeer( + peerId, + () => + new FederationError({ + code: "peer-unknown", + message: "That environment is not paired here.", + }), + ); + + const refreshPeer: FederationService["Service"]["refreshPeer"] = Effect.fn( + "FederationService.refreshPeer", + )(function* (peerId) { + const peer = yield* requireLocalPeer(peerId); + const hello = yield* callPeer(peer, (client, headers) => + client.federation.hello({ headers }), + ).pipe(Effect.result); + if (Result.isFailure(hello)) { + yield* publishPeers; + const presented = yield* peers.presentPeer(peer); + return presented; + } + const at = yield* nowIso; + const updated = { + ...peer, + label: hello.success.label, + remoteServerVersion: hello.success.serverVersion, + remoteProtocolVersion: hello.success.protocolVersion, + remoteCapabilities: hello.success.capabilities, + lastSeenAt: at, + }; + yield* peers.upsertPeer(updated).pipe(Effect.mapError(storeError)); + yield* publishPeers; + return yield* peers.presentPeer(updated); + }); + + const createPeerCode: FederationService["Service"]["createPeerCode"] = Effect.fn( + "FederationService.createPeerCode", + )(function* (input) { + const endpoint = yield* tailcat.readyEndpoint; + if (Option.isNone(endpoint)) { + return yield* new FederationError({ + code: "transport-unavailable", + message: "Enable Tailcat access on this environment before pairing peers.", + }); + } + if (input.scopes.length === 0) { + return yield* new FederationError({ + code: "scope-denied", + message: "Grant the peer at least one capability.", + }); + } + const ttlSeconds = input.ttlSeconds ?? FEDERATION_PEER_CODE_DEFAULT_TTL_SECONDS; + const issued = yield* environmentAuth + .createPairingLink({ + scopes: [AuthFederationPeerScope], + subject: FEDERATION_PEER_CODE_PAIRING_SUBJECT, + label: "Federation peer code", + ttl: Duration.seconds(ttlSeconds), + }) + .pipe(Effect.mapError((error) => internalError(error.message))); + yield* settlePendingPeerCodes(); + yield* peers + .addPendingPeerCode({ + linkId: issued.id, + scopes: input.scopes, + expiresAt: DateTime.formatIso(issued.expiresAt), + }) + .pipe(Effect.mapError(storeError)); + const descriptor = yield* serverEnvironment.getDescriptor; + const expiresAt = DateTime.formatIso(issued.expiresAt); + const payload = { + v: 1 as const, + kind: "peer" as const, + protocolVersion: FEDERATION_PROTOCOL_VERSION, + environmentId: identity.environmentId, + publicKey: identity.publicKey, + label: descriptor.label, + transport: { tailcat: { address: endpoint.value.address, port: endpoint.value.port } }, + token: issued.credential, + scopes: input.scopes, + expiresAt, + }; + yield* Effect.logInfo("Federation peer code issued.", { + pairingLinkId: issued.id, + scopes: input.scopes, + expiresAt, + }); + return { + code: encodeFederationPeerCode(payload), + payload, + expiresAt, + } satisfies FederationPeerCodeResult; + }); + + const addPeer: FederationService["Service"]["addPeer"] = Effect.fn("FederationService.addPeer")( + function* (input) { + const payload = yield* Effect.try({ + try: () => decodeFederationPeerCode(input.code), + catch: (cause) => + new FederationError({ + code: "code-invalid", + message: isConnectionCodeInvalidError(cause) + ? cause.message + : "The peer code is invalid.", + }), + }); + if (payload.protocolVersion !== FEDERATION_PROTOCOL_VERSION) { + return yield* new FederationError({ + code: "protocol-incompatible", + message: `The peer speaks federation protocol v${payload.protocolVersion}; this environment speaks v${FEDERATION_PROTOCOL_VERSION}. Update the older side.`, + }); + } + if (payload.environmentId === identity.environmentId) { + return yield* new FederationError({ + code: "code-invalid", + message: "This is this environment's own peer code. Paste it on the other machine.", + }); + } + if (DateTime.toEpochMillis(DateTime.makeUnsafe(payload.expiresAt)) <= (yield* nowMs)) { + return yield* new FederationError({ + code: "code-expired", + message: "This peer code has expired. Create a new one on the other machine.", + }); + } + const endpoint = yield* transport.endpointFor({ + peerId: payload.environmentId, + transport: payload.transport, + }); + const client = yield* clientFor(endpoint.httpBaseUrl).pipe( + Effect.mapError((cause) => internalError(describeCause(cause))), + ); + const response = yield* client.federation + .pair({ + payload: { + ...(yield* describeSelf), + token: payload.token, + grantedScopes: input.grantedScopes, + }, + }) + .pipe( + peerTimeout("The other machine did not answer the pairing request in time."), + Effect.mapError((cause) => + isFederationError(cause) + ? cause + : new FederationError({ + code: "peer-unreachable", + message: `Pairing failed: ${describeCause(cause)}`, + }), + ), + ); + if ( + response.environmentId !== payload.environmentId || + response.publicKey !== payload.publicKey + ) { + yield* transport.drop(payload.environmentId); + return yield* new FederationError({ + code: "peer-rejected", + message: + "The machine behind this code identified itself differently than the code claims. Pairing was aborted.", + }); + } + const at = yield* nowIso; + const stored: FederationPeerStore.PersistedFederationPeer = { + peerId: response.environmentId, + label: response.label, + publicKey: response.publicKey, + grantedScopes: input.grantedScopes, + allowedScopes: response.grantedScopes, + transport: payload.transport, + remoteServerVersion: response.serverVersion, + remoteProtocolVersion: response.protocolVersion, + remoteCapabilities: response.capabilities, + createdAt: at, + lastSeenAt: at, + }; + yield* peers.upsertPeer(stored).pipe(Effect.mapError(storeError)); + yield* peers.setPeerStatus(stored.peerId, { status: "online", lastError: null }); + if (response.tailcatNodeKey !== undefined) { + yield* tailcat + .recordTrustedPeer({ + nodeKey: response.tailcatNodeKey, + label: `Federation: ${response.label}`, + }) + .pipe( + Effect.catch((error) => + Effect.logWarning("Could not trust the peer's Tailcat key.", { error }), + ), + ); + } + yield* Effect.logInfo("Paired with a federation peer.", { + peerId: stored.peerId, + allowedScopes: stored.allowedScopes, + grantedScopes: stored.grantedScopes, + }); + yield* publishPeers; + return yield* peers.presentPeer(stored); + }, + ); + + const removePeer: FederationService["Service"]["removePeer"] = Effect.fn( + "FederationService.removePeer", + )(function* (peerId) { + const peer = yield* requireLocalPeer(peerId); + yield* peers.removePeer(peerId).pipe(Effect.mapError(storeError)); + yield* Ref.update(peerSessions, (current) => withoutKey(current, peerId)); + yield* Ref.update(remoteRunSync, (current) => { + const next = new Map(current); + for (const key of current.keys()) { + if (key.startsWith(`${peerId}:`)) next.delete(key); + } + return next; + }); + yield* transport.drop(peerId); + // Sessions the peer holds here die with the trust relationship. + const sessions = yield* environmentAuth.listSessions().pipe(Effect.orElseSucceed(() => [])); + yield* Effect.forEach( + sessions.filter( + (session) => session.subject === peerId && session.scopes.includes(AuthFederationPeerScope), + ), + (session) => environmentAuth.revokeSession(session.sessionId).pipe(Effect.ignore), + { discard: true }, + ); + yield* Effect.logInfo("Federation peer removed.", { peerId, label: peer.label }); + yield* publishPeers; + yield* publishRuns; + }); + + const listRemoteProjects: FederationService["Service"]["listRemoteProjects"] = Effect.fn( + "FederationService.listRemoteProjects", + )(function* (peerId) { + const peer = yield* requireLocalPeer(peerId); + yield* requireAllowed(peer, ["projects.read"]); + return yield* callPeer(peer, (client, headers) => client.federation.projects({ headers })); + }); + + const persistRemoteRun = (record: FederationPeerStore.PersistedRemoteRun) => + peers.upsertRemoteRun(record).pipe(Effect.mapError(storeError)); + const updateRemoteRunSync = ( + peerId: EnvironmentId, + threadId: ThreadId, + patch: (current: RemoteRunSync) => RemoteRunSync, + ) => + Ref.update(remoteRunSync, (current) => { + const key = remoteRunKey(peerId, threadId); + return new Map(current).set(key, patch(current.get(key) ?? EMPTY_SYNC)); + }); + + const startRemoteRun: FederationService["Service"]["startRemoteRun"] = Effect.fn( + "FederationService.startRemoteRun", + )(function* (input) { + const peer = yield* requireLocalPeer(input.peerId); + yield* requireAllowed(peer, ["runs.start", "runs.read"]); + const run = yield* callPeer(peer, (client, headers) => + client.federation.startRun({ + headers, + payload: { + projectId: input.projectId, + prompt: input.prompt, + ...(input.title === undefined ? {} : { title: input.title }), + ...(input.runtimeMode === undefined ? {} : { runtimeMode: input.runtimeMode }), + }, + }), + ); + const record: FederationRemoteRun = { + peerId: peer.peerId, + peerLabel: peer.label, + run, + events: [], + lastSyncedAt: yield* nowIso, + syncError: null, + }; + yield* persistRemoteRun({ peerId: record.peerId, peerLabel: record.peerLabel, run }); + yield* updateRemoteRunSync(record.peerId, run.threadId, () => ({ + events: [], + lastSyncedAt: record.lastSyncedAt, + syncError: null, + })); + yield* publishRuns; + yield* Queue.offer(pollSignals, "poll"); + return record; + }); + + const findRemoteRun = (input: FederationRemoteRunInput) => + presentRemoteRuns.pipe( + Effect.flatMap((runs) => { + const record = runs.find( + (run) => run.peerId === input.peerId && run.run.threadId === input.threadId, + ); + return record === undefined + ? Effect.fail( + new FederationError({ + code: "run-not-found", + message: "That remote run is not tracked here.", + }), + ) + : Effect.succeed(record); + }), + ); + + const cancelRemoteRun: FederationService["Service"]["cancelRemoteRun"] = Effect.fn( + "FederationService.cancelRemoteRun", + )(function* (input) { + const peer = yield* requireLocalPeer(input.peerId); + yield* requireAllowed(peer, ["runs.cancel"]); + const record = yield* findRemoteRun(input); + const run = yield* callPeer(peer, (client, headers) => + client.federation.cancelRun({ headers, params: { threadId: input.threadId } }), + ); + const updated = { ...record, run, lastSyncedAt: yield* nowIso, syncError: null }; + yield* persistRemoteRun({ peerId: record.peerId, peerLabel: record.peerLabel, run }); + yield* updateRemoteRunSync(record.peerId, run.threadId, (current) => ({ + ...current, + lastSyncedAt: updated.lastSyncedAt, + syncError: null, + })); + yield* publishRuns; + return updated; + }); + + const describeRemoteArtifacts: FederationService["Service"]["describeRemoteArtifacts"] = + Effect.fn("FederationService.describeRemoteArtifacts")(function* (input) { + const peer = yield* requireLocalPeer(input.peerId); + yield* requireAllowed(peer, ["artifacts.read"]); + yield* findRemoteRun(input); + return yield* callPeer(peer, (client, headers) => + client.federation.runArtifacts({ headers, params: { threadId: input.threadId } }), + ); + }); + + const fetchRemoteArtifact: FederationService["Service"]["fetchRemoteArtifact"] = Effect.fn( + "FederationService.fetchRemoteArtifact", + )(function* (input) { + const peer = yield* requireLocalPeer(input.peerId); + yield* requireAllowed(peer, ["artifacts.read"]); + yield* findRemoteRun({ peerId: input.peerId, threadId: input.threadId }); + return yield* callPeer(peer, (client, headers) => + client.federation.fetchArtifact({ + headers, + params: { threadId: input.threadId, turnId: input.turnId }, + }), + ); + }); + + const syncRemoteRun = (record: FederationRemoteRun) => + Effect.gen(function* () { + const peer = yield* peers.getPeer(record.peerId); + if (Option.isNone(peer)) { + return; + } + const afterSequence = record.events.at(-1)?.sequence ?? 0; + const response = yield* callPeer(peer.value, (client, headers) => + client.federation.runEvents({ + headers, + params: { threadId: record.run.threadId }, + payload: { afterSequence }, + }), + ).pipe(Effect.result); + const at = yield* nowIso; + if (Result.isFailure(response)) { + // Failures are volatile state; only a new failure message is worth a publish. + const message = response.failure.message; + yield* updateRemoteRunSync(record.peerId, record.run.threadId, (current) => ({ + ...current, + lastSyncedAt: at, + syncError: message, + })); + if (record.syncError !== message) yield* publishRuns; + return; + } + const { run, events } = response.success; + const runChanged = !sameRun(record.run, run); + const recovered = record.syncError !== null; + yield* updateRemoteRunSync(record.peerId, record.run.threadId, (current) => ({ + events: [...current.events, ...events].slice(-REMOTE_RUN_EVENT_LIMIT), + lastSyncedAt: at, + syncError: null, + })); + if (runChanged) { + yield* persistRemoteRun({ peerId: record.peerId, peerLabel: record.peerLabel, run }); + } + // An idle run produces no events and no projection change: nothing to write or push. + if (runChanged || events.length > 0 || recovered) { + yield* publishRuns; + } + }); + + const pollLoop = Effect.gen(function* () { + for (;;) { + const runs = yield* presentRemoteRuns; + const active = runs.filter((record) => isFederationRunActive(record.run)); + if (active.length === 0) { + // Nothing to watch: sleep until a run starts instead of polling peers for nothing. + yield* Queue.take(pollSignals); + yield* Queue.clear(pollSignals); + continue; + } + yield* Effect.forEach(active, syncRemoteRun, { discard: true, concurrency: 2 }); + yield* Effect.raceFirst( + Effect.sleep(REMOTE_RUN_POLL_INTERVAL), + Queue.take(pollSignals).pipe(Effect.asVoid), + ); + } + }); + yield* pollLoop.pipe(Effect.forkIn(serviceScope)); + + // Only peers with a live forward are refreshed on the timer: opening a tunnel + // to every peer every few minutes would keep a child process per peer alive + // forever. Explicit refreshes and runs open tunnels on demand. + const refreshAllPeers = peers.peers.pipe( + Effect.flatMap((stored) => + Effect.forEach( + stored, + (peer) => + transport + .isActive(peer.peerId) + .pipe( + Effect.flatMap((active) => + active ? refreshPeer(peer.peerId).pipe(Effect.ignore) : Effect.void, + ), + ), + { discard: true, concurrency: 2 }, + ), + ), + ); + yield* Effect.sleep(Duration.seconds(15)).pipe( + Effect.andThen(refreshAllPeers), + Effect.andThen( + Effect.sleep(PEER_REFRESH_INTERVAL).pipe(Effect.andThen(refreshAllPeers), Effect.forever), + ), + Effect.forkIn(serviceScope), + ); + + return FederationService.of({ + snapshot: SubscriptionRef.get(snapshotRef), + changes: SubscriptionRef.changes(snapshotRef), + remoteRuns: SubscriptionRef.get(runsRef), + remoteRunChanges: SubscriptionRef.changes(runsRef), + createPeerCode, + addPeer, + removePeer, + refreshPeer, + listRemoteProjects, + startRemoteRun, + cancelRemoteRun, + describeRemoteArtifacts, + fetchRemoteArtifact, + acceptPair, + issueChallenge, + redeemChallenge, + authorizePeer, + hello: helloEffect, + localProjects, + startLocalRun, + localRunStatus, + cancelLocalRun, + localRunEvents, + localRunArtifacts, + fetchLocalArtifact, + }); +}); + +export const layer = Layer.effect(FederationService, make); diff --git a/apps/server/src/federation/FederationTransport.ts b/apps/server/src/federation/FederationTransport.ts new file mode 100644 index 000000000000..9a42bf0a7a55 --- /dev/null +++ b/apps/server/src/federation/FederationTransport.ts @@ -0,0 +1,272 @@ +import { + type EnvironmentId, + FederationError, + type FederationTransport as FederationTransportDescriptor, + type TailcatNodeKey, +} from "@t3tools/contracts"; +import { waitForHttpReady } from "@t3tools/shared/httpReadiness"; +import * as NetService from "@t3tools/shared/Net"; +import * as TailcatRuntime from "@t3tools/tailcat/runtime"; +import * as Context from "effect/Context"; +import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; +import * as Ref from "effect/Ref"; +import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; +import { HttpClient } from "effect/unstable/http"; + +import * as ServerConfig from "../config.ts"; + +/** + * FederationTransport gives this server a loopback HTTP endpoint for each peer + * by running a Tailcat forward to the peer's listener, using this server's own + * Tailcat client identity. Forwards are created lazily, reused while healthy, + * and closed when the peer is removed or the server shuts down. + */ +export const TAILCAT_CLIENT_IDENTITY_FILE = "tailcat-client-identity.private.json"; +const PEER_READY_TIMEOUT = Duration.seconds(25); + +export interface PeerEndpoint { + readonly httpBaseUrl: string; + readonly localPort: number; +} + +export class FederationTransport extends Context.Service< + FederationTransport, + { + /** This server's Tailcat client node key, created on first use. */ + readonly clientNodeKey: Effect.Effect; + readonly endpointFor: (input: { + readonly peerId: EnvironmentId; + readonly transport: FederationTransportDescriptor; + }) => Effect.Effect; + /** Drops the forward for a peer so the next call starts a fresh one. */ + readonly drop: (peerId: EnvironmentId) => Effect.Effect; + /** Whether a forward to the peer is currently up. */ + readonly isActive: (peerId: EnvironmentId) => Effect.Effect; + } +>()("t3/federation/FederationTransport") {} + +/** Forwards nobody has used for this long are closed; the next call reopens one. */ +const FORWARD_IDLE_TTL = Duration.minutes(10); +const FORWARD_IDLE_SWEEP_INTERVAL = Duration.minutes(1); + +interface ActiveForward { + readonly scope: Scope.Closeable; + readonly handle: TailcatRuntime.TailcatForwardHandle; + readonly address: string; + readonly port: number; + readonly lastUsedAtMs: number; +} + +const withoutKey = (map: ReadonlyMap, key: K): ReadonlyMap => { + const next = new Map(map); + next.delete(key); + return next; +}; + +const transportUnavailable = (message: string) => + new FederationError({ code: "transport-unavailable", message }); + +export const make = Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const runtime = yield* TailcatRuntime.TailcatRuntime; + const net = yield* NetService.NetService; + const httpClient = yield* HttpClient.HttpClient; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const serviceScope = yield* Scope.Scope; + const identityPath = path.join(config.secretsDir, TAILCAT_CLIENT_IDENTITY_FILE); + const forwards = yield* Ref.make>(new Map()); + // One lock per peer: a slow or unreachable peer must not stall the others. + const locks = yield* Ref.make>(new Map()); + // Created and published in one modify, so concurrent first callers share a lock. + const lockFor = (peerId: EnvironmentId) => + Ref.modify(locks, (current) => { + const existing = current.get(peerId); + if (existing !== undefined) return [existing, current] as const; + const created = Semaphore.makeUnsafe(1); + return [created, new Map(current).set(peerId, created)] as const; + }); + const withPeerLock = (peerId: EnvironmentId, effect: Effect.Effect) => + lockFor(peerId).pipe(Effect.flatMap((lock) => lock.withPermits(1)(effect))); + const nowMs = DateTime.now.pipe(Effect.map(DateTime.toEpochMillis)); + + const clientNodeKey: FederationTransport["Service"]["clientNodeKey"] = Effect.gen(function* () { + const exists = yield* fileSystem.exists(identityPath).pipe(Effect.orElseSucceed(() => false)); + if (!exists) { + const created = yield* runtime.generateClientIdentity({ keyPath: identityPath }); + return created.nodeKey; + } + return yield* runtime.readClientPublicKey({ keyPath: identityPath }); + }).pipe( + Effect.mapError((error) => + transportUnavailable(`Tailcat is not available on this machine: ${error.message}`), + ), + ); + + const closeForward = (forward: ActiveForward) => + Scope.close(forward.scope, Exit.void).pipe(Effect.ignore); + + const endpointFor: FederationTransport["Service"]["endpointFor"] = ({ peerId, transport }) => + withPeerLock( + peerId, + Effect.gen(function* () { + const existing = (yield* Ref.get(forwards)).get(peerId); + if (existing !== undefined) { + const sameTarget = + existing.address === transport.tailcat.address && + existing.port === transport.tailcat.port; + // A live forward is trusted as-is; a failed request drops it and the + // caller retries, which is cheaper than probing before every call. + if (sameTarget && (yield* existing.handle.isRunning)) { + const touched = { ...existing, lastUsedAtMs: yield* nowMs }; + yield* Ref.update(forwards, (current) => new Map(current).set(peerId, touched)); + return { + httpBaseUrl: existing.handle.httpBaseUrl, + localPort: existing.handle.localPort, + } satisfies PeerEndpoint; + } + yield* Ref.update(forwards, (current) => withoutKey(current, peerId)); + yield* closeForward(existing); + } + yield* clientNodeKey; + const localPort = yield* net + .reserveLoopbackPort() + .pipe( + Effect.mapError((error) => + transportUnavailable(`Could not reserve a local port: ${error.message}`), + ), + ); + const scope = yield* Scope.make("sequential"); + const handle = yield* runtime + .forward({ + keyPath: identityPath, + address: transport.tailcat.address, + remotePort: transport.tailcat.port, + localPort, + readiness: ({ httpBaseUrl }) => + waitForHttpReady({ + baseUrl: httpBaseUrl, + path: "/.well-known/t3/environment", + timeoutMs: Duration.toMillis(PEER_READY_TIMEOUT), + intervalMs: 300, + probeTimeoutMs: 3_000, + makeError: () => "unreachable" as const, + }).pipe(Effect.provideService(HttpClient.HttpClient, httpClient)), + readinessTimeout: PEER_READY_TIMEOUT, + }) + .pipe( + Effect.provideService(Scope.Scope, scope), + Effect.onError(() => Scope.close(scope, Exit.void).pipe(Effect.ignore)), + Effect.mapError((error) => + error === "unreachable" + ? new FederationError({ + code: "peer-unreachable", + message: + "The peer did not answer through Tailcat. It may be offline, or this environment may no longer be trusted by it.", + }) + : error._tag === "TailcatBinaryMissingError" || + error._tag === "TailcatBinaryNotExecutableError" || + error._tag === "TailcatVersionIncompatibleError" + ? transportUnavailable(error.message) + : new FederationError({ code: "peer-unreachable", message: error.message }), + ), + ); + yield* Ref.update(forwards, (current) => + new Map(current).set(peerId, { + scope, + handle, + address: transport.tailcat.address, + port: transport.tailcat.port, + lastUsedAtMs: 0, + }), + ); + const touchedAt = yield* nowMs; + yield* Ref.update(forwards, (current) => { + const entry = current.get(peerId); + return entry === undefined + ? current + : new Map(current).set(peerId, { ...entry, lastUsedAtMs: touchedAt }); + }); + yield* Effect.logInfo("Federation transport ready.", { + peerId, + localPort: handle.localPort, + pid: handle.pid, + }); + return { + httpBaseUrl: handle.httpBaseUrl, + localPort: handle.localPort, + } satisfies PeerEndpoint; + }), + ); + + // Closes the peer's forward under its lock only while `shouldClose` still holds, + // so a caller that reused the forward in the meantime keeps it. + const dropWhen = (peerId: EnvironmentId, shouldClose: (forward: ActiveForward) => boolean) => + withPeerLock( + peerId, + Effect.gen(function* () { + const existing = (yield* Ref.get(forwards)).get(peerId); + if (existing === undefined || !shouldClose(existing)) { + return false; + } + yield* Ref.update(forwards, (current) => withoutKey(current, peerId)); + yield* closeForward(existing); + return true; + }), + ); + const drop: FederationTransport["Service"]["drop"] = (peerId) => + dropWhen(peerId, () => true).pipe(Effect.asVoid); + + const isActive: FederationTransport["Service"]["isActive"] = (peerId) => + Ref.get(forwards).pipe( + Effect.flatMap((current) => { + const existing = current.get(peerId); + return existing === undefined ? Effect.succeed(false) : existing.handle.isRunning; + }), + ); + + // Idle forwards are child processes with keepalive traffic; close the ones + // nobody has called through recently. + const sweepIdle = Effect.gen(function* () { + const cutoff = (yield* nowMs) - Duration.toMillis(FORWARD_IDLE_TTL); + const current = yield* Ref.get(forwards); + for (const [peerId, forward] of current) { + if (forward.lastUsedAtMs < cutoff) { + const closed = yield* dropWhen(peerId, (latest) => latest.lastUsedAtMs < cutoff); + if (closed) { + yield* Effect.logInfo("Federation transport closed after idling.", { peerId }); + } + } + } + }); + yield* Effect.sleep(FORWARD_IDLE_SWEEP_INTERVAL).pipe( + Effect.andThen(sweepIdle), + Effect.forever, + Effect.forkIn(serviceScope), + ); + + yield* Scope.addFinalizer( + serviceScope, + Ref.get(forwards).pipe( + Effect.flatMap((current) => + Effect.forEach(current.values(), closeForward, { discard: true, concurrency: "unbounded" }), + ), + ), + ); + + return FederationTransport.of({ + clientNodeKey, + endpointFor, + drop, + isActive, + }); +}); + +export const layer = Layer.effect(FederationTransport, make); diff --git a/apps/server/src/federation/http.ts b/apps/server/src/federation/http.ts new file mode 100644 index 000000000000..3bee37b2a713 --- /dev/null +++ b/apps/server/src/federation/http.ts @@ -0,0 +1,124 @@ +import { EnvironmentAuthenticatedPrincipal, EnvironmentHttpApi } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; + +import { annotateEnvironmentRequest } from "../auth/http.ts"; +import * as FederationService from "./FederationService.ts"; + +/** + * Peer-facing federation endpoints. Pairing and authentication are open by + * design (they establish trust and sessions); everything else runs under the + * ordinary session middleware and then checks the peer's federation grant. + */ +export const federationHttpApiLayer = HttpApiBuilder.group( + EnvironmentHttpApi, + "federation", + Effect.fnUntraced(function* (handlers) { + const federation = yield* FederationService.FederationService; + + const peerFor = (required: Parameters[1]) => + EnvironmentAuthenticatedPrincipal.pipe( + Effect.flatMap((principal) => + federation.authorizePeer( + { subject: principal.subject, scopes: principal.scopes }, + required, + ), + ), + ); + + return handlers + .handle( + "pair", + Effect.fn("environment.federation.pair")(function* (args) { + yield* annotateEnvironmentRequest(args.endpoint.name); + return yield* federation.acceptPair(args.payload); + }), + ) + .handle( + "challenge", + Effect.fn("environment.federation.challenge")(function* (args) { + yield* annotateEnvironmentRequest(args.endpoint.name); + return yield* federation.issueChallenge(args.payload); + }), + ) + .handle( + "token", + Effect.fn("environment.federation.token")(function* (args) { + yield* annotateEnvironmentRequest(args.endpoint.name); + return yield* federation.redeemChallenge(args.payload); + }), + ) + .handle( + "hello", + Effect.fn("environment.federation.hello")(function* (args) { + yield* annotateEnvironmentRequest(args.endpoint.name); + yield* peerFor("environment.read"); + return yield* federation.hello; + }), + ) + .handle( + "projects", + Effect.fn("environment.federation.projects")(function* (args) { + yield* annotateEnvironmentRequest(args.endpoint.name); + yield* peerFor("projects.read"); + return yield* federation.localProjects; + }), + ) + .handle( + "startRun", + Effect.fn("environment.federation.startRun")(function* (args) { + yield* annotateEnvironmentRequest(args.endpoint.name); + const peer = yield* peerFor("runs.start"); + return yield* federation.startLocalRun(peer, args.payload); + }), + ) + .handle( + "runStatus", + Effect.fn("environment.federation.runStatus")(function* (args) { + yield* annotateEnvironmentRequest(args.endpoint.name); + const peer = yield* peerFor("runs.read"); + return yield* federation.localRunStatus(peer, args.params.threadId); + }), + ) + .handle( + "cancelRun", + Effect.fn("environment.federation.cancelRun")(function* (args) { + yield* annotateEnvironmentRequest(args.endpoint.name); + const peer = yield* peerFor("runs.cancel"); + return yield* federation.cancelLocalRun(peer, args.params.threadId); + }), + ) + .handle( + "runEvents", + Effect.fn("environment.federation.runEvents")(function* (args) { + yield* annotateEnvironmentRequest(args.endpoint.name); + const peer = yield* peerFor("runs.read"); + return yield* federation.localRunEvents( + peer, + args.params.threadId, + args.payload.afterSequence ?? 0, + ); + }), + ) + .handle( + "runArtifacts", + Effect.fn("environment.federation.runArtifacts")(function* (args) { + yield* annotateEnvironmentRequest(args.endpoint.name); + const peer = yield* peerFor("artifacts.read"); + return yield* federation.localRunArtifacts(peer, args.params.threadId); + }), + ) + .handle( + "fetchArtifact", + Effect.fn("environment.federation.fetchArtifact")(function* (args) { + yield* annotateEnvironmentRequest(args.endpoint.name); + const peer = yield* peerFor("artifacts.read"); + return yield* federation.fetchLocalArtifact( + peer, + args.params.threadId, + args.params.turnId, + ); + }), + ); + }), +); diff --git a/apps/server/src/federation/runProjection.test.ts b/apps/server/src/federation/runProjection.test.ts new file mode 100644 index 000000000000..002d39228bd4 --- /dev/null +++ b/apps/server/src/federation/runProjection.test.ts @@ -0,0 +1,478 @@ +import { + CheckpointRef, + EnvironmentId, + EventId, + MessageId, + type OrchestrationCheckpointSummary, + type OrchestrationEvent, + type OrchestrationLatestTurn, + type OrchestrationLatestTurnState, + type OrchestrationThreadShell, + ProjectId, + ProviderInstanceId, + ThreadId, + TurnId, +} from "@t3tools/contracts"; +import { describe, expect, it } from "@effect/vitest"; + +import { + FEDERATION_PREVIEW_MAX_CHARS, + federationRunStatus, + isFederationRunActive, + projectFederationArtifacts, + projectFederationRun, + summarizeFederationRunEvent, + truncatePreview, +} from "./runProjection.ts"; + +const environmentId = EnvironmentId.make("environment-origin"); +const projectId = ProjectId.make("project-t3code"); +const threadId = ThreadId.make("thread-fix-checkpoints"); +const otherThreadId = ThreadId.make("thread-unrelated"); +const turnId = TurnId.make("turn-1"); +const messageId = MessageId.make("message-1"); +const createdAt = "2026-03-01T09:00:00.000Z"; +const requestedAt = "2026-03-01T09:30:00.000Z"; +const startedAt = "2026-03-01T09:30:01.000Z"; +const completedAt = "2026-03-01T09:42:17.000Z"; +const occurredAt = "2026-03-01T09:31:00.000Z"; +const modelSelection = { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", +} as const; + +const makeThreadShell = ( + overrides: Partial = {}, +): OrchestrationThreadShell => ({ + id: threadId, + projectId, + title: "Fix flaky checkpoint test", + modelSelection, + runtimeMode: "full-access", + interactionMode: "default", + branch: null, + worktreePath: null, + latestTurn: null, + createdAt, + updatedAt: createdAt, + archivedAt: null, + settledOverride: null, + settledAt: null, + session: null, + latestUserMessageAt: null, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + ...overrides, +}); + +const makeLatestTurn = ( + overrides: Partial = {}, +): OrchestrationLatestTurn => ({ + turnId, + state: "running", + requestedAt, + startedAt, + completedAt: null, + assistantMessageId: null, + ...overrides, +}); + +const eventBase = ( + sequence: number, + aggregateId: ThreadId | ProjectId = threadId, + aggregateKind: "thread" | "project" = "thread", +) => ({ + sequence, + eventId: EventId.make(`event-${sequence}`), + aggregateKind, + aggregateId, + occurredAt, + commandId: null, + causationEventId: null, + correlationId: null, + metadata: {}, +}); + +const messageSentEvent = (input: { + readonly text: string; + readonly role?: "user" | "assistant"; + readonly aggregateId?: ThreadId; +}): OrchestrationEvent => { + const aggregateId = input.aggregateId ?? threadId; + return { + ...eventBase(7, aggregateId), + type: "thread.message-sent", + payload: { + threadId: aggregateId, + messageId, + role: input.role ?? "user", + text: input.text, + turnId, + streaming: false, + createdAt: occurredAt, + updatedAt: occurredAt, + }, + }; +}; + +const sessionSetEvent = (lastError: string | null): OrchestrationEvent => ({ + ...eventBase(9), + type: "thread.session-set", + payload: { + threadId, + session: { + threadId, + status: lastError === null ? "ready" : "error", + providerName: "codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError, + updatedAt: occurredAt, + }, + }, +}); + +const turnDiffCompletedEvent = (fileCount: number): OrchestrationEvent => ({ + ...eventBase(11), + type: "thread.turn-diff-completed", + payload: { + threadId, + turnId, + checkpointTurnCount: 1, + checkpointRef: CheckpointRef.make("refs/t3/checkpoints/thread-fix-checkpoints/1"), + status: "ready", + files: Array.from({ length: fileCount }, (_, index) => ({ + path: `src/file-${index}.ts`, + kind: "modified", + additions: 3, + deletions: 1, + })), + assistantMessageId: null, + completedAt, + }, +}); + +const makeCheckpoint = ( + overrides: Partial = {}, +): OrchestrationCheckpointSummary => ({ + turnId, + checkpointTurnCount: 1, + checkpointRef: CheckpointRef.make("refs/t3/checkpoints/thread-fix-checkpoints/1"), + status: "ready", + files: [ + { path: "src/server.ts", kind: "modified", additions: 12, deletions: 4 }, + { path: "src/server.test.ts", kind: "added", additions: 40, deletions: 0 }, + ], + assistantMessageId: null, + completedAt, + ...overrides, +}); + +describe("federationRunStatus", () => { + it.each<{ readonly state: OrchestrationLatestTurnState | null; readonly expected: string }>([ + { state: null, expected: "queued" }, + { state: "running", expected: "running" }, + { state: "completed", expected: "completed" }, + { state: "interrupted", expected: "interrupted" }, + { state: "error", expected: "error" }, + ])("maps latest turn state $state to $expected", ({ state, expected }) => { + expect(federationRunStatus(state)).toBe(expected); + }); +}); + +describe("truncatePreview", () => { + it("collapses whitespace runs and trims the edges", () => { + expect(truncatePreview(" Refactor\n\n the reactor\t queue ")).toBe( + "Refactor the reactor queue", + ); + }); + + it("leaves text at the limit untouched", () => { + const text = "a".repeat(FEDERATION_PREVIEW_MAX_CHARS); + expect(truncatePreview(text)).toBe(text); + }); + + it("cuts longer text to the limit and ends it with an ellipsis", () => { + const preview = truncatePreview("b".repeat(FEDERATION_PREVIEW_MAX_CHARS + 60)); + expect(preview).toHaveLength(FEDERATION_PREVIEW_MAX_CHARS); + expect(preview).toBe(`${"b".repeat(FEDERATION_PREVIEW_MAX_CHARS - 1)}…`); + }); + + it("honors a custom limit", () => { + expect(truncatePreview("abcdefgh", 5)).toBe("abcd…"); + }); +}); + +describe("projectFederationRun", () => { + it("reports a thread without a turn as queued, timed from its creation", () => { + const run = projectFederationRun({ + environmentId, + thread: makeThreadShell(), + assistantPreview: null, + turnCount: 0, + }); + + expect(run).toEqual({ + environmentId, + projectId, + threadId, + turnId: null, + title: "Fix flaky checkpoint test", + status: "queued", + runtimeMode: "full-access", + modelSelection, + requestedAt: createdAt, + startedAt: null, + completedAt: null, + assistantPreview: null, + turnCount: 0, + }); + }); + + it("reports a running turn with its own timestamps", () => { + const run = projectFederationRun({ + environmentId, + thread: makeThreadShell({ latestTurn: makeLatestTurn() }), + assistantPreview: "Looking at the flaky test…", + turnCount: 1, + }); + + expect(run).toMatchObject({ + turnId, + status: "running", + requestedAt, + startedAt, + completedAt: null, + assistantPreview: "Looking at the flaky test…", + turnCount: 1, + }); + }); + + it.each<{ readonly state: OrchestrationLatestTurnState; readonly expected: string }>([ + { state: "completed", expected: "completed" }, + { state: "interrupted", expected: "interrupted" }, + { state: "error", expected: "error" }, + ])("reports a $state turn as $expected with its completion time", ({ state, expected }) => { + const run = projectFederationRun({ + environmentId, + thread: makeThreadShell({ + latestTurn: makeLatestTurn({ state, completedAt }), + }), + assistantPreview: "Done.", + turnCount: 3, + }); + + expect(run.status).toBe(expected); + expect(run.completedAt).toBe(completedAt); + expect(run.startedAt).toBe(startedAt); + }); +}); + +describe("isFederationRunActive", () => { + const baseRun = projectFederationRun({ + environmentId, + thread: makeThreadShell(), + assistantPreview: null, + turnCount: 0, + }); + + it("treats queued and running runs as active", () => { + expect(isFederationRunActive({ ...baseRun, status: "queued" })).toBe(true); + expect(isFederationRunActive({ ...baseRun, status: "running" })).toBe(true); + }); + + it("treats settled runs as inactive", () => { + expect(isFederationRunActive({ ...baseRun, status: "completed" })).toBe(false); + expect(isFederationRunActive({ ...baseRun, status: "interrupted" })).toBe(false); + expect(isFederationRunActive({ ...baseRun, status: "error" })).toBe(false); + }); +}); + +describe("summarizeFederationRunEvent", () => { + it("ignores events that belong to another thread", () => { + const event = messageSentEvent({ text: "hello", aggregateId: otherThreadId }); + expect(summarizeFederationRunEvent(event, threadId)).toBeNull(); + }); + + it("ignores project events even when the aggregate id matches", () => { + const event: OrchestrationEvent = { + ...eventBase(3, threadId, "project"), + type: "project.created", + payload: { + projectId, + title: "t3code", + workspaceRoot: "/home/dev/t3code", + defaultModelSelection: modelSelection, + scripts: [], + createdAt, + updatedAt: createdAt, + }, + }; + expect(summarizeFederationRunEvent(event, threadId)).toBeNull(); + }); + + it("ignores thread events that carry nothing worth relaying", () => { + const event: OrchestrationEvent = { + ...eventBase(4), + type: "thread.archived", + payload: { threadId, archivedAt: occurredAt, updatedAt: occurredAt }, + }; + expect(summarizeFederationRunEvent(event, threadId)).toBeNull(); + }); + + it("relays a sent message with its role and the event position", () => { + const summary = summarizeFederationRunEvent( + messageSentEvent({ text: "Please fix the\nflaky test", role: "user" }), + threadId, + ); + + expect(summary).toEqual({ + sequence: 7, + at: occurredAt, + type: "thread.message-sent", + summary: "user: Please fix the flaky test", + }); + }); + + it("truncates long message text after the role prefix", () => { + const summary = summarizeFederationRunEvent( + messageSentEvent({ text: "x".repeat(1_000), role: "assistant" }), + threadId, + ); + + expect(summary?.summary.startsWith("assistant: ")).toBe(true); + expect(summary?.summary.endsWith("…")).toBe(true); + expect(summary?.summary).toHaveLength("assistant: ".length + FEDERATION_PREVIEW_MAX_CHARS); + }); + + it("describes turn lifecycle requests", () => { + const started: OrchestrationEvent = { + ...eventBase(8), + type: "thread.turn-start-requested", + payload: { + threadId, + messageId, + runtimeMode: "full-access", + interactionMode: "default", + createdAt: occurredAt, + }, + }; + const interrupted: OrchestrationEvent = { + ...eventBase(10), + type: "thread.turn-interrupt-requested", + payload: { threadId, turnId, createdAt: occurredAt }, + }; + + expect(summarizeFederationRunEvent(started, threadId)?.summary).toBe("Turn started"); + expect(summarizeFederationRunEvent(interrupted, threadId)?.summary).toBe("Interrupt requested"); + }); + + it("describes session changes, including the last error when there is one", () => { + expect(summarizeFederationRunEvent(sessionSetEvent(null), threadId)?.summary).toBe( + "Session ready", + ); + expect( + summarizeFederationRunEvent(sessionSetEvent("codex exited with code 1"), threadId)?.summary, + ).toBe("Session error: codex exited with code 1"); + }); + + it("counts the files in a completed turn diff", () => { + expect(summarizeFederationRunEvent(turnDiffCompletedEvent(1), threadId)?.summary).toBe( + "Changes recorded (1 file)", + ); + expect(summarizeFederationRunEvent(turnDiffCompletedEvent(3), threadId)?.summary).toBe( + "Changes recorded (3 files)", + ); + expect(summarizeFederationRunEvent(turnDiffCompletedEvent(0), threadId)?.summary).toBe( + "Changes recorded (0 files)", + ); + }); + + it("relays activity summaries, truncated", () => { + const event: OrchestrationEvent = { + ...eventBase(12), + type: "thread.activity-appended", + payload: { + threadId, + activity: { + id: EventId.make("activity-12"), + tone: "tool", + kind: "tool-call", + summary: `Ran vitest ${"-".repeat(FEDERATION_PREVIEW_MAX_CHARS)}`, + payload: { command: "vitest" }, + turnId, + createdAt: occurredAt, + }, + }, + }; + + const summary = summarizeFederationRunEvent(event, threadId); + expect(summary?.type).toBe("thread.activity-appended"); + expect(summary?.summary.startsWith("Ran vitest ")).toBe(true); + expect(summary?.summary).toHaveLength(FEDERATION_PREVIEW_MAX_CHARS); + }); +}); + +describe("projectFederationArtifacts", () => { + it("projects ready checkpoints as turn diffs stamped with their origin", () => { + const secondTurnId = TurnId.make("turn-2"); + const artifacts = projectFederationArtifacts({ + environmentId, + threadId, + checkpoints: [ + makeCheckpoint(), + makeCheckpoint({ + turnId: secondTurnId, + checkpointTurnCount: 2, + checkpointRef: CheckpointRef.make("refs/t3/checkpoints/thread-fix-checkpoints/2"), + files: [{ path: "README.md", kind: "deleted", additions: 0, deletions: 20 }], + }), + ], + }); + + expect(artifacts).toEqual([ + { + environmentId, + threadId, + turnId, + kind: "turn-diff", + fromTurnCount: 0, + toTurnCount: 1, + files: [ + { path: "src/server.ts", status: "modified" }, + { path: "src/server.test.ts", status: "added" }, + ], + }, + { + environmentId, + threadId, + turnId: secondTurnId, + kind: "turn-diff", + fromTurnCount: 1, + toTurnCount: 2, + files: [{ path: "README.md", status: "deleted" }], + }, + ]); + }); + + it("skips checkpoints that are not ready and the pre-turn baseline", () => { + const artifacts = projectFederationArtifacts({ + environmentId, + threadId, + checkpoints: [ + makeCheckpoint({ status: "missing" }), + makeCheckpoint({ status: "error" }), + makeCheckpoint({ checkpointTurnCount: 0 }), + makeCheckpoint({ turnId: TurnId.make("turn-3"), checkpointTurnCount: 3 }), + ], + }); + + expect(artifacts.map((artifact) => artifact.turnId)).toEqual([TurnId.make("turn-3")]); + expect(artifacts[0]).toMatchObject({ fromTurnCount: 2, toTurnCount: 3 }); + }); + + it("returns nothing for a thread without checkpoints", () => { + expect(projectFederationArtifacts({ environmentId, threadId, checkpoints: [] })).toEqual([]); + }); +}); diff --git a/apps/server/src/federation/runProjection.ts b/apps/server/src/federation/runProjection.ts new file mode 100644 index 000000000000..8f9fff6e06d6 --- /dev/null +++ b/apps/server/src/federation/runProjection.ts @@ -0,0 +1,127 @@ +import type { + EnvironmentId, + FederationArtifactRef, + FederationRun, + FederationRunEvent, + FederationRunStatus, + OrchestrationCheckpointSummary, + OrchestrationEvent, + OrchestrationLatestTurnState, + OrchestrationThreadShell, + ThreadId, +} from "@t3tools/contracts"; + +/** + * Pure projections from orchestration state onto the federation protocol. + * Runs are threads the peer started; the projection deliberately exposes the + * few facts a coordinating environment needs and nothing about the rest of + * the thread. + */ + +export const FEDERATION_PREVIEW_MAX_CHARS = 240; + +export function federationRunStatus( + state: OrchestrationLatestTurnState | null, +): FederationRunStatus { + switch (state) { + case null: + return "queued"; + case "running": + return "running"; + case "completed": + return "completed"; + case "interrupted": + return "interrupted"; + case "error": + return "error"; + } +} + +export function truncatePreview(text: string, max = FEDERATION_PREVIEW_MAX_CHARS): string { + const collapsed = text.replace(/\s+/gu, " ").trim(); + return collapsed.length <= max ? collapsed : `${collapsed.slice(0, max - 1)}…`; +} + +export function projectFederationRun(input: { + readonly environmentId: EnvironmentId; + readonly thread: OrchestrationThreadShell; + readonly assistantPreview: string | null; + readonly turnCount: number; +}): FederationRun { + const latestTurn = input.thread.latestTurn; + return { + environmentId: input.environmentId, + projectId: input.thread.projectId, + threadId: input.thread.id, + turnId: latestTurn?.turnId ?? null, + title: input.thread.title, + status: federationRunStatus(latestTurn?.state ?? null), + runtimeMode: input.thread.runtimeMode, + modelSelection: input.thread.modelSelection, + requestedAt: latestTurn?.requestedAt ?? input.thread.createdAt, + startedAt: latestTurn?.startedAt ?? null, + completedAt: latestTurn?.completedAt ?? null, + assistantPreview: input.assistantPreview, + turnCount: input.turnCount, + }; +} + +export function isFederationRunActive(run: FederationRun): boolean { + return run.status === "queued" || run.status === "running"; +} + +/** Summarizes one persisted event for a peer; null when it carries nothing worth relaying. */ +export function summarizeFederationRunEvent( + event: OrchestrationEvent, + threadId: ThreadId, +): FederationRunEvent | null { + if (event.aggregateKind !== "thread" || event.aggregateId !== threadId) { + return null; + } + const base = { sequence: event.sequence, at: event.occurredAt, type: event.type }; + switch (event.type) { + case "thread.message-sent": + return { + ...base, + summary: `${event.payload.role}: ${truncatePreview(event.payload.text)}`, + }; + case "thread.turn-start-requested": + return { ...base, summary: "Turn started" }; + case "thread.turn-interrupt-requested": + return { ...base, summary: "Interrupt requested" }; + case "thread.session-set": + return { + ...base, + summary: event.payload.session.lastError + ? `Session ${event.payload.session.status}: ${truncatePreview(event.payload.session.lastError)}` + : `Session ${event.payload.session.status}`, + }; + case "thread.turn-diff-completed": + return { + ...base, + summary: `Changes recorded (${event.payload.files.length} ${event.payload.files.length === 1 ? "file" : "files"})`, + }; + case "thread.activity-appended": + return { ...base, summary: truncatePreview(event.payload.activity.summary) }; + default: + return null; + } +} + +export function projectFederationArtifacts(input: { + readonly environmentId: EnvironmentId; + readonly threadId: ThreadId; + readonly checkpoints: ReadonlyArray; +}): ReadonlyArray { + return input.checkpoints + .filter((checkpoint) => checkpoint.status === "ready" && checkpoint.checkpointTurnCount > 0) + .map((checkpoint) => ({ + environmentId: input.environmentId, + threadId: input.threadId, + turnId: checkpoint.turnId, + kind: "turn-diff" as const, + fromTurnCount: checkpoint.checkpointTurnCount - 1, + toTurnCount: checkpoint.checkpointTurnCount, + files: checkpoint.files.map((file) => ({ path: file.path, status: file.kind })), + })); +} diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index d972e2e00803..b92dbec79547 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -93,6 +93,8 @@ const decodeTransferShellSnapshot = Schema.decodeUnknownEffect( import * as BackgroundPolicy from "./background/BackgroundPolicy.ts"; import * as ServerConfig from "./config.ts"; import { HTTP_ROUTER_CONFIG, makeRoutesLayer } from "./server.ts"; +import * as FederationService from "./federation/FederationService.ts"; +import * as TailcatRemoteAccess from "./tailcat/TailcatRemoteAccess.ts"; import { isThreadDetailEvent, resolveAvailableEditorsForConfig, @@ -562,6 +564,8 @@ const buildAppUnderTest = (options?: { logWebSocketEvents: false, tailscaleServeEnabled: false, tailscaleServePort: 443, + tailcatEnabled: undefined, + tailcatBinaryPath: undefined, ...options?.config, }; const layerConfig = ServerConfig.layer(config); @@ -717,6 +721,14 @@ const buildAppUnderTest = (options?: { Layer.provide(Layer.succeed(HostProcessEnvironment, {})), ); + // Tailcat and federation are exercised by their own tests; here they only + // need to exist so the auth token exchange and RPC layer can resolve them. + const tailcatRemoteAccessLayer = Layer.mock(TailcatRemoteAccess.TailcatRemoteAccess)({ + readyEndpoint: Effect.succeed(Option.none()), + recordTrustedPeer: () => Effect.void, + start: () => Effect.void, + }); + const federationLayer = Layer.mock(FederationService.FederationService)({}); const servedRoutesLayer = HttpRouter.serve( makeRoutesLayer.pipe(Layer.provide(serviceLauncherClientLayer)), { @@ -979,6 +991,8 @@ const buildAppUnderTest = (options?: { const appLayer = servedRoutesLayer.pipe( Layer.provide(resourceTelemetryLayer), + Layer.provide(tailcatRemoteAccessLayer), + Layer.provide(federationLayer), Layer.provide(UsageService.layerTest), Layer.provide( Layer.mock(AnalyticsService.AnalyticsService)({ diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 3a93adc6d761..23567a655995 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -4,6 +4,7 @@ import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Layer from "effect/Layer"; import * as Schedule from "effect/Schedule"; +import * as Console from "effect/Console"; import * as Stream from "effect/Stream"; import { FetchHttpClient, HttpRouter, HttpServer } from "effect/unstable/http"; import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; @@ -126,6 +127,15 @@ import { persistServerRuntimeState, } from "./serverRuntimeState.ts"; import { orchestrationHttpApiLayer } from "./orchestration/http.ts"; +import { federationHttpApiLayer } from "./federation/http.ts"; +import * as FederationIdentity from "./federation/FederationIdentity.ts"; +import * as FederationPeerStore from "./federation/FederationPeerStore.ts"; +import * as FederationService from "./federation/FederationService.ts"; +import * as FederationTransport from "./federation/FederationTransport.ts"; +import { tailcatHttpApiLayer } from "./tailcat/http.ts"; +import * as TailcatRemoteAccess from "./tailcat/TailcatRemoteAccess.ts"; +import * as TailcatRuntimeLive from "./tailcat/TailcatRuntimeLive.ts"; +import { formatTailcatHeadlessOutput } from "./tailcat/startupOutput.ts"; import * as NetService from "@t3tools/shared/Net"; import * as RelayClient from "@t3tools/shared/relayClient"; import { disableTailscaleServe, ensureTailscaleServe } from "@t3tools/tailscale"; @@ -496,7 +506,7 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( ), ); -const RuntimeDependenciesLive = RuntimeCoreDependenciesLive.pipe( +const RuntimeBaseDependenciesLive = RuntimeCoreDependenciesLive.pipe( // Misc. Layer.provideMerge(BackgroundLayerLive), Layer.provideMerge(ResourceDiagnosticsLayerLive), @@ -509,6 +519,26 @@ const RuntimeDependenciesLive = RuntimeCoreDependenciesLive.pipe( Layer.provide(NetService.layer), ); +// Tailcat exposes this environment's loopback listener; federation rides on it. +// Both consume the runtime above (auth, orchestration, checkpoints, workspace). +const TailcatRemoteAccessLayerLive = TailcatRemoteAccess.layer.pipe( + Layer.provide(TailcatRuntimeLive.layer), +); +const FederationLayerLive = FederationService.layer.pipe( + Layer.provideMerge( + FederationTransport.layer.pipe( + Layer.provide(TailcatRuntimeLive.layer), + Layer.provide(NetService.layer), + ), + ), + Layer.provideMerge(FederationPeerStore.layer), + Layer.provideMerge(FederationIdentity.layer.pipe(Layer.provide(ServerSecretStore.layer))), +); +const RuntimeDependenciesLive = FederationLayerLive.pipe( + Layer.provideMerge(TailcatRemoteAccessLayerLive), + Layer.provideMerge(RuntimeBaseDependenciesLive), +); + const commandReadinessLayer = HttpRouter.middleware( (httpEffect) => Effect.flatMap(ServerRuntimeStartup.ServerRuntimeStartup, (startup) => @@ -525,6 +555,8 @@ export const makeRoutesLayer = Layer.mergeAll( Layer.provide(orchestrationHttpApiLayer), Layer.provide(pullRequestHttpApiLayer), Layer.provide(serverEnvironmentHttpApiLayer), + Layer.provide(tailcatHttpApiLayer), + Layer.provide(federationHttpApiLayer), Layer.provide(environmentAuthenticatedAuthLayer), ), otlpTracesProxyRouteLayer, @@ -553,6 +585,7 @@ export const makeServerLayer = Layer.unwrap( const activationLayer = Layer.succeed(ServerActivation, awaitActivation); const runtimeStateParked = yield* Deferred.make(); const tailscaleParked = yield* Deferred.make(); + const tailcatParked = yield* Deferred.make(); const cloudLinkParked = yield* Deferred.make(); const routesReady = yield* Deferred.make(); const launcherLayer = ServiceLauncherClient.layer; @@ -651,6 +684,54 @@ export const makeServerLayer = Layer.unwrap( ), ) : Layer.empty; + // Tailcat learns the bound loopback port once the listener is up, then + // starts serving if remote access is enabled (persisted or `--tailcat`). + const tailcatStartLayer = Layer.effectDiscard( + Effect.gen(function* () { + yield* Deferred.succeed(tailcatParked, undefined).pipe(Effect.orDie); + yield* awaitActivation; + const server = yield* HttpServer.HttpServer; + const address = server.address; + if (typeof address === "string" || !("port" in address)) { + return; + } + const remoteAccess = yield* TailcatRemoteAccess.TailcatRemoteAccess; + yield* remoteAccess.start({ localPort: address.port }); + if (config.tailcatEnabled !== true || config.startupPresentation !== "headless") { + return; + } + // Headless `t3 serve --tailcat`: print a one-time connection code once + // the Tailcat listener is reachable, like the pairing URL for HTTP. + yield* Effect.forkScoped( + Stream.concat(Stream.fromEffect(remoteAccess.state), remoteAccess.changes).pipe( + Stream.filter( + (state) => + state.status === "ready" || + state.status === "error" || + state.status === "unavailable", + ), + Stream.take(1), + Stream.runHead, + Effect.flatMap((settled) => + settled._tag === "Some" && settled.value.status === "ready" + ? remoteAccess + .createConnectionCode({}) + .pipe( + Effect.flatMap((issued) => + Console.log(formatTailcatHeadlessOutput(settled.value, issued)), + ), + ) + : Effect.logWarning("Tailcat remote access did not become ready.", { + error: settled._tag === "Some" ? settled.value.lastError : null, + }), + ), + Effect.catch((error) => + Effect.logWarning("Could not print the Tailcat connection code.", { error }), + ), + ), + ); + }), + ); const cloudDesiredLinkReconcileLayer = Layer.effectDiscard( Effect.gen(function* () { if (!hasCloudPublicConfig) { @@ -729,6 +810,7 @@ export const makeServerLayer = Layer.unwrap( Deferred.await(runtimeStateParked), Deferred.await(cloudLinkParked), Deferred.await(routesReady), + Deferred.await(tailcatParked), ...(config.tailscaleServeEnabled ? [Deferred.await(tailscaleParked)] : []), ], { concurrency: "unbounded" }, @@ -744,6 +826,7 @@ export const makeServerLayer = Layer.unwrap( httpListeningLayer, runtimeStateLayer, tailscaleServeLayer, + tailcatStartLayer, cloudDesiredLinkReconcileLayer, ); diff --git a/apps/server/src/tailcat/TailcatRemoteAccess.test.ts b/apps/server/src/tailcat/TailcatRemoteAccess.test.ts new file mode 100644 index 000000000000..d14de416d9d6 --- /dev/null +++ b/apps/server/src/tailcat/TailcatRemoteAccess.test.ts @@ -0,0 +1,440 @@ +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import { + AuthSessionId, + EnvironmentId, + type ExecutionEnvironmentDescriptor, + TAILCAT_CONNECTION_CODE_DEFAULT_TTL_SECONDS, + TAILCAT_CONNECTION_CODE_PAIRING_SUBJECT, + type TailcatAddress, + type TailcatNodeKey, + type TailcatRemoteAccessState, + type TailcatRuntimeInfo, + TailcatTrustedPeer, +} from "@t3tools/contracts"; +import { decodeTailcatConnectionCode } from "@t3tools/shared/t3ConnectionCode"; +import * as TailcatRuntime from "@t3tools/tailcat/runtime"; +import * as Clock from "effect/Clock"; +import * as Context from "effect/Context"; +import * as Deferred from "effect/Deferred"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +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 Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; +import * as Schema from "effect/Schema"; +import * as Stream from "effect/Stream"; +import * as TestClock from "effect/testing/TestClock"; + +import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; +import * as PairingGrantStore from "../auth/PairingGrantStore.ts"; +import * as ServerSecretStore from "../auth/ServerSecretStore.ts"; +import * as ServerConfig from "../config.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; +import { SqlitePersistenceMemory } from "../persistence/Layers/Sqlite.ts"; +import * as TailcatRemoteAccess from "./TailcatRemoteAccess.ts"; + +// Captured from a real `tailcat serve` run; decodes to server key 7ea7…ff32. +const SERVER_ADDRESS: TailcatAddress = + "tco2FwWCB-p3FjjOrzlCPp0w8aT3p9xDZ1nNaXWX_dASxDCFT_MmFrWCDRnh2-iykbZ7W4Fl0g3nBpwTnR3iXVCKKCk4pps47ndGFpGQEu"; +const SERVER_FINGERPRINT = "7ea7·7163·ff32"; +const PEER_NODE_KEY: TailcatNodeKey = + "nodekey:9ab555a4a588b75d2054adb683db82461bb6c707d43e8ba39439f8eb1e821503"; +const LOCAL_PORT = 3773; +/** Mirrors the service's relock debounce: one adjust lets a pending reconcile run. */ +const RELOCK_DEBOUNCE = Duration.millis(1_500); +/** Ceiling of the first-failure restart backoff (1s base plus 25% jitter). */ +const FIRST_RETRY_BACKOFF_MAX = Duration.millis(1_250); +const RUNTIME_INFO: TailcatRuntimeInfo = { + executablePath: "/opt/t3/bin/tailcat", + source: "bundled", + version: "0.4.2", + pinnedVersion: "0.4.2", + compatible: true, +}; +const ENVIRONMENT_ID = EnvironmentId.make("environment-tailcat-test"); +const DESCRIPTOR: ExecutionEnvironmentDescriptor = { + environmentId: ENVIRONMENT_ID, + label: "Tailcat test environment", + platform: { os: "linux", arch: "x64" }, + serverVersion: "0.0.0-test", + capabilities: { repositoryIdentity: true }, +}; + +interface FakeServe { + readonly options: { + readonly keyPath: string; + readonly localPort: number; + readonly allow: TailcatRuntime.TailcatAllowPolicy; + }; + /** Complete this to simulate the listener process dying. */ + readonly exit: Deferred.Deferred>; + /** False once the owning scope closed, i.e. the service stopped this listener. */ + readonly isRunning: Effect.Effect; +} + +/** Records what the service asked of the tailcat runtime; `Queue.take` is the receipt for a (re)started listener. */ +class FakeTailcat extends Context.Service< + FakeTailcat, + { + readonly serves: Queue.Queue; + readonly identityGenerations: Ref.Ref; + } +>()("t3/tailcat/TailcatRemoteAccess.test/FakeTailcat") { + static readonly layer = Layer.effect( + FakeTailcat, + Effect.gen(function* () { + return FakeTailcat.of({ + serves: yield* Queue.unbounded(), + identityGenerations: yield* Ref.make(0), + }); + }), + ); +} + +const fakeRuntimeLayer = Layer.unwrap( + Effect.gen(function* () { + const fake = yield* FakeTailcat; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + let nextPid = 40_000; + return Layer.mock(TailcatRuntime.TailcatRuntime)({ + resolve: Effect.succeed(RUNTIME_INFO), + refresh: Effect.succeed(RUNTIME_INFO), + generateServerIdentity: ({ keyPath }) => + Effect.gen(function* () { + yield* fileSystem.makeDirectory(path.dirname(keyPath), { recursive: true }); + yield* fileSystem.writeFileString(keyPath, "fake tailcat identity"); + yield* Ref.update(fake.identityGenerations, (count) => count + 1); + return { address: SERVER_ADDRESS }; + }).pipe(Effect.orDie), + serve: (options) => + Effect.gen(function* () { + const exit = yield* Deferred.make>(); + const running = yield* Ref.make(true); + const stop = Ref.set(running, false).pipe( + Effect.andThen(Deferred.succeed(exit, Option.none())), + Effect.asVoid, + ); + yield* Effect.addFinalizer(() => stop); + const handle: TailcatRuntime.TailcatServeHandle = { + pid: nextPid++, + address: SERVER_ADDRESS, + localPort: options.localPort, + allow: options.allow, + exit: Deferred.await(exit), + isRunning: Ref.get(running), + recentOutput: Effect.succeed([`listening on 127.0.0.1:${options.localPort}`]), + stop, + }; + yield* Queue.offer(fake.serves, { options, exit, isRunning: Ref.get(running) }); + return handle; + }), + }); + }), +).pipe(Layer.provideMerge(FakeTailcat.layer)); + +const authLayer = EnvironmentAuth.layer.pipe( + Layer.provide(SqlitePersistenceMemory), + Layer.provide(ServerSecretStore.layer), + Layer.provide( + Layer.mock(ServerEnvironment.ServerEnvironmentIdentity)({ + getEnvironmentId: Effect.succeed(ENVIRONMENT_ID), + }), + ), +); + +const serverEnvironmentLayer = Layer.mock(ServerEnvironment.ServerEnvironment)({ + getEnvironmentId: Effect.succeed(ENVIRONMENT_ID), + getDescriptor: Effect.succeed(DESCRIPTOR), +}); + +const makeTestLayer = () => + TailcatRemoteAccess.layer.pipe( + Layer.provideMerge(fakeRuntimeLayer), + Layer.provideMerge(authLayer), + Layer.provide(serverEnvironmentLayer), + Layer.provideMerge( + ServerConfig.layerTest(process.cwd(), { prefix: "t3-tailcat-remote-access-test-" }), + ), + ); + +const PersistedStateJson = Schema.fromJsonString( + Schema.Struct({ + version: Schema.Literal(1), + enabled: Schema.Boolean, + trustedPeers: Schema.Array(TailcatTrustedPeer), + }), +); +const decodePersistedState = Schema.decodeUnknownSync(PersistedStateJson); + +const readPersistedState = Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const raw = yield* fileSystem.readFileString( + path.join(config.stateDir, TailcatRemoteAccess.TAILCAT_REMOTE_ACCESS_STATE_FILE), + ); + return decodePersistedState(raw); +}); + +/** + * `changes` only carries publishes made after subscribing, so the watcher is + * forked (and subscribed) synchronously before the caller triggers anything. + * Join it to get the first published state matching `predicate`. + */ +const watchState = (predicate: (state: TailcatRemoteAccessState) => boolean) => + Effect.gen(function* () { + const service = yield* TailcatRemoteAccess.TailcatRemoteAccess; + return yield* Effect.forkChild( + service.changes.pipe(Stream.filter(predicate), Stream.runHead, Effect.map(Option.getOrThrow)), + { startImmediately: true }, + ); + }); + +/** Binds the service to the local port, enables it, and waits for the first listener. */ +const startEnabled = Effect.gen(function* () { + const service = yield* TailcatRemoteAccess.TailcatRemoteAccess; + const fake = yield* FakeTailcat; + const ready = yield* watchState((state) => state.status === "ready"); + yield* service.start({ localPort: LOCAL_PORT }); + const enabled = yield* service.setEnabled(true); + yield* TestClock.adjust(RELOCK_DEBOUNCE); + const serve = yield* Queue.take(fake.serves); + const state = yield* Fiber.join(ready); + return { enabled, serve, state }; +}); + +it.layer(NodeServices.layer)("TailcatRemoteAccess", (it) => { + it.effect("stays disabled and spawns nothing while remote access is off", () => + Effect.gen(function* () { + const service = yield* TailcatRemoteAccess.TailcatRemoteAccess; + const fake = yield* FakeTailcat; + const reconcileAt = (yield* Clock.currentTimeMillis) + Duration.toMillis(RELOCK_DEBOUNCE); + const reconciled = yield* watchState((state) => Date.parse(state.updatedAt) >= reconcileAt); + + yield* service.start({ localPort: LOCAL_PORT }); + yield* TestClock.adjust(RELOCK_DEBOUNCE); + const state = yield* Fiber.join(reconciled); + + expect(state).toMatchObject({ + enabled: false, + status: "disabled", + address: null, + pairingOpen: false, + trustedPeers: [], + runtime: null, + identityFingerprint: null, + lastError: null, + }); + expect(yield* Queue.size(fake.serves)).toBe(0); + expect(yield* Ref.get(fake.identityGenerations)).toBe(0); + expect(yield* service.readyEndpoint).toEqual(Option.none()); + }).pipe(Effect.provide(makeTestLayer())), + ); + + it.effect("enabling creates the identity once and serves a locked listener", () => + Effect.gen(function* () { + const service = yield* TailcatRemoteAccess.TailcatRemoteAccess; + const fake = yield* FakeTailcat; + const config = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + + const { enabled, serve, state } = yield* startEnabled; + + // setEnabled answers immediately; the listener comes up after the debounce. + expect(enabled.enabled).toBe(true); + expect(enabled.status).toBe("disabled"); + expect(serve.options).toEqual({ + keyPath: path.join(config.secretsDir, TailcatRemoteAccess.TAILCAT_SERVER_IDENTITY_FILE), + localPort: LOCAL_PORT, + allow: { _tag: "keys", nodeKeys: [] }, + }); + expect(yield* Ref.get(fake.identityGenerations)).toBe(1); + expect(yield* fileSystem.exists(serve.options.keyPath)).toBe(true); + expect(state).toMatchObject({ + enabled: true, + status: "ready", + address: SERVER_ADDRESS, + remotePort: LOCAL_PORT, + pairingOpen: false, + trustedPeers: [], + runtime: RUNTIME_INFO, + identityFingerprint: SERVER_FINGERPRINT, + lastError: null, + }); + expect(yield* service.readyEndpoint).toEqual( + Option.some({ address: SERVER_ADDRESS, port: LOCAL_PORT }), + ); + expect((yield* readPersistedState).enabled).toBe(true); + }).pipe(Effect.provide(makeTestLayer())), + ); + + it.effect("a connection code carries a one-time pairing token and opens the listener", () => + Effect.gen(function* () { + const service = yield* TailcatRemoteAccess.TailcatRemoteAccess; + const fake = yield* FakeTailcat; + const pairingLinks = yield* PairingGrantStore.PairingGrantStore; + const { serve: locked } = yield* startEnabled; + + const opened = yield* watchState((state) => state.pairingOpen && state.status === "ready"); + const issuedAt = yield* Clock.currentTimeMillis; + const result = yield* service.createConnectionCode({}); + const payload = decodeTailcatConnectionCode(result.code); + + expect(result.code.startsWith("t3c://tailcat/")).toBe(true); + expect(payload).toEqual(result.payload); + expect(payload).toMatchObject({ + v: 1, + transport: "tailcat", + address: SERVER_ADDRESS, + port: LOCAL_PORT, + environmentId: ENVIRONMENT_ID, + name: DESCRIPTOR.label, + serverVersion: DESCRIPTOR.serverVersion, + expiresAt: result.expiresAt, + }); + expect(Date.parse(result.expiresAt) - issuedAt).toBe( + TAILCAT_CONNECTION_CODE_DEFAULT_TTL_SECONDS * 1_000, + ); + const link = (yield* pairingLinks.listActive()).find( + (candidate) => candidate.id === result.pairingLinkId, + ); + expect(link?.subject).toBe(TAILCAT_CONNECTION_CODE_PAIRING_SUBJECT); + expect(link?.credential).toBe(payload.pairingToken); + + yield* TestClock.adjust(RELOCK_DEBOUNCE); + const open = yield* Queue.take(fake.serves); + const state = yield* Fiber.join(opened); + + expect(open.options.allow).toEqual({ _tag: "all" }); + expect(yield* locked.isRunning).toBe(false); + expect(state.address).toBe(SERVER_ADDRESS); + }).pipe(Effect.provide(makeTestLayer())), + ); + + it.effect("relocks the listener once the connection code expires", () => + Effect.gen(function* () { + const service = yield* TailcatRemoteAccess.TailcatRemoteAccess; + const fake = yield* FakeTailcat; + yield* startEnabled; + yield* service.createConnectionCode({ ttlSeconds: 60 }); + yield* TestClock.adjust(RELOCK_DEBOUNCE); + const open = yield* Queue.take(fake.serves); + expect(open.options.allow).toEqual({ _tag: "all" }); + + const closed = yield* watchState((state) => !state.pairingOpen && state.status === "ready"); + // Past the code's expiry (plus the service's grace second), then the debounce. + yield* TestClock.adjust(Duration.seconds(61)); + yield* TestClock.adjust(RELOCK_DEBOUNCE); + const relocked = yield* Queue.take(fake.serves); + const state = yield* Fiber.join(closed); + + expect(relocked.options.allow).toEqual({ _tag: "keys", nodeKeys: [] }); + expect(yield* open.isRunning).toBe(false); + expect(state.pairingOpen).toBe(false); + }).pipe(Effect.provide(makeTestLayer())), + ); + + it.effect("trusted peers are persisted, admitted on relock, and revocable", () => + Effect.gen(function* () { + const service = yield* TailcatRemoteAccess.TailcatRemoteAccess; + const fake = yield* FakeTailcat; + const { serve: locked } = yield* startEnabled; + const sessionId = AuthSessionId.make("session-julius-iphone"); + + yield* service.recordTrustedPeer({ + nodeKey: PEER_NODE_KEY, + label: " Julius iPhone ", + sessionId, + }); + const recorded = yield* service.state; + expect(recorded.trustedPeers).toHaveLength(1); + const peer = recorded.trustedPeers[0]!; + expect(peer).toMatchObject({ + nodeKey: PEER_NODE_KEY, + label: "Julius iPhone", + sessionIds: [sessionId], + }); + expect((yield* readPersistedState).trustedPeers).toEqual([peer]); + + yield* TestClock.adjust(RELOCK_DEBOUNCE); + const admitting = yield* Queue.take(fake.serves); + expect(admitting.options.allow).toEqual({ _tag: "keys", nodeKeys: [PEER_NODE_KEY] }); + expect(yield* locked.isRunning).toBe(false); + + const revoked = yield* service.revokeTrustedPeer(peer.id); + expect(revoked.trustedPeers).toEqual([]); + expect((yield* readPersistedState).trustedPeers).toEqual([]); + + yield* TestClock.adjust(RELOCK_DEBOUNCE); + const relocked = yield* Queue.take(fake.serves); + expect(relocked.options.allow).toEqual({ _tag: "keys", nodeKeys: [] }); + expect(yield* admitting.isRunning).toBe(false); + + const missing = yield* Effect.flip(service.revokeTrustedPeer(peer.id)); + expect(missing.code).toBe("unknown"); + }).pipe(Effect.provide(makeTestLayer())), + ); + + it.effect("an unexpected listener exit is reported and retried after the backoff", () => + Effect.gen(function* () { + const fake = yield* FakeTailcat; + const { serve: first } = yield* startEnabled; + + const failed = yield* watchState((state) => state.status === "error"); + yield* Deferred.succeed(first.exit, Option.some(1)); + const errorState = yield* Fiber.join(failed); + + expect(errorState.lastError).toMatchObject({ code: "process-exited" }); + expect(errorState.lastError?.message).toContain("exited (1)"); + // A transient failure keeps the stable address; only permanent ones drop it. + expect(errorState.address).toBe(SERVER_ADDRESS); + + const restarted = yield* watchState( + (state) => state.status === "ready" && state.lastError === null, + ); + yield* TestClock.adjust(FIRST_RETRY_BACKOFF_MAX); + yield* TestClock.adjust(RELOCK_DEBOUNCE); + const second = yield* Queue.take(fake.serves); + const readyState = yield* Fiber.join(restarted); + + expect(second.options.allow).toEqual({ _tag: "keys", nodeKeys: [] }); + expect(readyState.address).toBe(SERVER_ADDRESS); + // The identity file survived the restart, so no new address was minted. + expect(yield* Ref.get(fake.identityGenerations)).toBe(1); + }).pipe(Effect.provide(makeTestLayer())), + ); + + it.effect("disabling stops the listener and reports disabled", () => + Effect.gen(function* () { + const service = yield* TailcatRemoteAccess.TailcatRemoteAccess; + const fake = yield* FakeTailcat; + const { serve } = yield* startEnabled; + + const disabled = yield* watchState((state) => state.status === "disabled"); + const returned = yield* service.setEnabled(false); + expect(returned.enabled).toBe(false); + + yield* TestClock.adjust(RELOCK_DEBOUNCE); + const state = yield* Fiber.join(disabled); + + expect(state).toMatchObject({ + enabled: false, + status: "disabled", + address: null, + identityFingerprint: null, + lastError: null, + }); + expect(yield* serve.isRunning).toBe(false); + expect(yield* service.readyEndpoint).toEqual(Option.none()); + expect((yield* readPersistedState).enabled).toBe(false); + expect(yield* Queue.size(fake.serves)).toBe(0); + }).pipe(Effect.provide(makeTestLayer())), + ); +}); diff --git a/apps/server/src/tailcat/TailcatRemoteAccess.ts b/apps/server/src/tailcat/TailcatRemoteAccess.ts new file mode 100644 index 000000000000..464bd84cbf3e --- /dev/null +++ b/apps/server/src/tailcat/TailcatRemoteAccess.ts @@ -0,0 +1,773 @@ +import { + AuthStandardClientScopes, + type AuthSessionId, + FEDERATION_PEER_CODE_PAIRING_SUBJECT, + TAILCAT_CONNECTION_CODE_DEFAULT_TTL_SECONDS, + TAILCAT_CONNECTION_CODE_PAIRING_SUBJECT, + type TailcatAddress, + type TailcatConnectionCodeResult, + type TailcatCreateConnectionCodeInput, + type TailcatFailure, + type TailcatFailureCode, + type TailcatNodeKey, + TailcatRemoteAccessError, + type TailcatRemoteAccessState, + type TailcatRuntimeInfo, + type TailcatServeStatus, + TailcatTrustedPeer, + tailcatNodeKeyFingerprint, +} from "@t3tools/contracts"; +import { encodeTailcatConnectionCode } from "@t3tools/shared/t3ConnectionCode"; +import { decodeTailcatAddress } from "@t3tools/tailcat/address"; +import { tailcatBackoffDelayMs } from "@t3tools/tailcat/backoff"; +import { + type TailcatRuntimeError, + isTailcatRuntimeError, + tailcatFailureCode, +} from "@t3tools/tailcat/errors"; +import * as TailcatRuntime from "@t3tools/tailcat/runtime"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as Fiber from "effect/Fiber"; +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 Queue from "effect/Queue"; +import * as Random from "effect/Random"; +import * as Ref from "effect/Ref"; +import * as Result from "effect/Result"; +import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; + +import { writeFileStringAtomically } from "../atomicWrite.ts"; +import * as EnvironmentAuth from "../auth/EnvironmentAuth.ts"; +import * as PairingGrantStore from "../auth/PairingGrantStore.ts"; +import * as ServerConfig from "../config.ts"; +import * as ServerEnvironment from "../environment/ServerEnvironment.ts"; + +/** + * TailcatRemoteAccess makes this environment reachable over Tailcat. + * + * It owns one `tailcat serve` child that fronts the server's loopback listener, + * the server's Tailcat identity (a key file in the secrets directory, so the + * address is stable across restarts), and the list of trusted peers. Tailcat's + * CLI takes its allowlist at startup, so the listener is restarted whenever the + * trusted set changes: + * + * - locked: `--allow=` (or `none` while nobody is trusted) + * - open: no allowlist while a connection code is active, so a new device + * can reach the T3 pairing endpoint; T3 auth still gates everything + * + * Pairing over Tailcat is the ordinary T3 pairing flow. The token exchange that + * consumes a connection code reports the client's node key here, which adds it + * to the trusted set; the next relock only admits trusted keys. + */ + +const isTailcatRemoteAccessError = Schema.is(TailcatRemoteAccessError); + +export const TAILCAT_REMOTE_ACCESS_STATE_FILE = "tailcat-remote-access.json"; +export const TAILCAT_SERVER_IDENTITY_FILE = "tailcat-server-identity.private.json"; +const RELOCK_DEBOUNCE = Duration.millis(1_500); +const EXPIRY_GRACE = Duration.seconds(1); + +/** Pairing-link subjects whose active links open the Tailcat pairing window. */ +const PAIRING_WINDOW_SUBJECTS: ReadonlySet = new Set([ + TAILCAT_CONNECTION_CODE_PAIRING_SUBJECT, + FEDERATION_PEER_CODE_PAIRING_SUBJECT, +]); + +const PersistedTailcatRemoteAccess = Schema.Struct({ + version: Schema.Literal(1), + enabled: Schema.Boolean, + trustedPeers: Schema.Array(TailcatTrustedPeer), +}); +type PersistedTailcatRemoteAccess = typeof PersistedTailcatRemoteAccess.Type; + +const PersistedTailcatRemoteAccessJson = Schema.fromJsonString(PersistedTailcatRemoteAccess); +const decodePersistedState = Schema.decodeUnknownEffect(PersistedTailcatRemoteAccessJson); +const encodePersistedState = Schema.encodeEffect(PersistedTailcatRemoteAccessJson); + +const EMPTY_PERSISTED_STATE: PersistedTailcatRemoteAccess = { + version: 1, + enabled: false, + trustedPeers: [], +}; + +export class TailcatRemoteAccess extends Context.Service< + TailcatRemoteAccess, + { + readonly state: Effect.Effect; + readonly changes: Stream.Stream; + /** The address and port peers should dial while Tailcat access is enabled and serving. */ + readonly readyEndpoint: Effect.Effect< + Option.Option<{ readonly address: TailcatAddress; readonly port: number }> + >; + /** Binds the service to the server's listening port and starts reconciling. */ + readonly start: (input: { readonly localPort: number }) => Effect.Effect; + readonly setEnabled: ( + enabled: boolean, + ) => Effect.Effect; + readonly createConnectionCode: ( + input: TailcatCreateConnectionCodeInput, + ) => Effect.Effect; + /** Called by the token exchange that consumed a Tailcat connection code. */ + readonly recordTrustedPeer: (input: { + readonly nodeKey: TailcatNodeKey; + readonly label: string | undefined; + /** The T3 session issued alongside the pairing, revoked with the peer. */ + readonly sessionId?: AuthSessionId; + }) => Effect.Effect; + readonly revokeTrustedPeer: ( + peerId: string, + ) => Effect.Effect; + readonly renameTrustedPeer: (input: { + readonly peerId: string; + readonly label: string; + }) => Effect.Effect; + readonly regenerateIdentity: Effect.Effect; + } +>()("t3/tailcat/TailcatRemoteAccess") {} + +interface RunningServe { + readonly scope: Scope.Closeable; + readonly handle: TailcatRuntime.TailcatServeHandle; +} + +interface RuntimeState { + readonly localPort: number | null; + readonly running: RunningServe | null; + readonly status: TailcatServeStatus; + readonly address: TailcatAddress | null; + readonly pairingOpen: boolean; + readonly failures: number; + readonly lastError: TailcatFailure | null; + readonly runtime: TailcatRuntimeInfo | null; +} + +const INITIAL_RUNTIME_STATE: RuntimeState = { + localPort: null, + running: null, + status: "disabled", + address: null, + pairingOpen: false, + failures: 0, + lastError: null, + runtime: null, +}; + +function allowPolicyEquals( + left: TailcatRuntime.TailcatAllowPolicy, + right: TailcatRuntime.TailcatAllowPolicy, +): boolean { + if (left._tag !== right._tag) return false; + if (left._tag === "keys" && right._tag === "keys") { + const a = [...left.nodeKeys].sort(); + const b = [...right.nodeKeys].sort(); + return a.length === b.length && a.every((key, index) => key === b[index]); + } + return true; +} + +function failureOf( + error: TailcatRuntimeError | TailcatRemoteAccessError, + at: string, +): TailcatFailure { + if (isTailcatRuntimeError(error)) { + return { code: tailcatFailureCode(error), message: error.message, at }; + } + return { code: error.code, message: error.message, at }; +} + +const isPermanentFailure = (code: TailcatFailureCode): boolean => + code === "binary-missing" || + code === "binary-not-executable" || + code === "version-incompatible" || + code === "identity-failed"; + +export const make = Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const runtime = yield* TailcatRuntime.TailcatRuntime; + const environmentAuth = yield* EnvironmentAuth.EnvironmentAuth; + const pairingLinks = yield* PairingGrantStore.PairingGrantStore; + const serverEnvironment = yield* ServerEnvironment.ServerEnvironment; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const crypto = yield* Crypto.Crypto; + const serviceScope = yield* Scope.Scope; + + const statePath = path.join(config.stateDir, TAILCAT_REMOTE_ACCESS_STATE_FILE); + const identityPath = path.join(config.secretsDir, TAILCAT_SERVER_IDENTITY_FILE); + + const now = DateTime.now.pipe(Effect.map(DateTime.formatIso)); + + const readPersisted = Effect.gen(function* () { + const raw = yield* fileSystem.readFileString(statePath).pipe(Effect.option); + if (Option.isNone(raw) || raw.value.trim().length === 0) { + return EMPTY_PERSISTED_STATE; + } + return yield* decodePersistedState(raw.value).pipe( + Effect.catch((cause) => + Effect.logWarning("Tailcat remote access state is unreadable; starting from defaults.", { + statePath, + cause, + }).pipe(Effect.as(EMPTY_PERSISTED_STATE)), + ), + ); + }); + + const persisted = yield* Ref.make(yield* readPersisted); + const runtimeState = yield* Ref.make(INITIAL_RUNTIME_STATE); + const signals = yield* Queue.unbounded<"reconcile">(); + const expiryTimer = yield* Ref.make>>(Option.none()); + const retryTimer = yield* Ref.make>>(Option.none()); + + const persistError = (cause: unknown) => + new TailcatRemoteAccessError({ + code: "unknown", + message: `Could not save Tailcat remote access settings: ${String(cause)}`, + }); + + const writePersisted = (next: PersistedTailcatRemoteAccess) => + encodePersistedState(next).pipe( + Effect.flatMap((contents) => + writeFileStringAtomically({ filePath: statePath, contents: `${contents}\n` }), + ), + Effect.mapError(persistError), + Effect.andThen(Ref.set(persisted, next)), + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ); + + const buildState = Effect.gen(function* () { + const saved = yield* Ref.get(persisted); + const current = yield* Ref.get(runtimeState); + const fingerprint = + current.address === null + ? null + : Result.match(decodeTailcatAddress(current.address), { + onFailure: () => null, + onSuccess: (decoded) => tailcatNodeKeyFingerprint(decoded.serverNodeKey), + }); + return { + enabled: saved.enabled, + status: current.status, + address: current.address, + remotePort: current.localPort, + pairingOpen: current.pairingOpen, + trustedPeers: saved.trustedPeers, + runtime: current.runtime, + identityFingerprint: fingerprint, + lastError: current.lastError, + updatedAt: yield* now, + } satisfies TailcatRemoteAccessState; + }); + + const published = yield* SubscriptionRef.make(yield* buildState); + const publish = buildState.pipe(Effect.tap((state) => SubscriptionRef.set(published, state))); + + const signalReconcile = Queue.offer(signals, "reconcile").pipe(Effect.asVoid); + + const listActiveConnectionCodes = pairingLinks.listActive().pipe( + Effect.map((links) => links.filter((link) => PAIRING_WINDOW_SUBJECTS.has(link.subject))), + Effect.catch((cause) => + Effect.logWarning("Could not list Tailcat connection codes; treating none as active.", { + cause, + }).pipe(Effect.as([])), + ), + ); + + // Both timers are one-shot wake-ups for the reconcile loop; only the delay differs. + type TimerSlot = Ref.Ref>>; + const disarmTimer = (slot: TimerSlot) => + Ref.getAndSet(slot, Option.none()).pipe( + Effect.flatMap( + Option.match({ onNone: () => Effect.void, onSome: (fiber) => Fiber.interrupt(fiber) }), + ), + ); + const armTimer = (slot: TimerSlot, delayMs: number) => + Effect.sleep(Duration.millis(delayMs)).pipe( + Effect.andThen(signalReconcile), + Effect.forkIn(serviceScope), + Effect.flatMap((fiber) => Ref.set(slot, Option.some(fiber))), + ); + + /** + * The pairing window is derived, never stored: it is open exactly while an + * unconsumed, unexpired connection code exists. Expiry does not emit a store + * event, so a timer re-evaluates at the earliest expiry. + */ + const refreshPairingWindow = Effect.gen(function* () { + const active = yield* listActiveConnectionCodes; + const open = active.length > 0; + yield* disarmTimer(expiryTimer); + if (open) { + const currentMs = yield* DateTime.now.pipe(Effect.map(DateTime.toEpochMillis)); + const earliestExpiry = Math.min( + ...active.map((link) => DateTime.toEpochMillis(link.expiresAt)), + ); + yield* armTimer( + expiryTimer, + Math.max(0, earliestExpiry - currentMs) + Duration.toMillis(EXPIRY_GRACE), + ); + } + const previous = yield* Ref.get(runtimeState); + yield* Ref.update(runtimeState, (current) => ({ ...current, pairingOpen: open })); + return previous.pairingOpen !== open; + }); + + const ensureIdentity = Effect.gen(function* () { + const exists = yield* fileSystem.exists(identityPath).pipe(Effect.orElseSucceed(() => false)); + if (exists) { + return; + } + yield* Effect.logInfo("Creating the Tailcat server identity.", { identityPath }); + yield* runtime.generateServerIdentity({ keyPath: identityPath }); + }); + + const stopRunning = Effect.gen(function* () { + const current = yield* Ref.get(runtimeState); + if (current.running === null) { + return; + } + yield* Ref.update(runtimeState, (state) => ({ ...state, running: null })); + yield* Scope.close(current.running.scope, Exit.void).pipe(Effect.ignore); + yield* Effect.logInfo("Tailcat listener stopped.", { pid: current.running.handle.pid }); + }); + + const desiredAllowPolicy = Effect.gen(function* () { + const saved = yield* Ref.get(persisted); + const current = yield* Ref.get(runtimeState); + if (current.pairingOpen) { + return { _tag: "all" } as const satisfies TailcatRuntime.TailcatAllowPolicy; + } + return { + _tag: "keys", + nodeKeys: saved.trustedPeers.map((peer) => peer.nodeKey), + } as const satisfies TailcatRuntime.TailcatAllowPolicy; + }); + + const scheduleRetry = (failures: number) => + Effect.gen(function* () { + yield* disarmTimer(retryTimer); + yield* armTimer(retryTimer, tailcatBackoffDelayMs(failures, yield* Random.next)); + }); + + const recordFailure = (error: TailcatRuntimeError | TailcatRemoteAccessError) => + Effect.gen(function* () { + const at = yield* now; + const failure = failureOf(error, at); + const permanent = isPermanentFailure(failure.code); + const next = yield* Ref.updateAndGet(runtimeState, (state) => ({ + ...state, + status: permanent ? ("unavailable" as const) : ("error" as const), + address: permanent ? null : state.address, + failures: state.failures + 1, + lastError: failure, + })); + yield* Effect.logWarning("Tailcat listener failed.", { + code: failure.code, + message: failure.message, + failures: next.failures, + permanent, + }); + if (!permanent) { + yield* scheduleRetry(next.failures); + } + }); + + const startServe = (allow: TailcatRuntime.TailcatAllowPolicy, localPort: number) => + Effect.gen(function* () { + yield* Ref.update(runtimeState, (state) => ({ + ...state, + status: state.address === null ? ("starting" as const) : ("restarting" as const), + })); + yield* publish; + const info = yield* runtime.resolve; + yield* Ref.update(runtimeState, (state) => ({ ...state, runtime: info })); + yield* ensureIdentity.pipe( + Effect.mapError( + (error) => + new TailcatRemoteAccessError({ + code: "identity-failed", + message: `Could not prepare the Tailcat identity: ${error.message}`, + }), + ), + ); + const scope = yield* Scope.make("sequential"); + const handle = yield* runtime.serve({ keyPath: identityPath, localPort, allow }).pipe( + Effect.provideService(Scope.Scope, scope), + Effect.onError(() => Scope.close(scope, Exit.void).pipe(Effect.ignore)), + ); + const running: RunningServe = { scope, handle }; + yield* Ref.update(runtimeState, (state) => ({ + ...state, + running, + status: "ready" as const, + address: handle.address, + failures: 0, + lastError: null, + })); + yield* Effect.logInfo("Tailcat listener ready.", { + pid: handle.pid, + localPort, + allow: allow._tag, + trustedPeerCount: allow._tag === "keys" ? allow.nodeKeys.length : null, + }); + // Watch for an unexpected exit. A stop we initiated replaces `running` + // first, so only the still-current handle schedules a restart. + yield* handle.exit.pipe( + Effect.flatMap((exitCode) => + Effect.gen(function* () { + const current = yield* Ref.get(runtimeState); + if (current.running?.handle !== handle) { + return; + } + const recentOutput = yield* handle.recentOutput; + yield* Ref.update(runtimeState, (state) => ({ ...state, running: null })); + yield* Scope.close(scope, Exit.void).pipe(Effect.ignore); + yield* recordFailure( + new TailcatRemoteAccessError({ + code: "process-exited", + message: + recentOutput.at(-1) !== undefined + ? `The Tailcat listener exited (${Option.getOrNull(exitCode) ?? "signal"}): ${recentOutput.at(-1)}` + : `The Tailcat listener exited unexpectedly (${Option.getOrNull(exitCode) ?? "signal"}).`, + }), + ); + yield* publish; + }), + ), + Effect.forkIn(serviceScope), + ); + }); + + const reconcile = Effect.gen(function* () { + const saved = yield* Ref.get(persisted); + const current = yield* Ref.get(runtimeState); + if (current.localPort === null) { + return; + } + if (!saved.enabled) { + yield* stopRunning; + yield* Ref.update(runtimeState, (state) => ({ + ...state, + status: "disabled" as const, + address: null, + failures: 0, + lastError: null, + })); + return; + } + const allow = yield* desiredAllowPolicy; + if (current.running !== null && allowPolicyEquals(current.running.handle.allow, allow)) { + return; + } + if (current.running !== null) { + yield* Effect.logInfo("Tailcat allowlist changed; restarting the listener.", { + allow: allow._tag, + }); + yield* stopRunning; + } + yield* startServe(allow, current.localPort).pipe( + Effect.catch((error) => + isTailcatRuntimeError(error) || isTailcatRemoteAccessError(error) + ? recordFailure(error) + : Effect.die(error), + ), + ); + }); + + const reconcileLoop = Effect.gen(function* () { + for (;;) { + yield* Queue.take(signals); + // Coalesce bursts (a consumed code plus its recorded peer arrive together). + yield* Effect.sleep(RELOCK_DEBOUNCE); + yield* Queue.clear(signals); + yield* refreshPairingWindow; + yield* reconcile; + yield* publish; + } + }); + yield* reconcileLoop.pipe(Effect.forkIn(serviceScope)); + + yield* pairingLinks.streamChanges.pipe( + Stream.filter( + (change) => + change.type === "pairingLinkRemoved" || + PAIRING_WINDOW_SUBJECTS.has(change.pairingLink.subject), + ), + Stream.runForEach(() => signalReconcile), + Effect.forkIn(serviceScope), + ); + + yield* Scope.addFinalizer( + serviceScope, + Effect.gen(function* () { + const current = yield* Ref.get(runtimeState); + if (current.running !== null) { + yield* Scope.close(current.running.scope, Exit.void).pipe(Effect.ignore); + } + }), + ); + + const requireEnabledAndReady = Effect.gen(function* () { + const saved = yield* Ref.get(persisted); + const current = yield* Ref.get(runtimeState); + if (!saved.enabled) { + return yield* new TailcatRemoteAccessError({ + code: "unknown", + message: "Enable Tailcat access before creating a connection code.", + }); + } + if (current.address === null || current.localPort === null) { + return yield* new TailcatRemoteAccessError({ + code: current.lastError?.code ?? "startup-failed", + message: current.lastError?.message ?? "Tailcat is still starting. Try again in a moment.", + }); + } + return { address: current.address, localPort: current.localPort }; + }); + + const createConnectionCode: TailcatRemoteAccess["Service"]["createConnectionCode"] = Effect.fn( + "TailcatRemoteAccess.createConnectionCode", + )(function* (input) { + const ready = yield* requireEnabledAndReady; + const descriptor = yield* serverEnvironment.getDescriptor; + const ttlSeconds = input.ttlSeconds ?? TAILCAT_CONNECTION_CODE_DEFAULT_TTL_SECONDS; + const issued = yield* environmentAuth + .createPairingLink({ + scopes: AuthStandardClientScopes, + subject: TAILCAT_CONNECTION_CODE_PAIRING_SUBJECT, + label: input.label ?? "Tailcat connection code", + ttl: Duration.seconds(ttlSeconds), + }) + .pipe( + Effect.mapError( + (cause) => + new TailcatRemoteAccessError({ + code: "unknown", + message: `Could not issue a pairing credential: ${cause.message}`, + }), + ), + ); + const expiresAt = DateTime.formatIso(issued.expiresAt); + const payload = { + v: 1 as const, + transport: "tailcat" as const, + address: ready.address, + port: ready.localPort, + environmentId: descriptor.environmentId, + name: descriptor.label, + serverVersion: descriptor.serverVersion, + pairingToken: issued.credential, + expiresAt, + }; + // The window opens through the pairing-link change stream; nudge it so the + // listener reopens without waiting for the debounce to notice on its own. + yield* signalReconcile; + yield* Effect.logInfo("Tailcat connection code issued.", { + pairingLinkId: issued.id, + expiresAt, + }); + return { + code: encodeTailcatConnectionCode(payload), + payload, + pairingLinkId: issued.id, + expiresAt, + } satisfies TailcatConnectionCodeResult; + }); + + const setEnabled: TailcatRemoteAccess["Service"]["setEnabled"] = Effect.fn( + "TailcatRemoteAccess.setEnabled", + )(function* (enabled) { + const saved = yield* Ref.get(persisted); + if (saved.enabled !== enabled) { + yield* writePersisted({ ...saved, enabled }); + yield* Effect.logInfo(enabled ? "Tailcat access enabled." : "Tailcat access disabled."); + } + if (enabled) { + // Clear a stale permanent failure so a retry actually happens after the + // user installed or repaired the runtime. + yield* Ref.update(runtimeState, (state) => ({ ...state, failures: 0 })); + yield* runtime.refresh.pipe(Effect.ignore); + } + yield* signalReconcile; + return yield* publish; + }); + + const recordTrustedPeer: TailcatRemoteAccess["Service"]["recordTrustedPeer"] = Effect.fn( + "TailcatRemoteAccess.recordTrustedPeer", + )(function* (input) { + const saved = yield* Ref.get(persisted); + const at = yield* now; + const existing = saved.trustedPeers.find((peer) => peer.nodeKey === input.nodeKey); + const label = input.label?.trim() || existing?.label || "Paired device"; + const sessionIds = input.sessionId === undefined ? [] : [input.sessionId]; + const updated = existing + ? { + ...existing, + label, + lastSeenAt: at, + sessionIds: [ + ...existing.sessionIds, + ...sessionIds.filter((sessionId) => !existing.sessionIds.includes(sessionId)), + ], + } + : { + id: yield* crypto.randomUUIDv4.pipe( + Effect.mapError( + (cause) => + new TailcatRemoteAccessError({ + code: "unknown", + message: `Could not allocate a peer id: ${String(cause)}`, + }), + ), + ), + nodeKey: input.nodeKey, + label, + createdAt: at, + lastSeenAt: at, + sessionIds, + }; + const peers = existing + ? saved.trustedPeers.map((peer) => (peer === existing ? updated : peer)) + : [...saved.trustedPeers, updated]; + yield* writePersisted({ ...saved, trustedPeers: peers }); + yield* Effect.logInfo(existing ? "Tailcat peer re-paired." : "Tailcat peer trusted.", { + fingerprint: tailcatNodeKeyFingerprint(input.nodeKey), + label, + }); + yield* signalReconcile; + yield* publish; + }); + + const revokeTrustedPeer: TailcatRemoteAccess["Service"]["revokeTrustedPeer"] = Effect.fn( + "TailcatRemoteAccess.revokeTrustedPeer", + )(function* (peerId) { + const saved = yield* Ref.get(persisted); + const peer = saved.trustedPeers.find((candidate) => candidate.id === peerId); + if (peer === undefined) { + return yield* new TailcatRemoteAccessError({ + code: "unknown", + message: "That device is no longer in the trusted list.", + }); + } + yield* writePersisted({ + ...saved, + trustedPeers: saved.trustedPeers.filter((candidate) => candidate.id !== peerId), + }); + yield* Effect.forEach( + peer.sessionIds, + (sessionId) => environmentAuth.revokeSession(sessionId).pipe(Effect.ignore), + { discard: true }, + ); + yield* Effect.logInfo("Tailcat peer revoked.", { + fingerprint: tailcatNodeKeyFingerprint(peer.nodeKey), + revokedSessions: peer.sessionIds.length, + }); + yield* signalReconcile; + return yield* publish; + }); + + const renameTrustedPeer: TailcatRemoteAccess["Service"]["renameTrustedPeer"] = Effect.fn( + "TailcatRemoteAccess.renameTrustedPeer", + )(function* ({ peerId, label }) { + const saved = yield* Ref.get(persisted); + if (!saved.trustedPeers.some((peer) => peer.id === peerId)) { + return yield* new TailcatRemoteAccessError({ + code: "unknown", + message: "That device is no longer in the trusted list.", + }); + } + const trimmed = label.trim(); + if (trimmed.length === 0) { + return yield* new TailcatRemoteAccessError({ + code: "unknown", + message: "A device name cannot be empty.", + }); + } + yield* writePersisted({ + ...saved, + trustedPeers: saved.trustedPeers.map((peer) => + peer.id === peerId ? { ...peer, label: trimmed } : peer, + ), + }); + return yield* publish; + }); + + const regenerateIdentity: TailcatRemoteAccess["Service"]["regenerateIdentity"] = Effect.gen( + function* () { + yield* stopRunning; + yield* fileSystem.remove(identityPath, { force: true }).pipe( + Effect.mapError( + (cause) => + new TailcatRemoteAccessError({ + code: "identity-failed", + message: `Could not remove the previous Tailcat identity: ${String(cause)}`, + }), + ), + ); + yield* Ref.update(runtimeState, (state) => ({ + ...state, + address: null, + failures: 0, + lastError: null, + })); + yield* Effect.logInfo("Tailcat identity regenerated; connected devices must re-pair."); + yield* signalReconcile; + return yield* publish; + }, + ).pipe(Effect.withSpan("TailcatRemoteAccess.regenerateIdentity")); + + const start: TailcatRemoteAccess["Service"]["start"] = Effect.fn("TailcatRemoteAccess.start")( + function* ({ localPort }) { + const current = yield* Ref.get(runtimeState); + if (current.localPort !== null) { + return; + } + yield* Ref.update(runtimeState, (state) => ({ ...state, localPort })); + if (config.tailcatEnabled === true) { + const saved = yield* Ref.get(persisted); + if (!saved.enabled) { + yield* writePersisted({ ...saved, enabled: true }).pipe( + Effect.catch((error) => + Effect.logWarning("Could not persist the Tailcat enable flag.", { error }), + ), + ); + } + } + yield* signalReconcile; + }, + ); + + return TailcatRemoteAccess.of({ + readyEndpoint: Effect.gen(function* () { + const current = yield* Ref.get(runtimeState); + const saved = yield* Ref.get(persisted); + // Only while the listener is up (or bouncing for a relock): a failed or + // unavailable listener must not be advertised in codes. + const serving = current.status === "ready" || current.status === "restarting"; + return saved.enabled && serving && current.address !== null && current.localPort !== null + ? Option.some({ address: current.address, port: current.localPort }) + : Option.none(); + }), + state: SubscriptionRef.get(published), + changes: SubscriptionRef.changes(published), + start, + setEnabled, + createConnectionCode, + recordTrustedPeer, + revokeTrustedPeer, + renameTrustedPeer, + regenerateIdentity, + }); +}); + +export const layer = Layer.effect(TailcatRemoteAccess, make); diff --git a/apps/server/src/tailcat/TailcatRuntimeLive.ts b/apps/server/src/tailcat/TailcatRuntimeLive.ts new file mode 100644 index 000000000000..5bd5fa4d3fc6 --- /dev/null +++ b/apps/server/src/tailcat/TailcatRuntimeLive.ts @@ -0,0 +1,45 @@ +import { HostProcessArchitecture, HostProcessPlatform } from "@t3tools/shared/hostProcess"; +import * as TailcatRuntime from "@t3tools/tailcat/runtime"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import * as Path from "effect/Path"; + +import * as ServerConfig from "../config.ts"; + +/** + * Resolves the Tailcat executable for this server process. Preference order: + * an explicit `T3CODE_TAILCAT_BINARY` override, the path the desktop app hands + * over in its bootstrap (the binary it ships), the copy bundled next to the CLI + * bundle (`dist/tailcat//`), the monorepo's fetched runtime, and + * finally a `tailcat` already on PATH. + */ +export const layer = Layer.unwrap( + Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const path = yield* Path.Path; + const platform = yield* HostProcessPlatform; + const architecture = yield* HostProcessArchitecture; + const overridePath = yield* TailcatRuntime.tailcatOverridePathFromEnvironment; + const moduleDirectory = import.meta.dirname; + const bundledCandidates = [ + ...(config.tailcatBinaryPath === undefined ? [] : [config.tailcatBinaryPath]), + ...TailcatRuntime.bundledTailcatCandidates({ + platform, + architecture, + joinPath: path.join, + moduleDirectory, + repoRootCandidates: [ + path.resolve(moduleDirectory, "../../../.."), + path.resolve(moduleDirectory, "../../.."), + ], + }), + ]; + return TailcatRuntime.layer({ + resolution: { + overridePath, + bundledCandidates, + allowSystem: true, + }, + }); + }), +); diff --git a/apps/server/src/tailcat/http.ts b/apps/server/src/tailcat/http.ts new file mode 100644 index 000000000000..1ca1cefe1c78 --- /dev/null +++ b/apps/server/src/tailcat/http.ts @@ -0,0 +1,48 @@ +import { AuthAccessReadScope, AuthAccessWriteScope, EnvironmentHttpApi } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as HttpApiBuilder from "effect/unstable/httpapi/HttpApiBuilder"; + +import { annotateEnvironmentRequest, requireEnvironmentScope } from "../auth/http.ts"; +import * as TailcatRemoteAccess from "./TailcatRemoteAccess.ts"; + +/** HTTP surface for the CLI; the UI uses the equivalent RPC methods. */ +export const tailcatHttpApiLayer = HttpApiBuilder.group( + EnvironmentHttpApi, + "tailcat", + Effect.fnUntraced(function* (handlers) { + const remoteAccess = yield* TailcatRemoteAccess.TailcatRemoteAccess; + return handlers + .handle( + "remoteAccess", + Effect.fn("environment.tailcat.remoteAccess")(function* (args) { + yield* annotateEnvironmentRequest(args.endpoint.name); + yield* requireEnvironmentScope(AuthAccessReadScope); + return yield* remoteAccess.state; + }), + ) + .handle( + "setRemoteAccess", + Effect.fn("environment.tailcat.setRemoteAccess")(function* (args) { + yield* annotateEnvironmentRequest(args.endpoint.name); + yield* requireEnvironmentScope(AuthAccessWriteScope); + return yield* remoteAccess.setEnabled(args.payload.enabled); + }), + ) + .handle( + "createConnectionCode", + Effect.fn("environment.tailcat.createConnectionCode")(function* (args) { + yield* annotateEnvironmentRequest(args.endpoint.name); + yield* requireEnvironmentScope(AuthAccessWriteScope); + return yield* remoteAccess.createConnectionCode(args.payload); + }), + ) + .handle( + "revokeTrustedPeer", + Effect.fn("environment.tailcat.revokeTrustedPeer")(function* (args) { + yield* annotateEnvironmentRequest(args.endpoint.name); + yield* requireEnvironmentScope(AuthAccessWriteScope); + return yield* remoteAccess.revokeTrustedPeer(args.payload.peerId); + }), + ); + }), +); diff --git a/apps/server/src/tailcat/startupOutput.ts b/apps/server/src/tailcat/startupOutput.ts new file mode 100644 index 000000000000..3e6377a9015f --- /dev/null +++ b/apps/server/src/tailcat/startupOutput.ts @@ -0,0 +1,42 @@ +import type { TailcatConnectionCodeResult, TailcatRemoteAccessState } from "@t3tools/contracts"; + +import { renderTerminalQrCode } from "../startupAccess.ts"; + +/** + * Terminal output for `t3 serve --tailcat`: the connection code a client pastes + * into Add Environment, plus a QR for the mobile app. The code embeds a + * single-use pairing credential, so it is shown exactly like the pairing URL. + */ +/** The code, its QR, and the handling instructions, shared by every terminal entry point. */ +export function formatTailcatConnectionCodeLines( + issued: TailcatConnectionCodeResult, +): ReadonlyArray { + return [ + `Connection code (expires ${issued.expiresAt}, single use):`, + issued.code, + "", + renderTerminalQrCode(issued.code), + "", + "Paste the code in T3 Code under Add Environment → Tailcat, or scan it with the mobile app.", + "This code embeds a one-time pairing credential. Share it only with the device you are pairing.", + ]; +} + +export function formatTailcatHeadlessOutput( + state: TailcatRemoteAccessState, + issued: TailcatConnectionCodeResult, +): string { + const path = + state.runtime === null + ? "unknown" + : `${state.runtime.source} ${state.runtime.version} (${state.runtime.executablePath})`; + return [ + "", + "Tailcat remote access is ready.", + `Tailcat address: ${state.address ?? "unknown"}`, + `Tailcat runtime: ${path}`, + ...formatTailcatConnectionCodeLines(issued), + "Trusted devices stay connected after the code expires; issue a new code per device.", + "", + ].join("\n"); +} diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 839937cf2ea7..697898881ca2 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -59,6 +59,7 @@ import { AssetWorkspaceContextResolutionError, RpcClientId, EnvironmentAuthorizationError, + AuthFederationPeerScope, ThreadId, type TerminalAttachStreamEvent, type TerminalError, @@ -111,6 +112,8 @@ import { deletePendingAttachment, issueAttachmentUploadUrl } from "./assets/Atta import * as PortScanner from "./preview/PortScanner.ts"; import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts"; +import * as FederationService from "./federation/FederationService.ts"; +import * as TailcatRemoteAccess from "./tailcat/TailcatRemoteAccess.ts"; import { readWorkflowScript } from "./orchestration/workflowScriptQuery.ts"; import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts"; @@ -523,6 +526,8 @@ const makeWsRpcLayer = ( const startup = yield* ServerRuntimeStartup.ServerRuntimeStartup; const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; const workspaceFileSystem = yield* WorkspaceFileSystem.WorkspaceFileSystem; + const tailcatRemoteAccess = yield* TailcatRemoteAccess.TailcatRemoteAccess; + const federation = yield* FederationService.FederationService; const canReplayPersistedRange = Effect.fnUntraced(function* ( afterSequence: number, headSequence: number, @@ -2670,6 +2675,114 @@ const makeWsRpcLayer = ( ), { "rpc.aggregate": "server" }, ), + // Tailcat remote access: the Tailcat listener this environment exposes. + [WS_METHODS.tailcatSubscribeRemoteAccess]: (_input) => + observeRpcStream( + WS_METHODS.tailcatSubscribeRemoteAccess, + Stream.unwrap( + Effect.map(tailcatRemoteAccess.state, (latest) => + Stream.concat(Stream.make(latest), tailcatRemoteAccess.changes), + ), + ), + { "rpc.aggregate": "tailcat" }, + ), + [WS_METHODS.tailcatSetRemoteAccessEnabled]: (input) => + observeRpcEffect( + WS_METHODS.tailcatSetRemoteAccessEnabled, + tailcatRemoteAccess.setEnabled(input.enabled), + { "rpc.aggregate": "tailcat" }, + ), + [WS_METHODS.tailcatCreateConnectionCode]: (input) => + observeRpcEffect( + WS_METHODS.tailcatCreateConnectionCode, + tailcatRemoteAccess.createConnectionCode(input), + { "rpc.aggregate": "tailcat" }, + ), + [WS_METHODS.tailcatRevokeTrustedPeer]: (input) => + observeRpcEffect( + WS_METHODS.tailcatRevokeTrustedPeer, + tailcatRemoteAccess.revokeTrustedPeer(input.peerId), + { "rpc.aggregate": "tailcat" }, + ), + [WS_METHODS.tailcatRenameTrustedPeer]: (input) => + observeRpcEffect( + WS_METHODS.tailcatRenameTrustedPeer, + tailcatRemoteAccess.renameTrustedPeer(input), + { "rpc.aggregate": "tailcat" }, + ), + [WS_METHODS.tailcatRegenerateIdentity]: (_input) => + observeRpcEffect( + WS_METHODS.tailcatRegenerateIdentity, + tailcatRemoteAccess.regenerateIdentity, + { "rpc.aggregate": "tailcat" }, + ), + // Federation: peers this environment trusts and runs it delegated to them. + [WS_METHODS.federationSubscribePeers]: (_input) => + observeRpcStream( + WS_METHODS.federationSubscribePeers, + Stream.unwrap( + Effect.map(federation.snapshot, (latest) => + Stream.concat(Stream.make(latest), federation.changes), + ), + ), + { "rpc.aggregate": "federation" }, + ), + [WS_METHODS.federationCreatePeerCode]: (input) => + observeRpcEffect(WS_METHODS.federationCreatePeerCode, federation.createPeerCode(input), { + "rpc.aggregate": "federation", + }), + [WS_METHODS.federationAddPeer]: (input) => + observeRpcEffect(WS_METHODS.federationAddPeer, federation.addPeer(input), { + "rpc.aggregate": "federation", + }), + [WS_METHODS.federationRemovePeer]: (input) => + observeRpcEffect(WS_METHODS.federationRemovePeer, federation.removePeer(input.peerId), { + "rpc.aggregate": "federation", + }), + [WS_METHODS.federationRefreshPeer]: (input) => + observeRpcEffect(WS_METHODS.federationRefreshPeer, federation.refreshPeer(input.peerId), { + "rpc.aggregate": "federation", + }), + [WS_METHODS.federationListRemoteProjects]: (input) => + observeRpcEffect( + WS_METHODS.federationListRemoteProjects, + federation.listRemoteProjects(input.peerId), + { "rpc.aggregate": "federation" }, + ), + [WS_METHODS.federationStartRemoteRun]: (input) => + observeRpcEffect(WS_METHODS.federationStartRemoteRun, federation.startRemoteRun(input), { + "rpc.aggregate": "federation", + }), + [WS_METHODS.federationCancelRemoteRun]: (input) => + observeRpcEffect( + WS_METHODS.federationCancelRemoteRun, + federation.cancelRemoteRun(input), + { + "rpc.aggregate": "federation", + }, + ), + [WS_METHODS.federationSubscribeRemoteRuns]: (_input) => + observeRpcStream( + WS_METHODS.federationSubscribeRemoteRuns, + Stream.unwrap( + Effect.map(federation.remoteRuns, (latest) => + Stream.concat(Stream.make(latest), federation.remoteRunChanges), + ), + ), + { "rpc.aggregate": "federation" }, + ), + [WS_METHODS.federationDescribeRemoteArtifacts]: (input) => + observeRpcEffect( + WS_METHODS.federationDescribeRemoteArtifacts, + federation.describeRemoteArtifacts(input), + { "rpc.aggregate": "federation" }, + ), + [WS_METHODS.federationFetchRemoteArtifact]: (input) => + observeRpcEffect( + WS_METHODS.federationFetchRemoteArtifact, + federation.fetchRemoteArtifact(input), + { "rpc.aggregate": "federation" }, + ), }); }), ); @@ -2723,6 +2836,11 @@ export const websocketRpcRouteLayer = Layer.unwrap( failEnvironmentInternal("internal_error", error), ), ); + if (session.scopes.includes(AuthFederationPeerScope)) { + // Federation peers speak the versioned HTTP protocol only; the RPC + // surface is for this environment's own clients. + return yield* failEnvironmentAuthInvalid("invalid_credential"); + } const clientOrigin = readClientConnectionOrigin(request); const clientAnalyticsProps = readClientAnalyticsProps(request); yield* sessions.recordClientConnection(session.sessionId, clientOrigin); diff --git a/apps/web/src/components/settings/ConnectionsSettings.tsx b/apps/web/src/components/settings/ConnectionsSettings.tsx index 6b81a7f70dda..cbd3c286b5ad 100644 --- a/apps/web/src/components/settings/ConnectionsSettings.tsx +++ b/apps/web/src/components/settings/ConnectionsSettings.tsx @@ -1,4 +1,11 @@ -import { ChevronsLeftRightEllipsisIcon, PlusIcon, QrCodeIcon, TerminalIcon } from "lucide-react"; +import { + ChevronsLeftRightEllipsisIcon, + PlusIcon, + QrCodeIcon, + RadioTowerIcon, + TerminalIcon, +} from "lucide-react"; +import { formatAbsoluteTimestamp } from "~/timestampFormat"; import { useAtomValue } from "@effect/atom-react"; import { type KeyboardEvent, @@ -59,6 +66,13 @@ import { } from "./settingsLayout"; import { searchableSetting } from "./settingsSearch"; import { EnvironmentIconPicker } from "./EnvironmentIconPicker"; +import { FederationSection } from "./FederationSection"; +import { TailcatConnectForm } from "./TailcatConnectForm"; +import { + TailcatEnvironmentDetailsDialog, + useTailcatEnvironmentSubtitle, +} from "./TailcatEnvironmentDetails"; +import { TailcatRemoteAccessRow } from "./TailcatRemoteAccessSection"; import { Input } from "../ui/input"; import { CommandShortcut } from "../ui/command"; import { @@ -137,6 +151,7 @@ import { refreshDesktopNetworkAccessState, } from "~/state/desktopNetworkAccess"; import { desktopSshHostsStateAtom, filterDiscoveredSshHosts } from "~/state/desktopSshHosts"; +import { isDesktopTailcatAvailable } from "~/state/desktopTailcat"; import { desktopWslStateAtom, refreshDesktopWslState } from "~/state/desktopWslState"; import { type EnvironmentPresentation, @@ -157,6 +172,9 @@ import { } from "../../keybindings"; const DEFAULT_TAILSCALE_SERVE_PORT = 443; + +/** How a new saved environment is added: pairing link, desktop SSH, or a Tailcat connection code. */ +type SavedBackendMode = "remote" | "ssh" | "tailcat"; const EMPTY_ADVERTISED_ENDPOINTS: ReadonlyArray = []; const EMPTY_DISCOVERED_SSH_HOSTS: ReadonlyArray = []; @@ -166,19 +184,6 @@ const EMPTY_DISCOVERED_SSH_HOSTS: ReadonlyArray = []; const BACKEND_VALUE_DEFAULT_WSL = "backend:default-wsl"; const BACKEND_VALUE_WSL_OFF = "backend:wsl-off"; -const accessTimestampFormatter = new Intl.DateTimeFormat(undefined, { - dateStyle: "medium", - timeStyle: "short", -}); - -function formatAccessTimestamp(value: string): string { - const parsed = new Date(value); - if (Number.isNaN(parsed.getTime())) { - return value; - } - return accessTimestampFormatter.format(parsed); -} - const PAIRING_SCOPE_OPTIONS: ReadonlyArray<{ readonly scope: AuthEnvironmentScope; readonly title: string; @@ -689,7 +694,7 @@ const PairingLinkListRow = memo(function PairingLinkListRow({ copyPairingValue(pairingLink.credential, "code"); }, [copyPairingValue, pairingLink.credential]); - const expiresAbsolute = formatAccessTimestamp(pairingLink.expiresAt); + const expiresAbsolute = formatAbsoluteTimestamp(pairingLink.expiresAt); const primaryLabel = pairingLink.label ?? "Pairing link"; const selectedQrOption = selectQrEndpointOption( @@ -712,7 +717,7 @@ const PairingLinkListRow = memo(function PairingLinkListRow({

{primaryLabel}

@@ -940,7 +945,7 @@ const ConnectedClientListRow = memo(function ConnectedClientListRow({ ? `Connected for ${formatElapsedDurationLabel(lastConnectedAt, nowMs)}` : "Connected" : lastConnectedAt - ? `Last connected at ${formatAccessTimestamp(lastConnectedAt)}` + ? `Last connected at ${formatAbsoluteTimestamp(lastConnectedAt)}` : "Not connected yet."; const deviceInfoBits = [ clientSession.client.deviceType !== "unknown" @@ -1435,8 +1440,16 @@ function SavedBackendListRow({ environment.entry.profile.value._tag === "SshConnectionProfile" ? environment.entry.profile.value.target : null; + const tailcatProfile = + environment.entry.target._tag === "TailcatConnectionTarget" && + Option.isSome(environment.entry.profile) && + environment.entry.profile.value._tag === "TailcatConnectionProfile" + ? environment.entry.profile.value + : null; + const tailcatSubtitle = useTailcatEnvironmentSubtitle(tailcatProfile?.connectionId ?? null); const metadataBits = [ sshTarget ? `SSH ${formatDesktopSshTarget(sshTarget)}` : null, + tailcatSubtitle, environment.relayManaged ? "T3 Connect" : null, ].filter((value): value is string => value !== null); @@ -1546,6 +1559,15 @@ function SavedBackendListRow({ ) : ( <> + {tailcatProfile ? ( + + ) : null} {!isConnected ? ( ); }; + const renderTailcatModeBody = () => ( + { + setSavedBackendError(null); + setAddBackendDialogOpen(false); + }} + /> + ); + const renderTailcatRemoteAccessRow = () => + supportsTailcatRemoteAccess && primaryEnvironmentId !== null ? ( + + ) : null; const renderRemoteFields = () => (
@@ -3168,12 +3216,14 @@ export function ConnectionsSettings() { {renderNetworkAccessRow()} {renderEndpointRows("endpoint-rail")} {renderTailscaleRow()} + {renderTailcatRemoteAccessRow()} {renderWslRow()} ) : ( <> {renderDisabledNetworkAccessRow()} + {renderTailcatRemoteAccessRow()} )} @@ -3522,7 +3572,12 @@ export function ConnectionsSettings() {
-
+
{renderConnectionModeCard({ mode: "remote", title: "Remote link", @@ -3537,9 +3592,23 @@ export function ConnectionsSettings() { icon: , }) : null} + {renderConnectionModeCard({ + mode: "tailcat", + title: "Tailcat", + description: + "Paste a connection code from the other machine. Tunnels with relay fallback, no VPN account.", + icon: , + ...(isDesktopTailcatReady + ? {} + : { unavailableReason: "Desktop app required" }), + })}
- {savedBackendMode === "ssh" ? renderSshFields() : renderRemoteModeBody()} + {savedBackendMode === "ssh" + ? renderSshFields() + : savedBackendMode === "tailcat" + ? renderTailcatModeBody() + : renderRemoteModeBody()}
@@ -3561,6 +3630,10 @@ export function ConnectionsSettings() { savedEnvironments={savedEnvironments} /> + + {canManageLocalBackend && supportsFederation && primaryEnvironmentId !== null ? ( + + ) : null} ); } diff --git a/apps/web/src/components/settings/FederationSection.logic.test.ts b/apps/web/src/components/settings/FederationSection.logic.test.ts new file mode 100644 index 000000000000..9cfcb742a0f8 --- /dev/null +++ b/apps/web/src/components/settings/FederationSection.logic.test.ts @@ -0,0 +1,152 @@ +import { EnvironmentId, FederationRemoteRun } from "@t3tools/contracts"; +import { + encodeFederationPeerCode, + encodeTailcatConnectionCode, +} from "@t3tools/shared/t3ConnectionCode"; +import * as Schema from "effect/Schema"; +import { describe, expect, it } from "vite-plus/test"; + +import { + describeFederationPeerCode, + isRemoteRunActive, + remoteRunLastEventSummary, + remoteRunStatusBadgeVariant, + remoteRunStatusLabel, + sortRemoteRuns, + toggleFederationScope, +} from "./FederationSection.logic"; + +const ADDRESS = `tc${"b".repeat(40)}`; +const NOW_MS = Date.parse("2026-09-03T12:00:00.000Z"); + +const decodeRemoteRun = Schema.decodeUnknownSync(FederationRemoteRun); + +function remoteRun(overrides: { + readonly requestedAt: string; + readonly events?: ReadonlyArray<{ + readonly sequence: number; + readonly at: string; + readonly type: string; + readonly summary: string; + }>; + readonly assistantPreview?: string | null; +}): FederationRemoteRun { + return decodeRemoteRun({ + peerId: "env-peer", + peerLabel: "Build box", + run: { + environmentId: "env-peer", + projectId: "project-1", + threadId: "thread-1", + turnId: null, + title: "Fix flaky test", + status: "running", + runtimeMode: "full-access", + modelSelection: { instanceId: "codex", model: "gpt-5" }, + requestedAt: overrides.requestedAt, + startedAt: null, + completedAt: null, + assistantPreview: overrides.assistantPreview ?? null, + turnCount: 1, + }, + events: overrides.events ?? [], + lastSyncedAt: null, + syncError: null, + }); +} + +describe("remote run presentation", () => { + it("labels statuses and knows which ones can still be cancelled", () => { + expect(remoteRunStatusLabel("queued")).toBe("Queued"); + expect(remoteRunStatusLabel("error")).toBe("Failed"); + expect(remoteRunStatusBadgeVariant("running")).toBe("warning"); + expect(remoteRunStatusBadgeVariant("completed")).toBe("success"); + expect(isRemoteRunActive("queued")).toBe(true); + expect(isRemoteRunActive("running")).toBe(true); + expect(isRemoteRunActive("completed")).toBe(false); + expect(isRemoteRunActive("interrupted")).toBe(false); + }); + + it("prefers the newest event summary, then the assistant preview", () => { + const at = "2026-09-03T12:00:00.000Z"; + expect( + remoteRunLastEventSummary( + remoteRun({ + requestedAt: at, + events: [ + { sequence: 2, at, type: "turn.completed", summary: "Turn completed" }, + { sequence: 1, at, type: "turn.started", summary: "Turn started" }, + ], + assistantPreview: "Working on it", + }), + ), + ).toBe("Turn completed"); + expect( + remoteRunLastEventSummary(remoteRun({ requestedAt: at, assistantPreview: " Working " })), + ).toBe("Working"); + expect(remoteRunLastEventSummary(remoteRun({ requestedAt: at }))).toBeNull(); + }); + + it("sorts runs newest first", () => { + const older = remoteRun({ requestedAt: "2026-09-03T11:00:00.000Z" }); + const newer = remoteRun({ requestedAt: "2026-09-03T12:00:00.000Z" }); + expect(sortRemoteRuns([older, newer])).toEqual([newer, older]); + }); +}); + +describe("toggleFederationScope", () => { + it("adds once and removes cleanly", () => { + expect(toggleFederationScope(["runs.read"], "runs.start", true)).toEqual([ + "runs.read", + "runs.start", + ]); + expect(toggleFederationScope(["runs.read"], "runs.read", true)).toEqual(["runs.read"]); + expect(toggleFederationScope(["runs.read", "runs.start"], "runs.read", false)).toEqual([ + "runs.start", + ]); + }); +}); + +describe("describeFederationPeerCode", () => { + it("previews a valid peer code and detects expiry", () => { + const code = encodeFederationPeerCode({ + v: 1, + kind: "peer", + protocolVersion: 1, + environmentId: EnvironmentId.make("env-2"), + publicKey: "pem", + label: "Build box", + transport: { tailcat: { address: ADDRESS, port: 3773 } }, + token: "one-time", + scopes: ["environment.read", "runs.start"], + expiresAt: "2026-09-03T12:05:00.000Z", + }); + expect(describeFederationPeerCode(code, NOW_MS)).toEqual({ + kind: "valid", + payload: expect.objectContaining({ + label: "Build box", + scopes: ["environment.read", "runs.start"], + }), + expired: false, + }); + expect(describeFederationPeerCode(code, Date.parse("2026-09-03T12:06:00.000Z"))).toMatchObject({ + kind: "valid", + expired: true, + }); + }); + + it("redirects Tailcat codes and rejects everything else", () => { + const tailcatCode = encodeTailcatConnectionCode({ + v: 1, + transport: "tailcat", + address: ADDRESS, + port: 3773, + }); + expect(describeFederationPeerCode(tailcatCode, NOW_MS)).toMatchObject({ kind: "tailcat-code" }); + expect(describeFederationPeerCode(" ", NOW_MS)).toEqual({ kind: "empty" }); + expect(describeFederationPeerCode("nope", NOW_MS)).toMatchObject({ + kind: "invalid", + message: expect.stringContaining("t3c://peer/"), + }); + }); +}); diff --git a/apps/web/src/components/settings/FederationSection.logic.ts b/apps/web/src/components/settings/FederationSection.logic.ts new file mode 100644 index 000000000000..4ca1e88ce107 --- /dev/null +++ b/apps/web/src/components/settings/FederationSection.logic.ts @@ -0,0 +1,164 @@ +import type { + FederationPeerCodePayload, + FederationPeerStatus, + FederationRemoteRun, + FederationRunStatus, + FederationScope, +} from "@t3tools/contracts"; +import { describeT3ConnectionCode } from "@t3tools/shared/t3ConnectionCode"; + +export const FEDERATION_SCOPE_OPTIONS: ReadonlyArray<{ + readonly scope: FederationScope; + readonly title: string; + readonly description: string; +}> = [ + { + scope: "environment.read", + title: "See environment", + description: "Read this environment's name, version, and capabilities.", + }, + { + scope: "projects.read", + title: "List projects", + description: "See which projects can be targeted for a run.", + }, + { + scope: "runs.read", + title: "Follow runs", + description: "Read status and event summaries of runs it started here.", + }, + { + scope: "runs.start", + title: "Start runs", + description: "Start agent runs in this environment's projects.", + }, + { + scope: "runs.cancel", + title: "Cancel runs", + description: "Interrupt runs it started here.", + }, + { + scope: "artifacts.read", + title: "Read changes", + description: "Fetch the diffs produced by runs it started here.", + }, +]; + +export function toggleFederationScope( + scopes: ReadonlyArray, + scope: FederationScope, + checked: boolean, +): ReadonlyArray { + if (checked) { + return scopes.includes(scope) ? scopes : [...scopes, scope]; + } + return scopes.filter((candidate) => candidate !== scope); +} + +export type RemoteRunBadgeVariant = "outline" | "warning" | "success" | "error" | "info"; + +export function remoteRunStatusLabel(status: FederationRunStatus): string { + switch (status) { + case "queued": + return "Queued"; + case "running": + return "Running"; + case "completed": + return "Completed"; + case "interrupted": + return "Interrupted"; + case "error": + return "Failed"; + } +} + +export function remoteRunStatusBadgeVariant(status: FederationRunStatus): RemoteRunBadgeVariant { + switch (status) { + case "queued": + return "info"; + case "running": + return "warning"; + case "completed": + return "success"; + case "interrupted": + return "outline"; + case "error": + return "error"; + } +} + +/** Queued and running runs can still be cancelled on the peer. */ +export function isRemoteRunActive(status: FederationRunStatus): boolean { + return status === "queued" || status === "running"; +} + +/** The freshest one-line description of a remote run: its latest event, else the assistant preview. */ +export function remoteRunLastEventSummary(remoteRun: FederationRemoteRun): string | null { + const latest = remoteRun.events.reduce( + (best, event) => (best === null || event.sequence > best.sequence ? event : best), + null, + ); + const summary = latest?.summary.trim(); + if (summary) return summary; + const preview = remoteRun.run.assistantPreview?.trim(); + return preview ? preview : null; +} + +/** Newest request first, so the run just started is at the top. */ +export function sortRemoteRuns( + runs: ReadonlyArray, +): ReadonlyArray { + return [...runs].toSorted( + (left, right) => Date.parse(right.run.requestedAt) - Date.parse(left.run.requestedAt), + ); +} + +export function peerStatusDotClassName(status: FederationPeerStatus): string { + switch (status) { + case "online": + return "bg-success"; + case "offline": + return "bg-destructive"; + case "unknown": + return "bg-muted-foreground/40"; + } +} + +export function peerStatusLabel(status: FederationPeerStatus): string { + switch (status) { + case "online": + return "Online"; + case "offline": + return "Offline"; + case "unknown": + return "Not checked yet"; + } +} + +export type FederationPeerCodePreview = + | { readonly kind: "empty" } + | { readonly kind: "invalid"; readonly message: string } + | { readonly kind: "tailcat-code"; readonly message: string } + | { + readonly kind: "valid"; + readonly payload: FederationPeerCodePayload; + readonly expired: boolean; + }; + +/** Live feedback for the peer-code field; a Tailcat connection code is redirected, not rejected. */ +export function describeFederationPeerCode(raw: string, nowMs: number): FederationPeerCodePreview { + const preview = describeT3ConnectionCode(raw, "peer"); + switch (preview.kind) { + case "empty": + case "invalid": + return preview; + case "other-kind": + return { kind: "tailcat-code", message: preview.message }; + case "valid": + return { + kind: "valid", + payload: preview.payload, + expired: preview.expiresAtMs !== null && preview.expiresAtMs <= nowMs, + }; + } +} diff --git a/apps/web/src/components/settings/FederationSection.tsx b/apps/web/src/components/settings/FederationSection.tsx new file mode 100644 index 000000000000..e7dd3f23135d --- /dev/null +++ b/apps/web/src/components/settings/FederationSection.tsx @@ -0,0 +1,1180 @@ +import { + type AtomCommandResult, + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { formatAbsoluteTimestamp } from "~/timestampFormat"; +import { + type EnvironmentId, + FEDERATION_DEFAULT_SCOPES, + type FederationArtifactFetchResponse, + type FederationArtifactRef, + type FederationPeer, + type FederationPeerCodeResult, + type FederationProjectSummary, + type FederationRemoteRun, + type FederationScope, + type RuntimeMode, +} from "@t3tools/contracts"; +import type * as Cause from "effect/Cause"; +import { CopyIcon, PlayIcon, PlusIcon, RefreshCwIcon } from "lucide-react"; +import { memo, useCallback, useMemo, useState } from "react"; + +import { useCopyToClipboard } from "../../hooks/useCopyToClipboard"; +import { formatExpiresInLabel } from "../../timestampFormat"; +import { federationEnvironment } from "~/state/federation"; +import { useEnvironmentQuery } from "~/state/query"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { ConnectionStatusDot } from "../ConnectionStatusDot"; +import { + AlertDialog, + AlertDialogClose, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogPopup, + AlertDialogTitle, +} from "../ui/alert-dialog"; +import { Badge } from "../ui/badge"; +import { Button } from "../ui/button"; +import { Checkbox } from "../ui/checkbox"; +import { + Dialog, + DialogDescription, + DialogFooter, + DialogHeader, + DialogPanel, + DialogPopup, + DialogTitle, + DialogTrigger, +} from "../ui/dialog"; +import { QRCodeSvg } from "../ui/qr-code"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; +import { Textarea } from "../ui/textarea"; +import { stackedThreadToast, toastManager } from "../ui/toast"; +import { + FEDERATION_SCOPE_OPTIONS, + describeFederationPeerCode, + isRemoteRunActive, + peerStatusDotClassName, + peerStatusLabel, + remoteRunLastEventSummary, + remoteRunStatusBadgeVariant, + remoteRunStatusLabel, + sortRemoteRuns, + toggleFederationScope, +} from "./FederationSection.logic"; +import { ITEM_ROW_CLASSNAME, ITEM_ROW_INNER_CLASSNAME } from "./itemRows"; +import { SettingsRow, SettingsSection, useRelativeTimeTick } from "./settingsLayout"; +import { searchableSetting } from "./settingsSearch"; + +const EMPTY_REMOTE_RUNS: ReadonlyArray = []; +const EMPTY_PEERS: ReadonlyArray = []; + +const RUNTIME_MODE_OPTIONS: ReadonlyArray<{ readonly value: RuntimeMode; readonly label: string }> = + [ + { value: "approval-required", label: "Approval required" }, + { value: "auto-accept-edits", label: "Auto-accept edits" }, + { value: "auto", label: "Auto" }, + { value: "full-access", label: "Full access" }, + ]; + +const PEER_DEFAULT_RUNTIME_MODE = "peer-default"; + +function commandFailureMessage( + result: { readonly cause: Cause.Cause }, + fallback: string, +): string { + const error = squashAtomCommandFailure(result); + return error instanceof Error && error.message.trim().length > 0 ? error.message : fallback; +} + +function ScopeChecklist({ + scopes, + disabled, + heading, + onToggle, +}: { + readonly scopes: ReadonlyArray; + readonly disabled: boolean; + readonly heading: string; + readonly onToggle: (scope: FederationScope, checked: boolean) => void; +}) { + return ( +
+

{heading}

+
+ {FEDERATION_SCOPE_OPTIONS.map(({ scope, title, description }) => ( + + ))} +
+
+ ); +} + +function ScopeChips({ + label, + scopes, +}: { + readonly label: string; + readonly scopes: ReadonlyArray; +}) { + return ( + + {label} + {scopes.length === 0 ? ( + none + ) : ( + scopes.map((scope) => ( + + {scope} + + )) + )} + + ); +} + +/** The minted peer code with QR and a countdown; ticks only while shown. */ +const PeerCodeReveal = memo(function PeerCodeReveal({ + issued, +}: { + readonly issued: FederationPeerCodeResult; +}) { + const nowMs = useRelativeTimeTick(1_000); + const { copyToClipboard } = useCopyToClipboard({ + onCopy: () => { + toastManager.add({ + type: "success", + title: "Peer code copied", + description: "Add it on the other environment under Federation → Add peer.", + }); + }, + onError: (error) => { + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Could not copy peer code", + description: error.message, + }), + ); + }, + }); + const expired = Date.parse(issued.expiresAt) <= nowMs; + + return ( +
+
+
+