import { test, expect } from "../../fixtures/ssh-test-base"; import { execFileSync } from "node:child_process"; import fs from "node:fs"; import path from "node:path"; import { execInContainer } from "../../helpers/ssh"; /** * The SSH executor commits to surfacing connection errors verbatim — no * generic "executor failed" wrapping. Spec lists this as a hard * requirement; the suite asserts the strings users see. * * Covers e2e-plan.md group Q (Q1–Q5). */ test.describe("ssh error surfacing", () => { test("TCP refused — error mentions 'connection refused'", async ({ apiClient, seedData }) => { const result = await apiClient.testSSHConnection({ name: "Q1", host: "127.0.0.1", port: 1, user: "kandev", identity_source: "file", identity_file: seedData.sshTarget.identityFile, }); expect(result.success).toBe(false); const handshake = result.steps.find((s) => s.name === "SSH handshake"); expect(handshake?.error).toMatch(/connection refused|tcp dial/i); }); test("auth failed — error names the failure mode", async ({ apiClient, seedData }) => { const strangerKey = path.join(seedData.sshTarget.workDir, "q2-stranger"); fs.rmSync(strangerKey, { force: true }); fs.rmSync(`${strangerKey}.pub`, { force: true }); execFileSync("ssh-keygen", ["-t", "ed25519", "-f", strangerKey, "-N", "", "-q"]); const result = await apiClient.testSSHConnection({ name: "Q2 auth fail", host: seedData.sshTarget.host, port: seedData.sshTarget.port, user: seedData.sshTarget.user, identity_source: "file", identity_file: strangerKey, }); expect(result.success).toBe(false); const handshake = result.steps.find((s) => s.name === "SSH handshake"); expect(handshake?.error).toMatch( /handshake|unable to authenticate|permission denied|no supported methods/i, ); }); test("ProxyJump unreachable — error mentions the bastion", async ({ apiClient, seedData }) => { const result = await apiClient.testSSHConnection({ name: "Q4 bastion unreachable", host: seedData.sshTarget.host, port: seedData.sshTarget.port, user: seedData.sshTarget.user, identity_source: "file", identity_file: seedData.sshTarget.identityFile, proxy_jump: "127.0.0.1:1", }); expect(result.success).toBe(false); const handshake = result.steps.find((s) => s.name === "SSH handshake"); expect(handshake?.error).toMatch(/bastion|proxy|connection refused/i); }); test("unsupported arm64 host surfaces the linux/amd64-only guidance", async ({ apiClient, seedData, }) => { // We can't trivially spin up arm64 sshd, but we can assert the message // shape via the existing Verify arch step on the running container. // When the host is the expected x86_64, this step succeeds — that's a // negative control. The arm64 message itself is unit-tested in // executor_ssh_connection_test.go (TestRequireSupportedArch). const result = await apiClient.testSSHConnection({ name: "Q-arch-control", host: seedData.sshTarget.host, port: seedData.sshTarget.port, user: seedData.sshTarget.user, identity_source: "file", identity_file: seedData.sshTarget.identityFile, }); expect(result.success).toBe(true); const arch = result.steps.find((s) => s.name === "Verify arch"); expect(arch?.success).toBe(true); expect(arch?.output).toBe("x86_64"); }); test("missing agent binary on remote — launch fails with install hint, not raw exec error", async ({ apiClient, seedData, }) => { test.setTimeout(180_000); // The sshd image bakes mock-agent at /usr/local/bin/mock-agent (so // launches normally find it via $PATH). Move it aside to simulate a // fresh host that hasn't had the agent CLI installed yet, then assert // the pre-flight surfaces a friendly, actionable error. execInContainer(seedData.sshTarget, [ "sh", "-c", "mv /usr/local/bin/mock-agent /usr/local/bin/mock-agent.bak", ]); try { const task = await apiClient.createTaskWithAgent( seedData.workspaceId, "Q-missing-binary", seedData.agentProfileId, { description: "/e2e:simple-message", workflow_id: seedData.workflowId, workflow_step_id: seedData.startStepId, repository_ids: [seedData.repositoryId], executor_profile_id: seedData.sshExecutorProfileId, }, ); // Wait for the session to settle into FAILED. We can't use // waitForLatestSessionDone because its terminal-set is COMPLETED/ // WAITING_FOR_INPUT; failures land in FAILED. Poll explicitly. await expect .poll( async () => { const { sessions } = await apiClient.listTaskSessions(task.id); return sessions[0]?.state ?? ""; }, { timeout: 60_000, message: "session reaches FAILED" }, ) .toBe("FAILED"); const { sessions } = await apiClient.listTaskSessions(task.id); const errMsg = sessions[0]?.error_message ?? ""; // The structured pre-flight message names the agent, the binary, and // the install hint sourced from agent.InstallScript(). Bare exec // errors (`exec: "...": executable file not found in $PATH`) used to // surface here verbatim — this assertion guards against regressing // back to that path. expect(errMsg).toMatch(/Mock Agent/i); expect(errMsg).toContain('"mock-agent"'); expect(errMsg).toMatch(/\$PATH/); // Mock agent's InstallScript is a deterministic echo-only command; // assert at least the literal "mock-install" leaks through so we know // the hint block landed. expect(errMsg).toMatch(/mock-install/); } finally { execInContainer(seedData.sshTarget, [ "sh", "-c", "mv /usr/local/bin/mock-agent.bak /usr/local/bin/mock-agent", ]); } }); test("backend 5xx on /api/v1/ssh/test is not the same as 'test failed'", async ({ apiClient, }) => { // POST with a body that triggers a 400 from the gin route. Asserts the // client surfaces the HTTP error verbatim rather than synthesising a // SSHTestResult-shaped success=false. const res = await apiClient.rawRequest("POST", "/api/v1/ssh/test", "garbage"); expect(res.status).toBe(400); const body = await res.text(); expect(body).toMatch(/invalid request body|json/i); }); });