Files
kandev/apps/web/e2e/helpers/ssh.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

335 lines
11 KiB
TypeScript

import { execFileSync, spawnSync } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
/**
* Live SSH server handle returned by startSSHServer. Tests use the host/port
* pair to talk SSH, the identityFile for auth, and the hostFingerprint for
* the "Trust this host" UI checkbox assertion. Pass it back to stop /
* fault-injection helpers.
*/
export type SSHServerHandle = {
containerId: string;
containerName: string;
/** 127.0.0.1 — sshd is reached over a published port. */
host: string;
/** Published host port. */
port: number;
/** Linux user inside the container. */
user: string;
/** Absolute path to the test-only private key. */
identityFile: string;
/** Absolute path to the matching public key. */
publicKeyFile: string;
/** SHA256:... fingerprint of the container's host key after first start. */
hostFingerprint: string;
/** Directory holding identityFile / publicKeyFile / authorized_keys. */
workDir: string;
};
const SSH_BASE_PORT = 22000;
/**
* Start a fresh sshd container for a Playwright worker. Each worker gets:
* - A unique published port (SSH_BASE_PORT + workerIndex).
* - A unique ed25519 keypair generated into workDir.
* - A unique container name `kandev-sshd-e2e-<workerIndex>` (labeled
* `kandev.managed=true` so the shared cleanup hook destroys it).
*
* Blocks until sshd answers TCP connections and a stable host fingerprint
* is observable via `ssh-keyscan`.
*/
export function startSSHServer(
workerIndex: number,
imageTag: string,
workDir: string,
): SSHServerHandle {
fs.mkdirSync(workDir, { recursive: true });
const port = SSH_BASE_PORT + workerIndex;
const containerName = `kandev-sshd-e2e-${workerIndex}`;
const identityFile = path.join(workDir, "id_ed25519");
const publicKeyFile = `${identityFile}.pub`;
generateKeypair(identityFile);
// kandev's orchestrator builds the agent command using KANDEV_MOCK_AGENT_LINUX_BINARY,
// which is set to the *host* absolute path of mock-agent-linux-amd64.
// Docker e2e gets away with using that path inside the container because
// it bind-mounts the same host file at the same path; for SSH e2e we have
// to do the equivalent — otherwise the kandev backend tells agentctl on
// the remote to exec a path that doesn't exist there.
const mockAgentHostPath = path.resolve(__dirname, "../../../backend/bin/mock-agent-linux-amd64");
if (!fs.existsSync(mockAgentHostPath)) {
throw new Error(
`mock-agent-linux-amd64 not found at ${mockAgentHostPath}; ` +
`build it with 'make -C apps/backend build-mock-agent-linux'.`,
);
}
// The container reads /authorized_keys on entry, so mount the public key
// into that path read-only.
removeContainerIfExists(containerName);
// Bind-mount the remote workspace root to the host so agentctl + task logs
// survive `--rm` teardown and the test runner can `cat /tmp/...` them in
// failure summaries. The kandev user inside the container writes to its
// own home dir at /home/kandev/.kandev, so we point the host side at the
// worker's workDir/remote-home (created on demand).
const remoteHome = path.join(workDir, "remote-home");
fs.mkdirSync(remoteHome, { recursive: true });
const containerId = execFileSync(
"docker",
[
"run",
"-d",
"--rm",
"--name",
containerName,
"--label",
"kandev.managed=true",
"--label",
"kandev.e2e.role=ssh-target",
"--cap-add",
"NET_ADMIN", // iptables-based fault injection
"-p",
`127.0.0.1:${port}:22`,
"-v",
`${publicKeyFile}:/authorized_keys:ro`,
"-v",
`${mockAgentHostPath}:${mockAgentHostPath}:ro`,
"-v",
`${remoteHome}:/home/kandev/.kandev`,
imageTag,
],
{ encoding: "utf8" },
)
.toString()
.trim();
waitForTCPOpen("127.0.0.1", port);
const hostFingerprint = scanHostFingerprint("127.0.0.1", port);
return {
containerId,
containerName,
host: "127.0.0.1",
port,
user: "kandev",
identityFile,
publicKeyFile,
hostFingerprint,
workDir,
};
}
/**
* Tear down the container started by startSSHServer. Safe to call even if
* the container has already exited.
*/
export function stopSSHServer(handle: SSHServerHandle): void {
spawnSync("docker", ["rm", "-f", handle.containerName], { stdio: "ignore" });
}
/**
* Regenerate the container's host key and bounce sshd. The container's host
* fingerprint changes; subsequent connections that use the old pinned
* fingerprint surface the host-key-changed error verbatim — exactly the path
* the host-key-rotation specs care about.
*
* Returns the new fingerprint so the test can assert against it.
*/
export function regenerateHostKey(handle: SSHServerHandle): string {
execInContainer(handle, [
"sh",
"-c",
"rm -f /etc/ssh/ssh_host_* && ssh-keygen -A >/dev/null && pkill -HUP sshd || true",
]);
// sshd needs a moment to re-bind after pkill -HUP.
waitForTCPOpen(handle.host, handle.port);
return scanHostFingerprint(handle.host, handle.port);
}
/**
* Drop all TCP/22 traffic at the container's INPUT chain so any new SSH
* connection times out. Existing keepalives fire and eventually evict the
* pooled connection. Call restoreTraffic() to undo.
*/
export function dropTrafficToPort22(handle: SSHServerHandle): void {
execInContainer(handle, ["iptables", "-A", "INPUT", "-p", "tcp", "--dport", "22", "-j", "DROP"]);
}
export function restoreTraffic(handle: SSHServerHandle): void {
// Best-effort: removes the first matching rule. Idempotent across repeat calls
// because subsequent ones report "Bad rule" via stderr and we ignore it.
spawnSync(
"docker",
[
"exec",
handle.containerName,
"iptables",
"-D",
"INPUT",
"-p",
"tcp",
"--dport",
"22",
"-j",
"DROP",
],
{ stdio: "ignore" },
);
}
/**
* Kill a remote process by pid inside the container. Used by recovery tests
* to simulate "the agentctl process died" without disturbing the SSH
* connection itself.
*/
export function killRemotePid(handle: SSHServerHandle, pid: number): void {
execInContainer(handle, ["kill", "-9", String(pid)]);
}
/**
* Read a file from inside the container as the kandev user. Used to assert
* what the SSH executor wrote — uploaded agentctl path, sha256 sidecar, port
* file, pid file. Returns the file's contents; throws on `cat` failure.
*/
export function readRemoteFile(handle: SSHServerHandle, absPath: string): string {
return execInContainer(handle, ["sh", "-c", `cat ${shellQuote(absPath)}`]);
}
/**
* Check whether a file or directory exists inside the container.
*/
export function remotePathExists(handle: SSHServerHandle, absPath: string): boolean {
const res = spawnSync(
"docker",
["exec", handle.containerName, "sh", "-c", `test -e ${shellQuote(absPath)}`],
{ stdio: "ignore" },
);
return res.status === 0;
}
/**
* List the entries directly under a directory inside the container.
* One basename per line; empty lines suppressed.
*/
export function listRemoteDir(handle: SSHServerHandle, absPath: string): string[] {
const res = spawnSync(
"docker",
["exec", handle.containerName, "sh", "-c", `ls -1 ${shellQuote(absPath)} 2>/dev/null || true`],
{ encoding: "utf8" },
);
return res.stdout
.toString()
.split("\n")
.map((s) => s.trim())
.filter(Boolean);
}
/**
* Count how many sshd child processes are alive in the container. Specs that
* assert "same-host sessions share one SSH connection" use this to detect
* connection fan-in.
*/
export function countSshdConnections(handle: SSHServerHandle): number {
const out = execInContainer(handle, ["sh", "-c", "ps -ef | grep -c 'sshd.*: kandev' || true"]);
return parseInt(out.trim(), 10) || 0;
}
// --- internals ---
function generateKeypair(identityFile: string): void {
if (fs.existsSync(identityFile)) {
fs.rmSync(identityFile, { force: true });
fs.rmSync(`${identityFile}.pub`, { force: true });
}
execFileSync(
"ssh-keygen",
["-t", "ed25519", "-f", identityFile, "-N", "", "-C", "kandev-e2e", "-q"],
{ stdio: process.env.E2E_DEBUG ? "inherit" : "ignore" },
);
// Ensure private key has the strict perms ssh requires.
fs.chmodSync(identityFile, 0o600);
}
function execInContainer(handle: SSHServerHandle, argv: string[]): string {
const res = spawnSync("docker", ["exec", handle.containerName, ...argv], {
encoding: "utf8",
});
if (res.status !== 0) {
throw new Error(
`docker exec ${argv.join(" ")} failed (status=${res.status}): ${res.stderr || res.stdout}`,
);
}
return res.stdout;
}
function removeContainerIfExists(name: string): void {
spawnSync("docker", ["rm", "-f", name], { stdio: "ignore" });
}
function waitForTCPOpen(host: string, port: number, timeoutMs = 30_000): void {
const deadline = Date.now() + timeoutMs;
let lastErr = "";
while (Date.now() < deadline) {
// Prefer bash's /dev/tcp probe — always available on POSIX, no `nc` dep.
const res = spawnSync("bash", ["-c", `exec 3<>/dev/tcp/${host}/${port} && exec 3<&-`], {
stdio: ["ignore", "ignore", "pipe"],
encoding: "utf8",
});
if (res.status === 0) return;
lastErr = (res.stderr ?? "").trim();
sleep(250);
}
throw new Error(`sshd at ${host}:${port} did not open within ${timeoutMs}ms: ${lastErr}`);
}
function scanHostFingerprint(host: string, port: number): string {
// ssh-keyscan prints the host's public key on stdout; `# host SSH-2.0-...`
// comment lines go to stderr. We retry briefly because the first scan can
// race the container's first sshd accept.
const deadline = Date.now() + 15_000;
let stdout = "";
let stderr = "";
while (Date.now() < deadline) {
const keyOut = spawnSync(
"ssh-keyscan",
["-p", String(port), "-t", "ed25519", "-T", "5", host],
{
encoding: "utf8",
},
);
stdout = keyOut.stdout ?? "";
stderr = keyOut.stderr ?? "";
if (keyOut.status === 0 && stdout.trim()) break;
sleep(300);
}
if (!stdout.trim()) {
throw new Error(
`ssh-keyscan ${host}:${port} returned nothing after retries; stderr=${stderr.trim()}`,
);
}
const tmpKey = path.join(os.tmpdir(), `kandev-e2e-keyscan-${process.pid}-${port}`);
fs.writeFileSync(tmpKey, stdout);
try {
const fp = spawnSync("ssh-keygen", ["-lf", tmpKey], { encoding: "utf8" });
const match = fp.stdout.match(/SHA256:\S+/);
if (!match) throw new Error(`could not parse SHA256 fingerprint from: ${fp.stdout}`);
return match[0];
} finally {
fs.rmSync(tmpKey, { force: true });
}
}
function shellQuote(s: string): string {
return `'${s.replace(/'/g, `'\\''`)}'`;
}
function sleep(ms: number): void {
// Synchronous sleep via the system `sleep` binary. Subprocess overhead is
// dwarfed by the ms we're waiting, and we get an honest wall-clock pause
// rather than CPU spin.
spawnSync("sleep", [(ms / 1000).toFixed(3)]);
}