diff --git a/docs/user/remote-access.md b/docs/user/remote-access.md index b10123ab6223..07fc89adb74e 100644 --- a/docs/user/remote-access.md +++ b/docs/user/remote-access.md @@ -118,6 +118,10 @@ scheme uses HTTP, so include `https://` when your server uses HTTPS. ## Desktop-managed SSH +T3 Code asks for an SSH password only when the server offers password or keyboard-interactive +authentication. For key-only hosts, an authentication failure shows the SSH error. If that error +says `communication with agent failed`, check that your SSH agent is available, then retry. + In the desktop app, open **Settings → Connections → Add environment**, choose **SSH**, and enter a host or SSH alias such as `user@example.com`. T3 Code starts or reuses a server there and opens the port forward for you. Projects, provider diff --git a/packages/ssh/src/auth.test.ts b/packages/ssh/src/auth.test.ts index 3cd42ad43825..82c0f1f3f368 100644 --- a/packages/ssh/src/auth.test.ts +++ b/packages/ssh/src/auth.test.ts @@ -11,9 +11,37 @@ import { buildSshAskpassHelperDescriptor, buildSshChildEnvironment, isSshAuthFailure, + isSshPasswordRetryable, } from "./auth.ts"; describe("ssh auth", () => { + it("does not offer a password retry when a key-only host's agent fails", () => { + const error = new Error( + [ + 'sign_and_send_pubkey: signing failed for ED25519 "key.pub" from agent: communication with agent failed', + "dev@10.13.37.3: Permission denied (publickey).", + ].join("\n"), + ); + assert.isTrue(isSshAuthFailure(error)); + assert.isFalse(isSshPasswordRetryable(error)); + }); + + it.each([ + ["Permission denied (publickey).", false], + ["Permission denied (hostbased,gssapi-with-mic).", false], + ["Permission denied (password).", true], + ["Permission denied (keyboard-interactive).", true], + ["Permission denied (publickey,password,keyboard-interactive).", true], + ["Permission denied (PASSWORD, publickey).", true], + ["Permission denied (publickey,password-expired).", false], + ["Authentication failed", false], + ["Too many authentication failures", false], + ["Connection timed out", false], + ])("checks the offered methods in %s", (message, expected) => { + assert.equal(isSshPasswordRetryable(new Error(message)), expected); + assert.equal(isSshPasswordRetryable(message), expected); + }); + it.effect("detects ssh auth failures from common permission denied messages", () => Effect.sync(() => { assert.equal( diff --git a/packages/ssh/src/auth.ts b/packages/ssh/src/auth.ts index fca086de3186..0b5657c64777 100644 --- a/packages/ssh/src/auth.ts +++ b/packages/ssh/src/auth.ts @@ -213,3 +213,15 @@ export function isSshAuthFailure(error: unknown): boolean { /too many authentication failures/u.test(normalized) ); } + +export function isSshPasswordRetryable(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + const match = /permission denied \(([^)]*)\)/iu.exec(message); + const methods = match?.[1] + ?.toLowerCase() + .split(",") + .map((method) => method.trim()); + return ( + methods?.some((method) => method === "password" || method === "keyboard-interactive") ?? false + ); +} diff --git a/packages/ssh/src/tunnel.test.ts b/packages/ssh/src/tunnel.test.ts index e2536ba92017..2261bc5bfd06 100644 --- a/packages/ssh/src/tunnel.test.ts +++ b/packages/ssh/src/tunnel.test.ts @@ -386,6 +386,72 @@ describe("ssh tunnel scripts", () => { }).pipe(Effect.provide(processLayer)); }); + it.effect.each([ + { methods: "publickey", prompts: 0 }, + { methods: "publickey,password", prompts: 2 }, + { methods: "publickey,keyboard-interactive", prompts: 2 }, + ])( + "only prompts for server-offered password authentication ($methods)", + ({ methods, prompts }) => { + const stderr = [ + 'sign_and_send_pubkey: signing failed for ED25519 "key.pub" from agent: communication with agent failed', + `dev@10.13.37.3: Permission denied (${methods}).`, + ].join("\n"); + const attempts: number[] = []; + let commandCount = 0; + const spawner = ChildProcessSpawner.make((command) => + Effect.sync(() => { + if (commandArgs(command).includes("-G")) { + return makeSuccessfulProcess("hostname 10.13.37.3\nuser dev\nport 22\n"); + } + commandCount += 1; + return { + ...makeSuccessfulProcess(""), + exitCode: Effect.succeed(ChildProcessSpawner.ExitCode(255)), + stderr: Stream.make(new TextEncoder().encode(stderr)), + }; + }), + ); + return Effect.gen(function* () { + const manager = yield* SshEnvironmentManager; + const result = yield* Effect.result( + manager.ensureEnvironment({ + alias: "devbox", + hostname: "10.13.37.3", + username: "dev", + port: 22, + }), + ); + assert.isTrue(Result.isFailure(result)); + if (Result.isFailure(result)) { + assert.instanceOf(result.failure, SshCommandError); + assert.equal(result.failure.message, stderr); + } + assert.deepEqual(attempts, prompts === 0 ? [] : [1, 2]); + assert.equal(commandCount, prompts + 1); + }).pipe( + Effect.provide( + Layer.mergeAll( + NodeServices.layer, + Layer.succeed(ChildProcessSpawner.ChildProcessSpawner, spawner), + Layer.succeed(HttpClient.HttpClient, testHttpClient), + Layer.succeed(NetService.NetService, testNetService), + Layer.succeed(SshPasswordPrompt, { + isAvailable: true, + request: ({ attempt }) => + Effect.sync(() => { + attempts.push(attempt); + return "test-password"; + }), + }), + SshEnvironmentManager.layer(), + ), + ), + 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 9cb6b25e4121..b05f1855409c 100644 --- a/packages/ssh/src/tunnel.ts +++ b/packages/ssh/src/tunnel.ts @@ -27,6 +27,7 @@ import { type SshAuthOptions, SshPasswordPrompt, isSshAuthFailure, + isSshPasswordRetryable, } from "./auth.ts"; import { baseSshArgs, @@ -1274,7 +1275,7 @@ const makeSshEnvironmentManager = Effect.fn("ssh/tunnel.SshEnvironmentManager.ma cause: input.error, }); const promptService = yield* SshPasswordPrompt; - if (!promptService.isAvailable) { + if (!promptService.isAvailable || !isSshPasswordRetryable(input.error)) { return yield* input.error; } if (input.authSecret !== null) {