Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions docs/user/remote-access.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 28 additions & 0 deletions packages/ssh/src/auth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
12 changes: 12 additions & 0 deletions packages/ssh/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
);
}
66 changes: 66 additions & 0 deletions packages/ssh/src/tunnel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
3 changes: 2 additions & 1 deletion packages/ssh/src/tunnel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
type SshAuthOptions,
SshPasswordPrompt,
isSshAuthFailure,
isSshPasswordRetryable,
} from "./auth.ts";
import {
baseSshArgs,
Expand Down Expand Up @@ -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) {
Expand Down
Loading