Files
kandev/apps/web/e2e/tests/ssh/error-surfacing.spec.ts
T
José Almeida 39c574bd57 fix(ssh): make the end-to-end launch path actually work
A pass of issues uncovered by running the SSH e2e suite locally
(KANDEV_E2E_CONTAINERS=1 against the real sshd container). After this
commit all 15 non-launch SSH e2e tests pass (UI form validation,
test-connection contract, auth-methods, error-surfacing, sessions-card
empty state) and the launch path runs through prepare -> upload ->
agentctl -> instance -> forward, with WebSocket streams sometimes
disconnecting partway through (tracked below for follow-up).

Image changes (apps/web/e2e/fixtures/ssh-image.ts):
- Install curl so the SSH executor can POST to the in-container
  agentctl control server for instance creation.
- Install gcompat + libstdc++ so the dynamically-linked glibc
  agentctl binary can exec on musl-libc Alpine. Without it the
  kernel returns ENOENT for /lib64/ld-linux-x86-64.so.2.
- Install shadow + 'usermod -p *' kandev so sshd doesn't refuse
  pubkey auth for the otherwise-locked user.
- Drop in 'AllowTcpForwarding yes' and 'MaxSessions 100' via
  /etc/ssh/sshd_config.d/. Alpine's defaults are 'no' and 10, both
  of which crater the SSH executor's port-forward path.
- chown /home/kandev/.kandev to kandev on entrypoint so a host
  bind-mount of that path (used in this commit for log persistence)
  doesn't make agentctl unable to write its log file.

Image helper (apps/web/e2e/helpers/ssh.ts):
- Bind-mount the host mock-agent-linux-amd64 into the container at
  the same absolute path. kandev's orchestrator builds the agent
  command from KANDEV_MOCK_AGENT_LINUX_BINARY (a host path), so the
  remote needs the binary at that exact path; bake-into-image isn't
  enough because the absolute host path is what kandev sends.
- Bind-mount /home/kandev/.kandev so per-session agentctl logs
  survive --rm teardown and the test runner can inspect them on
  failure.
- Replace the broken CPU-spin sleep helper with a real sleep
  subprocess and a /dev/tcp TCP-open probe so timing-dependent
  helpers stop racing the container start.
- Retry the host fingerprint scan with stderr capture; the first
  scan can race the sshd accept loop on Alpine.

Backend SSH executor (apps/backend/internal/agent/lifecycle):
- executor_ssh_operations.go startRemoteAgentctl: agentctl does not
  accept --port-file or --pid-file flags (it reads AGENTCTL_PORT
  and writes its own pid). Rewrite the launch wrapper to inject
  AGENTCTL_PORT via env, capture $! as the pid, and tail the
  on-remote agentctl.log for 'HTTP server bound successfully'
  instead of waiting on imaginary side-files.
- executor_ssh_operations.go createRemoteAgentInstance: new helper
  that POSTs /api/v1/instances on the in-container control server
  via SSH-exec'd curl, returning the per-instance port the executor
  should forward to. Mirrors what executor_sprites_operations.go
  does inside its sprite. Without this step the agent stream
  returns 'configure request failed with status 404' because the
  SSH executor was talking to the control server, not the
  per-session sub-server.
- executor_ssh.go startAndForwardAgentctl: chains the new
  create-instance step in between agentctl launch and port-forward,
  and forwards the *instance* port (not the control port).
- executor_ssh.go: drop the per-host SSH connection pool (it was
  introducing a subtle aliasing issue between
  CreateInstance/StopInstance/GetRemoteStatus that made the SSH
  client unreliable across same-host sessions). Each session now
  holds its own ssh.Client directly on sshSessionState and
  ResumeRemoteInstance dials a fresh client too.
- executor_ssh_operations.go SSHPortForwarder: serialize the
  client.Dial calls with a per-forwarder mutex. golang.org/x/crypto/
  ssh.Client.Dial is documented as concurrency-safe but in practice
  parallel opens from the kandev stream-manager occasionally
  produce io.EOF as if the channel-open response was never
  received.

Test fix (apps/web/e2e/tests/ssh/error-surfacing.spec.ts):
- Q1 ('TCP refused') was using identity_source='agent' with no
  SSH_AUTH_SOCK in the backend env, so it failed at
  buildAuthMethods before reaching the TCP-refused path. Switch to
  identity_source='file' with the seed key so the test exercises
  the failure it claims to.

Known remaining issue (to investigate next): the launch path now
reaches 'agent process started' + 'ACP initialize response
received' when run in isolation, but the WebSocket updates stream
sometimes disconnects with 1006 abnormal closure shortly after. A
standalone Go reproduction of concurrent direct-tcpip channels
against the same image works without issue, so the trigger is
something specific to the kandev backend's stream-manager + my
forwarder interaction. Tracked for the next iteration.
2026-05-17 23:45:07 +01:00

99 lines
3.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test, expect } from "../../fixtures/ssh-test-base";
import { execFileSync } from "node:child_process";
import fs from "node:fs";
import path from "node:path";
/**
* 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("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);
});
});