diff --git a/packages/ssh/src/tunnel.test.ts b/packages/ssh/src/tunnel.test.ts index 1ebb1ff5d3a3..ea0cf6bfc7ca 100644 --- a/packages/ssh/src/tunnel.test.ts +++ b/packages/ssh/src/tunnel.test.ts @@ -16,7 +16,8 @@ import { HttpClient, HttpClientResponse } from "effect/http"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; import * as SshAuth from "./auth.ts"; -import { SshCommandError } from "./errors.ts"; +import { remoteStateKey } from "./command.ts"; +import { SshCommandError, SshPairingError } from "./errors.ts"; import * as SshTunnel from "./tunnel.ts"; const TEST_NODE_ENGINE_RANGE = "^22.16 || ^23.11 || >=24.10"; @@ -490,6 +491,181 @@ describe("ssh tunnel scripts", () => { }).pipe(Effect.provide(layerProcess)); }); + describe.skipIf(HostProcess.Platform.defaultValue() === "win32")("SSH release pairing", () => { + it.effect.each(["0.0.46-nightly.20261004.2652", ARCHIVE.archiveVersion, "1.2.4", "0.0.0-dev"])( + "mints pairing tokens with the running server's CLI version %s", + (serverVersion) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const localSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const home = yield* fs.makeTempDirectoryScoped({ prefix: "t3-ssh-pairing-" }); + for (const version of new Set([serverVersion, ARCHIVE.archiveVersion])) { + const runtime = `${home}/.t3/runtime/versions/${version}`; + yield* fs.makeDirectory(runtime, { recursive: true }); + yield* fs.writeFileString(`${runtime}/.install-complete`, version); + yield* fs.writeFileString( + `${runtime}/t3`, + `#!/bin/sh\nprintf '%s\\n' '{"credential":"paired-${version}"}'\n`, + ); + yield* fs.chmod(`${runtime}/t3`, 0o700); + } + const runner = + serverVersion === "0.0.0-dev" ? { nodeScriptPath: `${home}/dev.mjs` } : ARCHIVE; + if ("nodeScriptPath" in runner) { + yield* fs.writeFileString( + runner.nodeScriptPath, + `process.stdout.write(JSON.stringify({ credential: "paired-${serverVersion}" }));`, + ); + } + const target = { alias: "devbox", hostname: "devbox", username: null, port: null }; + const stateKey = yield* remoteStateKey(target); + const launchRunner = `${home}/.t3/ssh-launch/${stateKey}/run-t3.sh`; + const launchScript = SshTunnel.buildRemoteT3RunnerScript(runner); + yield* fs.makeDirectory(`${home}/.t3/ssh-launch/${stateKey}`, { recursive: true }); + yield* fs.writeFileString(launchRunner, launchScript); + let launches = 0; + let descriptorRequests = 0; + const spawner = ChildProcessSpawner.make((command) => + Effect.gen(function* () { + const args = commandArgs(command); + if (args.includes("-N")) return makeRunningProcess(() => undefined); + if (args.includes("--")) { + launches += 1; + return makeSuccessfulProcess('{"remotePort":3773,"serverKind":"external"}\n'); + } + if (command._tag === "StandardCommand" && args.includes("sh")) { + const stdin = command.options.stdin; + if ( + typeof stdin === "object" && + "stream" in stdin && + Stream.isStream(stdin.stream) + ) { + const script = yield* stdin.stream.pipe( + Stream.decodeText(), + Stream.runFold( + () => "", + (text, chunk) => text + chunk, + ), + ); + if (script.includes("PAIRING_BASE_DIR=")) { + return yield* localSpawner.spawn( + ChildProcess.make("sh", ["-s"], { + env: { HOME: home, PATH: process.env.PATH ?? "" }, + stdin: { + stream: Stream.make(new TextEncoder().encode(script)), + endOnDone: true, + }, + }), + ); + } + } + } + return makeSuccessfulProcess(""); + }), + ); + const client = HttpClient.make((request) => { + if (request.url.endsWith("/.well-known/t3/environment")) { + descriptorRequests += 1; + return Effect.succeed( + HttpClientResponse.fromWeb(request, Response.json({ serverVersion })), + ); + } + return Effect.succeed(HttpClientResponse.fromWeb(request, new Response(""))); + }); + yield* Effect.gen(function* () { + const manager = yield* SshTunnel.SshEnvironmentManager; + yield* manager.ensureEnvironment(target); + assert.equal(descriptorRequests, 0); + for (let attempt = 0; attempt < 2; attempt += 1) { + const paired = yield* manager.ensureEnvironment(target, { issuePairingToken: true }); + assert.equal(paired.pairingToken, `paired-${serverVersion}`); + assert.equal(paired.remoteServerKind, "external"); + } + assert.equal(descriptorRequests, "nodeScriptPath" in runner ? 0 : 2); + assert.equal(launches, 1); + assert.equal(yield* fs.readFileString(launchRunner), launchScript); + }).pipe( + Effect.provide( + Layer.mergeAll( + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), + Layer.succeed(HttpClient.HttpClient, client), + Layer.succeed(NetService.NetService, testNetService), + SshAuth.SshPasswordPrompt.disabledLayer, + SshTunnel.SshEnvironmentManager.layer({ + resolveCliRunner: Effect.succeed(runner), + }), + ), + ), + Effect.scoped, + ); + }).pipe(Effect.provide(NodeServices.layer), Effect.scoped), + ); + }); + + it.effect.each([ + { status: 500, body: "{}" }, + { status: 200, body: "not JSON" }, + { status: 200, body: "{}" }, + { status: 200, body: '{"serverVersion":"../invalid"}' }, + { status: 200, body: null }, + ])("does not mint a grant when server version discovery fails: %j", ({ status, body }) => { + let pairingCommands = 0; + const spawner = ChildProcessSpawner.make((command) => { + const args = commandArgs(command); + if (args.includes("-N")) return Effect.succeed(makeRunningProcess(() => undefined)); + if (args.includes("--")) return Effect.succeed(makeSuccessfulProcess('{"remotePort":3773}')); + if (args.includes("sh")) pairingCommands += 1; + return Effect.succeed(makeSuccessfulProcess("")); + }); + return Effect.gen(function* () { + const discoveryStarted = yield* Deferred.make(); + const client = HttpClient.make((request) => { + if (request.url.endsWith("/.well-known/t3/environment") && body === null) { + return Deferred.succeed(discoveryStarted, undefined).pipe(Effect.andThen(Effect.never)); + } + return Effect.succeed( + HttpClientResponse.fromWeb( + request, + request.url.endsWith("/.well-known/t3/environment") + ? new Response(body, { status }) + : new Response(""), + ), + ); + }); + const manager = yield* SshTunnel.SshEnvironmentManager; + const pairing = yield* Effect.forkChild( + Effect.result( + manager + .ensureEnvironment( + { alias: "devbox", hostname: "devbox", username: null, port: null }, + { issuePairingToken: true }, + ) + .pipe(Effect.provideService(HttpClient.HttpClient, client)), + ), + ); + if (body === null) { + yield* Deferred.await(discoveryStarted); + yield* TestClock.adjust(Duration.seconds(20)); + } + const result = yield* Fiber.join(pairing); + assert.isTrue(Result.isFailure(result)); + if (Result.isFailure(result)) assert.instanceOf(result.failure, SshPairingError); + assert.equal(pairingCommands, 0); + }).pipe( + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), + Layer.succeed(HttpClient.HttpClient, testHttpClient), + Layer.succeed(NetService.NetService, testNetService), + SshAuth.SshPasswordPrompt.disabledLayer, + SshTunnel.SshEnvironmentManager.layer({ resolveCliRunner: Effect.succeed(ARCHIVE) }), + ), + ), + Effect.scoped, + ); + }); + it.effect.each(["successful stop", "failed stop"] as const)( "closes the tunnel scope and starts fresh after a %s", (mode) => { diff --git a/packages/ssh/src/tunnel.ts b/packages/ssh/src/tunnel.ts index c13e8d3768fa..b03332a4a92c 100644 --- a/packages/ssh/src/tunnel.ts +++ b/packages/ssh/src/tunnel.ts @@ -22,6 +22,7 @@ import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import { HttpClient } from "effect/http"; +import * as HttpClientResponse from "effect/http/HttpClientResponse"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; import * as SshAuth from "./auth.ts"; @@ -724,7 +725,7 @@ printf '{"remotePort":%s,"serverKind":"%s"}\\n' "$REMOTE_PORT" "\${REMOTE_MANAGE const REMOTE_PAIRING_SCRIPT = `set -eu STATE_DIR="$HOME/.t3/ssh-launch/@@T3_STATE_KEY@@" DEFAULT_SERVER_HOME="$HOME/.t3" -RUNNER_FILE="$STATE_DIR/run-t3.sh" +RUNNER_FILE="$STATE_DIR/pair-t3.sh" mkdir -p "$STATE_DIR" cat >"$RUNNER_FILE" <<'SH' @@T3_RUNNER_SCRIPT@@ @@ -847,6 +848,41 @@ export function buildRemotePairingScript(stateKey: string, input?: RemoteT3Runne }); } +const resolveRemotePairingRunner = Effect.fn("ssh/tunnel.resolveRemotePairingRunner")(function* ( + httpBaseUrl: string, + runner: RemoteT3RunnerOptions | undefined, +) { + if (isNodeScriptRunner(runner)) { + return runner; + } + const descriptor = yield* HttpClient.get( + new URL("/.well-known/t3/environment", httpBaseUrl).toString(), + ).pipe( + Effect.flatMap(HttpClientResponse.filterStatusOk), + Effect.flatMap( + HttpClientResponse.schemaBodyJson(Schema.Struct({ serverVersion: Schema.String })), + ), + Effect.timeout(SSH_READY_TIMEOUT_MS), + Effect.mapError( + (cause) => + new SshPairingError({ + message: "Failed to discover the running SSH server version for pairing.", + stdout: "", + cause, + }), + ), + ); + if (!EXACT_ARCHIVE_VERSION.test(descriptor.serverVersion)) { + return yield* new SshPairingError({ + message: "The running SSH server did not report a valid release version for pairing.", + stdout: "", + }); + } + // A reused server can predate the desktop's runner. Its own CLI must mint + // the grant so scopes and persistence formats agree with the consumer. + return { ...runner, archiveVersion: descriptor.serverVersion }; +}); + export function buildRemoteStopScript(stateKey: string): string { return applyScriptPlaceholders(REMOTE_STOP_SCRIPT, { T3_STATE_KEY: stateKey, @@ -1688,13 +1724,15 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma key, Effect.gen(function* () { const entry = yield* ensureTunnelEntry(key, resolvedTarget, runner); - + const pairingRunner = requestOptions?.issuePairingToken + ? yield* resolveRemotePairingRunner(entry.httpBaseUrl, runner) + : runner; const pairingResult = requestOptions?.issuePairingToken ? yield* runWithSshAuth({ key, target: entry.target, operation: (authOptions) => - issueRemotePairingToken(entry.target, authOptions, runner), + issueRemotePairingToken(entry.target, authOptions, pairingRunner), }) : null; const pairingToken = pairingResult?.credential ?? null;