Commit Graph

19 Commits

Author SHA1 Message Date
José Almeida 044b76bbd5 fix(ssh): don't leak session-scoped metadata across sibling sessions
reuseExistingEnvironment copies persistent metadata from the previous
ExecutorRunning row into the new launch request so executors can pick up
worktree/container/sandbox IDs. For SSH that swept in session-scoped
keys too — agentctl PID/port, session dir, local forward port — which
made the second session's ResumeRemoteInstance reattach to the first
session's agentctl process. Mark those keys as session-scoped and skip
them in applyExecutorRunningMetadata; the same-session resume path
(applyRunningRecordToResumeRequest) still copies them because it works
off the session's own ExecutorRunning record.
2026-05-18 02:34:27 +01:00
José Almeida c4e5f593b6 fix(ssh): unique sftp temp filename so concurrent uploads don't collide
When two sessions on the same task launch back-to-back they both call
ensureAgentctlOnHost which streams to <remotePath>.tmp before renaming.
With a shared .tmp name the second uploader's rename errors with
"file does not exist" once the first uploader's rename consumed the temp
file. Give each uploader its own pid+random-suffix temp; uploaders write
identical sha256-keyed content, so the rename race is then safe.
2026-05-18 02:33:57 +01:00
José Almeida cbc573b0a0 fix(ssh): reject invalid ssh_port + retry in-net keyscan + restore D5 + doc count
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.
2026-05-18 00:05:15 +01:00
José Almeida 99edda84b8 fix(ssh): IPv6 ProxyJump + port range + dedup + docker timeouts
parseLiteralProxyJump: handle bracketed IPv6 + reject invalid ports.
  The literal parser used LastIndex(":") to split host from port, which
  for [2001:db8::1]:22 left host as "[2001:db8::1]" and produced an
  invalid double-bracket address through net.JoinHostPort. Recognise
  the bracketed form explicitly via a parseBracketedHostPort helper
  and strip the brackets. Validate port is in [1, 65535] instead of
  accepting any integer (Atoi happily parses negative or out-of-range
  values). Split the host/port branch into its own function so the
  nesting stays under the golangci nestif limit.

ssh-create-page: dedupe on append.
  setExecutors([...current, next]) would double-list the same executor
  if a WS event (or another refresh) inserted it while createExecutor
  was in flight. Skip the append when current already has the id.

ssh-image fixture: time-bound docker probe + build.
  hasSSHContainerSupport ran `docker info` with no timeout — an
  unreachable / hung daemon would stall the Playwright worker
  indefinitely. Cap at 10s for the probe. buildE2ESSHImage runs
  `docker build` with no timeout; cap at 15 min so a stuck registry
  pull or BuildKit hang fails fast.
2026-05-18 00:05:15 +01:00
José Almeida 2a342927e7 fix(ssh): defend launch + handshake against several real bugs
manager_launch: trusted exec-config keys override req.Metadata.
  buildLaunchMetadata applied executor config only when the matching
  key was missing in req.Metadata. A request that carried metadata.
  ssh_host_fingerprint (or ssh_host, ssh_identity_*) could pivot the
  launch to a different target and bypass the pinned fingerprint.
  Pin a list of connection-routing keys (ssh_host, ssh_port, ssh_user,
  ssh_host_fingerprint, ssh_identity_source, ssh_identity_file,
  ssh_proxy_jump) and have the executor config always win for those.
  Cubic / CodeRabbit P1.

executor_ssh.CreateInstance: reuse the resumed session state.
  ResumeRemoteInstance attaches the recovered ssh.Client + forwarder
  to r.sessions[InstanceID] and refreshes metadata, then the lifecycle
  manager unconditionally called CreateInstance, which dialed a fresh
  client and started a second remote agentctl on top of the live one
  — leaking the resumed state. CreateInstance now early-returns from
  the existing r.sessions entry via buildResumedInstance. Cubic P1.

executor_ssh.Close: actually release tracked sessions.
  After dropping the SSHConnPool, Close was a no-op. A hard kandev
  exit (no per-session StopInstance) left forwarders and SSH clients
  open. Iterate r.sessions and Close the forwarder + client for each.
  Cubic P2.

buildAuthMethods: close the ssh-agent unix socket after handshake.
  Each agent-authenticated dial opened net.Dial("unix", $SSH_AUTH_SOCK)
  and never closed it; only PublicKeysCallback held the agent.Client
  reference and that callback is consulted exactly once at handshake.
  Return a cleanup func from buildAuthMethods + buildClientConfig and
  defer it from dialSSH / dialViaJump so the socket releases as soon
  as key exchange completes. Greptile P1 security.

