diff --git a/src/agents/sandbox/ssh.test.ts b/src/agents/sandbox/ssh.test.ts index c2c07a3bf11..09ef1ee75fd 100644 --- a/src/agents/sandbox/ssh.test.ts +++ b/src/agents/sandbox/ssh.test.ts @@ -39,10 +39,52 @@ describe("sandbox ssh helpers", () => { expect(config).toContain("UpdateHostKeys no"); const configDir = session.configPath.slice(0, session.configPath.lastIndexOf("/")); - expect(await fs.readFile(`${configDir}/identity`, "utf8")).toBe("PRIVATE KEY"); - expect(await fs.readFile(`${configDir}/certificate.pub`, "utf8")).toBe("SSH CERT"); + expect(await fs.readFile(`${configDir}/identity`, "utf8")).toBe("PRIVATE KEY\n"); + expect(await fs.readFile(`${configDir}/certificate.pub`, "utf8")).toBe("SSH CERT\n"); expect(await fs.readFile(`${configDir}/known_hosts`, "utf8")).toBe( - "example.com ssh-ed25519 AAAATEST", + "example.com ssh-ed25519 AAAATEST\n", + ); + }); + + it("normalizes CRLF and escaped-newline private keys before writing temp files", async () => { + const session = await createSshSandboxSessionFromSettings({ + command: "ssh", + target: "peter@example.com:2222", + strictHostKeyChecking: true, + updateHostKeys: false, + identityData: + "-----BEGIN OPENSSH PRIVATE KEY-----\\nbGluZTE=\\r\\nbGluZTI=\\r\\n-----END OPENSSH PRIVATE KEY-----", + knownHostsData: "example.com ssh-ed25519 AAAATEST", + }); + sessions.push(session); + + const configDir = session.configPath.slice(0, session.configPath.lastIndexOf("/")); + expect(await fs.readFile(`${configDir}/identity`, "utf8")).toBe( + "-----BEGIN OPENSSH PRIVATE KEY-----\n" + + "bGluZTE=\n" + + "bGluZTI=\n" + + "-----END OPENSSH PRIVATE KEY-----\n", + ); + }); + + it("normalizes mixed real and escaped newlines in private keys", async () => { + const session = await createSshSandboxSessionFromSettings({ + command: "ssh", + target: "peter@example.com:2222", + strictHostKeyChecking: true, + updateHostKeys: false, + identityData: + "-----BEGIN OPENSSH PRIVATE KEY-----\nline-1\\nline-2\n-----END OPENSSH PRIVATE KEY-----", + knownHostsData: "example.com ssh-ed25519 AAAATEST", + }); + sessions.push(session); + + const configDir = session.configPath.slice(0, session.configPath.lastIndexOf("/")); + expect(await fs.readFile(`${configDir}/identity`, "utf8")).toBe( + "-----BEGIN OPENSSH PRIVATE KEY-----\n" + + "line-1\n" + + "line-2\n" + + "-----END OPENSSH PRIVATE KEY-----\n", ); }); diff --git a/src/agents/sandbox/ssh.ts b/src/agents/sandbox/ssh.ts index 1590b515e8f..d4884b44a3a 100644 --- a/src/agents/sandbox/ssh.ts +++ b/src/agents/sandbox/ssh.ts @@ -35,6 +35,35 @@ export type RunSshSandboxCommandParams = { tty?: boolean; }; +function normalizeInlineSshMaterial(contents: string, filename: string): string { + const withoutBom = contents.replace(/^\uFEFF/, ""); + const normalizedNewlines = withoutBom.replace(/\r\n?/g, "\n"); + const normalizedEscapedNewlines = normalizedNewlines + .replace(/\\r\\n/g, "\\n") + .replace(/\\r/g, "\\n"); + const expanded = + filename === "identity" || filename === "certificate.pub" + ? normalizedEscapedNewlines.replace(/\\n/g, "\n") + : normalizedEscapedNewlines; + return expanded.endsWith("\n") ? expanded : `${expanded}\n`; +} + +function buildSshFailureMessage(stderr: string, exitCode?: number): string { + const trimmed = stderr.trim(); + if ( + trimmed.includes("error in libcrypto") && + (trimmed.includes('Load key "') || trimmed.includes("Permission denied (publickey)")) + ) { + return `${trimmed}\nSSH sandbox failed to load the configured identity. The private key contents may be malformed (for example CRLF or escaped newlines). Prefer identityFile when possible.`; + } + return ( + trimmed || + (exitCode !== undefined + ? `ssh exited with code ${exitCode}` + : "ssh exited with a non-zero status") + ); +} + export function shellEscape(value: string): string { return `'${value.replaceAll("'", `'"'"'`)}'`; } @@ -201,14 +230,11 @@ export async function runSshSandboxCommand( const exitCode = code ?? 0; if (exitCode !== 0 && !params.allowFailure) { reject( - Object.assign( - new Error(stderr.toString("utf8").trim() || `ssh exited with code ${exitCode}`), - { - code: exitCode, - stdout, - stderr, - }, - ), + Object.assign(new Error(buildSshFailureMessage(stderr.toString("utf8"), exitCode)), { + code: exitCode, + stdout, + stderr, + }), ); return; } @@ -328,7 +354,10 @@ async function writeSecretMaterial( contents: string, ): Promise { const pathname = path.join(dir, filename); - await fs.writeFile(pathname, contents, { encoding: "utf8", mode: 0o600 }); + await fs.writeFile(pathname, normalizeInlineSshMaterial(contents, filename), { + encoding: "utf8", + mode: 0o600, + }); await fs.chmod(pathname, 0o600); return pathname; }