executor_ssh.targetFromMetadata: reject malformed / out-of-range
ssh_port instead of silently falling back to 0 → 22 in the resolver.
"abc", "0", "70000" all now return a "must be 1-65535" error so a
broken config can't masquerade as a successful launch routed through
port 22. Cubic P2.
scanFingerprintInsideNetwork (bastion fixture): retry on empty output.
Mirrors the host-side scanFingerprintViaHost retry loop — sshd accepts
TCP before host-key negotiation completes on a cold container, so the
keyscan can come back empty on the first try. Bound at 15s; surfaces
"status=..., stderr=..." on final failure for diagnosis. Cubic P2.
executor-crud D5 (changing config…): restore the seeded executor in a
try/finally. The test pinned the executor to host=10.255.255.1 to
verify the snapshot stayed sticky on the live session, but never put
the real host back, so any later spec in the same worker that reused
seedData.sshExecutorId would dial the unroutable address and hang.
Cubic P2.
e2e-plan.md: replace the "roughly 75" estimate with the actual 80 /
17-files current count plus the ~2.4 min wall-clock. Cubic P3.
G2 (sessions-endpoint filter) iterated `for (const s of sessions)` over
the listSSHSessions result without first asserting `length > 0` — an
empty response let the test pass without checking anything. Seed an SSH
launch before the loop and require at least one row, so a handler
regression that returned [] would now fail loud. CodeRabbit P1.
F6 (ws ssh.test parity) called apiClient.testSSHConnection twice (HTTP
both times) and compared the two HTTP responses, which would have
passed even if the WS dispatcher entry was broken. Use apiClient.
wsRequest("ssh.test", payload) for the second call so the gateway's
WS handler is actually invoked, and assert the step names match across
transports. CodeRabbit P1.
The fingerprint-change-warning spec pinned a deliberately bogus
fingerprint on the worker's seeded SSH executor to exercise the
mismatch UI, then exited without resetting it. Later specs in the
same worker that launched real tasks against the same executor
(workdir-per-profile R1/R2 in alphabetical order) failed at SSH dial
time with host-key-changed, since the executor was still pinned to
the bad value.
Wrap the assertion in a try/finally that restores the original
fingerprint from seedData when the test ends, so the worker leaves
the executor in the same state it inherited.
Hostkey-rotation specs mutate the worker-scoped sshd container and
the seeded SSH executor's pinned fingerprint. Without a cleanup hook,
the next spec in the same worker that resolves seedData.sshTarget.
hostFingerprint observes a stale value and the UI's trusted-badge
check (persistence R1) flaps.
Add an afterEach that re-probes the live container via kandev, syncs
the fingerprint into both seedData and the seeded executor's pinned
config. The cleanup is a no-op when the probe fails (e.g. K2 left an
intentionally bad fingerprint that no longer matches anything — the
next test will re-establish state via its own setup).
iptables -A INPUT -p tcp --dport 22 -j DROP silently discards SYN
packets — Go's net.Dialer sits in the connect syscall through the
kernel's TCP_SYN_RETRIES (default 6 ~ 127s) before the syscall is
abandoned, even though the dialer's logical Timeout is 30s. The
spec polled the testSSHConnection API for the failure path; the
request blocked long enough that Node's keep-alive evicted the
socket and the test failed with "other side closed" instead of
seeing kandev's success=false response.
Race the probe against a 5s timeout. If the request errored or
timed out the test passes (both are valid evidence that the drop
hit the dial path); if it came back with a response, assert
success=false the same way the original spec did. The restore-probe
poll still gates on a real success after restoreTraffic.
dialViaJump fed target.ProxyJump straight into ResolveSSHTarget as a
host alias. That works for `Host prod-jump` entries in ~/.ssh/config
but turns the literal `user@host:port` form into a single hostname
that the kernel resolver chokes on (`lookup kandev@127.0.0.1:23000: no
such host`).
Detect the literal shape inline (`[user@]host[:port]`) and build the
bastion SSHTarget from its parts. Falls back to the alias path when
no @ / : separators are present.
Fixes the proxy-jump.spec.ts bastion flow. While here:
* scanFingerprintInsideNetwork: alpine package is `openssh-client`,
not the (nonexistent) `openssh-keyscan`. Surface install errors
so future failures aren't silent.
* scanFingerprintViaHost: retry on empty output — sshd accepts TCP
before host-key negotiation completes on a cold container.
* startBastionAndTarget: share one keypair across bastion + target
so a single -i auths both hops (matches how OpenSSH ProxyJump
handles per-host identities outside ~/.ssh/config).
* proxy-jump N1: assert any SHA256 fingerprint came back, since the
test no longer pre-discovers a value to compare against.
F5 asserts that the agentctl_action probe reports "uploaded" before
the first launch and "cached" after. Tests share a worker-scoped
sshd container, so an earlier test in the same worker may have
already primed the cache, making the "before" assertion flap.
Wipe ~/.kandev/bin/agentctl + .sha256 from the container at the top
of F5 so the assertion sees the documented fresh-install path.
Export execInContainer so the spec can reach into the container
without copying the docker-exec boilerplate.
Add error_message to the listTaskSessions return type so the
hostkey-rotation spec can read the field (which the backend already
returns).
regenerateHostKey was returning the ssh-keyscan fingerprint, but
ssh-keyscan and the Go ssh client can negotiate different host-key
types — meaning the fingerprint kandev observes during a real probe
does not always match what keyscan saw. The keyscan output was used
verbatim as the expected value, which K1 / K3 then asserted against
kandev's pinned-fingerprint result, so the specs flapped depending
on which algorithm sshd advertised.
Drop the return value from regenerateHostKey and have callers hit
/api/v1/ssh/test to learn the canonical fingerprint, matching what
the SSH fixture already does at seeding time.
K2 was polling env.error_message which doesn't exist — the launch
fails at SSH dial time before the executor is recorded on the task
environment. Point the polling at the task-session error_message
instead, where updateTaskSessionState actually persists the dial
failure.
The wrapper writes agentctl.pid and agentctl.log to <sessionDir>; the
remote agentctl port travels via AGENTCTL_PORT env + ExecutorRunning
metadata, not a separate .port file. Assert the files that actually
exist and the metadata field that carries the port.
The simple-message scenario completes inside a couple hundred
milliseconds, so the previous "wait for executor_type=ssh, then
query once" pattern raced the row's removal during teardown. Poll
listSSHSessions directly with a 60s window so the row is observed
while the executor is up.
Adds dumpRemoteLogs helper for copying per-session agentctl.log
files out of the sshd container on assertion failure, easing
debugging when the row never appears.
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.
Three bugs surfaced by actually running the SSH e2e suite
(KANDEV_E2E_CONTAINERS=1 against the containers project):
1. Alpine's `adduser -D kandev` leaves the account password-locked
(`!` in /etc/shadow). sshd then refuses every auth method for that
user — including pubkey — with "User kandev not allowed because
account is locked". Fix: `usermod -p '*' kandev` in the Dockerfile
so the account is open for key-based login but still has no valid
password. Also adds the `shadow` apk package for `usermod`.
2. ssh-keyscan reports the ed25519 host fingerprint, but Go's ssh
client may negotiate a different host key type when sshd offers
multiple — meaning the fingerprint the fixture pre-scanned didn't
match the one kandev observed at handshake time. Fix: the fixture
now hits /api/v1/ssh/test with a permissive callback right after
the container is up and uses that fingerprint as the pinned value.
Same code path real users hit; eliminates the negotiation skew by
construction.
3. ssh.ts had a CPU-spin sleep helper and an nc-dependent TCP probe,
both fragile. Replaced with a proper sleep subprocess and a bash
/dev/tcp probe, plus retry-with-stderr on the fingerprint scan so
failures are diagnosable.
Also tightens test-endpoint.spec.ts F4: the test was using
identity_source=agent to "fail fast", but that fails inside
buildAuthMethods before the handshake step ever runs. Switched to
identity_source=file against a refused port so the test exercises
the actual TCP+handshake failure path.
After these fixes 4/6 test-endpoint cases pass against a real sshd.