waitAgentctlHealthy: probe HTTP /health, not just the TCP listener.
  The local port is owned by the SSH forwarder, which Accepts before
  it Dials the remote direct-tcpip channel — a TCP connect can succeed
  before the channel actually establishes. Issue a real GET /health
  and require a 2xx so a broken tunnel surfaces here rather than at
  the first agent operation. Cubic P1.

ssh-connection-card: also gate the trust + save on in-flight tests.
  Editing a connection field during a pending test silently kept
  resultStale=false because prev.result was still null; when the test
  returned, the new fingerprint was bound to the old form values but
  the gate let the user trust + save anyway. Treat in-flight tests as
  prior tests for staleness purposes, and preserve resultStale across
  the test's success setState. Cubic P2.
2026-05-18 00:05:15 +01:00
José Almeida b6f77d921d fix(ssh): verify bastion host key + tighten the trust-gate UX
Bastion host-key verification (security)
  resolveBastion built the bastion's ClientConfig with an empty
  PinnedFingerprint, which buildClientConfig translated into a
  callback that accepts any key. An attacker on the path to the
  bastion (DNS poisoning, route hijack) could pivot through it
  before the final-target check ever ran. Greptile flagged it as P1.

  Swap in a callback that consults ~/.ssh/known_hosts via
  knownhosts.New: matched key → accept; mismatched key → reject loud
  (the alarm); host absent → accept with the same TOFU semantics
  OpenSSH uses for StrictHostKeyChecking=accept-new, so e2e and
  first-connect setups still work without manual `ssh-keyscan`
  priming.

Stale-test trust gate (frontend)
  Editing a connection field after a successful test previously kept
  the prior result + cleared `trust`, but the trust checkbox could
  be re-checked and saved with the old fingerprint bound to the new
  target. CodeRabbit + cubic both flagged it as P1.

  Add a `resultStale` flag that flips when a CONNECTION_FIELD edit
  follows a successful test, surface a warning under the result, and
  disable the trust checkbox + save button until the user re-runs
  Test Connection. handleTest clears the flag.

While here
  * Surface onSave failures in the card instead of swallowing them.
  * ssh-create-page: read the executors slice from the live store
    (useAppStoreApi + getState()) at write time so a concurrent
    update doesn't get overwritten by a stale closure.
  * Split FingerprintTrustBlock into its own file to keep
    ssh-connection-card under the 600-line cap.
2026-05-18 00:05:15 +01:00
José Almeida a30833b336 refactor(ssh): drop the unused conn pool + remote curl dependency
CodeRabbit and Greptile both flagged the SSHConnPool: the executor never
took the pool path (CreateInstance always dialDirect'd a fresh client and
parked it on sshSessionState), so the pool's keepalive goroutines and
refcount logic were dead code. Get() also overwrote map entries on every
call, which would have leaked keepalive goroutines if the pool *had* been
used. Delete the entire SSHConnPool + sshConnKey + pooledConn + keepalive
plumbing. Export DialSSH so the SSH test endpoint can dial directly and
close the client when its single-shot probe is done.

While here:
  * pickRemoteAgentctlPort: switch from time.Now().UnixNano()%20000 to
    math/rand/v2.IntN. Two concurrent CreateInstance calls on a fast
    machine were getting the same port (UnixNano cycles in ~20µs through
    the 20k range); randomized picks avoid the collision.
  * createRemoteAgentInstance: POST to the remote control port over a
    direct-tcpip dial via net/http instead of shelling out to `curl`. The
    sshd container no longer needs curl, the kandev test endpoint no
    longer needs to probe for it, and the call inherits Go's standard
    timeout + error handling. Drop `curl` from the e2e Alpine image.
  * maybeUploadCredentials: was probing `printf %s "$HOME"` and
    discarding the output, then allocating an unused sshFileUploader.
    SSH v1 doesn't ship credential upload — just log a warning when a
    user sets remote_credentials so they know it won't take effect, and
    delete the dead uploader file.
2026-05-18 00:05:15 +01:00
José Almeida 09b8f8511f fix(ssh): quote every remote path + clean identity file path
CodeQL flagged two paths from HTTP-supplied SSH-config fields into
shell + filesystem operations:

  * runSSHCommand built "cat $remoteShaFile" / "test -x $remoteBin"
    / "mkdir -p $(dirname $remoteBin)" by direct concatenation; the
    arguments come from $HOME on the remote (not the kandev user)
    but the analyzer can't see that, and a remote $HOME containing
    shell metacharacters would still misparse. Route them through
    shellQuote for parity with every other call site in the file.

  * dialAuth opened target.IdentityFile straight from the configured
    string. Run it through filepath.Clean so any `..` segments slipped
    in by string substitution are normalised before os.ReadFile, which
    is the canonical mitigation CodeQL recognises here. Behaviour is
    unchanged for normal paths.
