39c574bd57
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.
99 lines
3.9 KiB
TypeScript
99 lines
3.9 KiB
TypeScript
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);
|
||
});
|
||
});
|