2026-05-18 00:05:15 +01:00
José Almeida 9fc84efadf feat(ssh): parse literal user@host:port ProxyJump bastion strings
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.
2026-05-18 00:05:15 +01:00
José Almeida e3845fe0f6 fix(ssh): re-read ~/.ssh/config on every resolve, no process-wide cache
kevinburke/ssh_config's package-level Get/GetStrict use a sync.Once
to load $HOME/.ssh/config once per process and cache the result. That
breaks two things:
  * users who edit their config mid-session don't see updates until
    kandev restarts;
  * e2e tests that write a fresh ~/.ssh/config per test all see the
    state captured by whichever test triggered the first lookup.

Open and Decode $HOME/.ssh/config on demand from a local
inheritFromSSHConfig helper. The library still parses the file the
same way — we're just dropping the global cache.

Unblocks the four ssh/config-inheritance.spec.ts cases that wrote a
per-test ~/.ssh/config.
2026-05-18 00:05:15 +01:00
José Almeida b5991a8d82 fix(ssh): half-close forwarded SSH channels to avoid premature WS abnormal-close
The SSH executor's port-forwarder previously called the full Close() as soon
as either direction of the bidirectional copy finished. Under HTTP-Upgrade
WebSocket traffic this meant: when the WS server flushed its final frame and
shut down the write side, our local->remote io.Copy returned EOF, we closed
the entire SSH channel, the remote->local copy aborted mid-frame, and the
backend's WS reader observed close 1006 (unexpected EOF) instead of a clean
close frame.

Switch to a half-close pattern: each direction CloseWrite()s its destination
when its source EOFs, letting the other half drain the remaining frames. The
final Close happens after both copies return.

The "ssh forwarder dial remote failed: EOF" warning in e2e logs was a
follow-on symptom — the SSH client was being torn down by the abnormal close,
so any later channel-open returned EOF immediately.
2026-05-17 23:45:07 +01:00
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
José Almeida 99404133d4 feat(backend): add SSH executor runtime
Run agents on any Linux box reachable over SSH: own VPS, homelab, EC2,
shared dev box. One SSH connection per (host, user, identity) shared
across all sessions on that host; each session gets its own remote
agentctl process, remote port, and SSH-tunneled local port. Mirrors the
local ~/.kandev/tasks/<task-dir>/<repo>/ layout on the remote so
multi-session tasks share a worktree and per-session runtime state lives
under the task dir.

Implements ExecutorBackend + RemoteSessionResumer + RemoteStatusProvider
+ Closeable. Host-key trust is explicit: the test endpoint observes a
fingerprint without pinning; the user trusts it in settings; subsequent
connects reject any mismatch. Auth via ssh-agent or IdentityFile only
(passphrase-protected keys must be loaded into ssh-agent). Parses
~/.ssh/config for HostName/Port/User/IdentityFile/ProxyJump inheritance.
agentctl is content-hash-cached on the remote (~/.kandev/bin/agentctl
plus a .sha256 sidecar) and uploaded only when local and remote differ.
Linux/amd64 only in v1, matching Docker's current state; arm64 is
called out as a cross-cutting follow-up.

See docs/specs/ssh-executor/spec.md.
2026-05-17 23:28:50 +01:00
Carlos Florêncio f6bd83da33 refactor: tighten type system across backend + frontend (#920) 2026-05-17 20:16:15 +01:00
Carlos Florêncio 0b2085688b feat(office): autonomous agent management layer (#914) 2026-05-16 20:07:57 +01:00
Carlos Florêncio a23ab5be3a feat: remote executors + secrets (#257) 2026-02-21 22:14:56 +00:00
Carlos Florêncio a70de7da11 feat: local executor and worktree + new task dialog (#211) 2026-02-08 11:56:23 +00:00
João Salavisa 187d002717 feat: per-task executor selection with multi-runtime support (#92) 2026-01-25 02:05:19 +00:00
Carlos Florêncio 93251b48ce feat: mcp servers + executors (#60)
* feat: mcp servers

* feat: mcp executors

* feat: mcp per agent profile

* feat: improve side bar

* feat: add reactive executors crud
2026-01-21 01:13:20 +00:00