9722a396390809fa67252bbf59c49d05ac2de224
88 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
9722a39639 |
fix(ssh-executor): forward agent creds, fix terminal host config, restore linux/arm64 (#1559)
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com> |
||
|
|
c9063e1b0a | feat: consolidate office system skills (#1604) | ||
|
|
46648b13df | ci: disable opencode review workflow (#1590) | ||
|
|
a3bbb999ba | ci: add release backfill and dmg diagnostics (#1570) | ||
|
|
bffbc8e96f |
ci: add harness file linter (#1549)
* ci: add harness file linter * ci: address harness linter review feedback * ci: cover codex harness config linting * ci: tighten harness lint opt-ins * ci: expand harness lint coverage * ci: address harness linter review feedback * ci: fix harness linter review followups * ci: lint claude settings harness config * ci: tighten harness linter coverage * ci: fail malformed harness toml --------- Co-authored-by: root <root@kandev.ts.net> |
||
|
|
ec942540bc |
ci: harden action pinning lint (#1545)
* ci: harden action pinning lint * refactor: extract dockview review dialog slot * test: cover action pinning edge cases * fix: reject dynamic workflow action sources * fix: normalize quoted workflow action refs * test: cover quoted unpinned action refs --------- Co-authored-by: root <root@kandev.ts.net> |
||
|
|
c53b8f4bea | ci: cargo audit + SHA-pin all GHA action refs + enforcement lint (#1539) | ||
|
|
f3b26dbc5a | feat: support remote macos ssh hosts (#1534) | ||
|
|
7a43a5bfbe | ci: build unsigned desktop artifacts without signing secrets (#1530) | ||
|
|
831a15a9b7 | fix: repair postgres schema migration coverage (#1529) | ||
|
|
0f8da4ff28 | fix: unblock release desktop and universal builds (#1522) | ||
|
|
14a5fb2c7f | fix: support unsigned desktop releases (#1520) | ||
|
|
7e92f8da7a | feat: add tauri desktop app (#1478) | ||
|
|
d3fe3d7228 | fix: avoid invalid opencode inline comments (#1464) | ||
|
|
da45983c4c | feat: add native kandev launcher (#1391) | ||
|
|
2b93545a4d | ci: fix opencode fork review merge base (#1451) | ||
|
|
773cd1a5a7 | ci: harden opencode same-repo review helper (#1439) | ||
|
|
2560f194ee | ci: harden opencode pr review workflow (#1434) | ||
|
|
aee1846cd5 | feat: remove nextjs production runtime (#1389) | ||
|
|
1f5a551579 | ci: clarify opencode review completion (#1420) | ||
|
|
9b2d47f713 | ci: avoid unconditional playwright apt install (#1419) | ||
|
|
67037fd936 | ci: add opencode PR review workflow (#1405) | ||
|
|
9702cffff5 | ci: add postgres backend boot smoke test (#1398) | ||
|
|
0988087b9f | ci: speed up e2e checks (#1361) | ||
|
|
f7eff241b9 | ci: faster e2e via image split, shard rebalance, and flake fixes (#1126) | ||
|
|
d97ec5b8fd | ci: skip Playwright CDN download for e2e-containers job (#1123) | ||
|
|
15721860e9 | ci: migrate backend/frontend/e2e workflows to kandev-ci base image (#1115) | ||
|
|
0c2df491cc | ci: add kandev-ci base Docker image and publish workflow (#1102) | ||
|
|
777dfdb15b |
feat(ssh): add SSH executor (#927)
* 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.
* feat(backend): add SSH test + sessions HTTP/WS endpoints
POST /api/v1/ssh/test runs the connection probe used by the settings UI:
dials the host with a permissive host-key callback that records (but
does not pin) the fingerprint, probes uname/arch/git, and reports
whether agentctl is already cached on the remote. The UI gates the
"Trust this host" checkbox on success + fingerprint, and Save persists
the trusted fingerprint into the executor config.
GET /api/v1/ssh/executors/:id/sessions returns the active SSH-runtime
sessions backing the "Active sessions" card on the executor settings
page, mirroring the SpritesInstancesCard / DockerContainersCard shape
(task, session, user@host, remote port, local forward port, uptime).
Both routes also exposed over WS via ssh.test / ssh.sessions.list.
* feat(web): add SSH executor settings UI
SSHConnectionCard: connection form (name, host_alias / host / port /
user, identity source = ssh-agent | file with optional file path,
optional ProxyJump) with a Test Connection button that runs the backend
probe, surfaces the observed host fingerprint, and only enables Save
when the user explicitly ticks "Trust this host". Editing the host /
port / user / identity fields resets both the test result and the trust
checkbox. On save, if the executor already has running sessions, a
confirm modal warns that those keep going on the old host and only new
sessions will use the new config.
SSHSessionsCard: 90s-polled table of active sessions for the executor,
mirroring SpritesInstancesCard / DockerContainersCard. Columns: task,
session, user@host, remote agentctl port, local forward port, uptime,
status badge. Backed by GET /api/v1/ssh/executors/:id/sessions.
Also registers the ssh entry in executor-icons.ts (label "SSH",
IconTerminal2) so it surfaces consistently in the rest of the settings
chrome.
* docs: add SSH executor v1 spec
Captures the design behind the new SSH executor: why ("run on a Linux
box I already own" between local and Sprites), the remote layout that
mirrors local ~/.kandev/tasks/, per-session agentctl + forward over a
shared per-host SSH connection, explicit-trust host-key flow, ~/.ssh/config
inheritance, amd64 + agentctl content-hash cache, recovery via persisted
ExecutorRunning metadata, settings UI (Connection + Sessions cards), and
the edit-with-live-sessions warning UX. Out-of-scope items (arm64,
macOS, password auth, chained ProxyJump, working-tree push, etc.) are
called out explicitly with the follow-ups they unblock.
* chore(e2e): rename docker project to containers + add e2e README
The Docker e2e Playwright project was the natural home for any test
that needs a real Docker daemon on the host — but its name implied it
was only about the Docker executor. SSH e2e tests (incoming in
follow-up commits) belong here too, since they use a Dockerised sshd
container as the SSH executor's remote target.
- Rename project: docker -> containers, widen testMatch to also include
tests/ssh/*.spec.ts.
- Rename env flag: KANDEV_E2E_DOCKER -> KANDEV_E2E_CONTAINERS, with the
old name kept as a deprecated alias for one release so local scripts
and CI configs don't break overnight.
- Rename CI job: e2e-docker -> e2e-containers, switch its blob report
name and the merge-job dependency.
- Add apps/web/e2e/README.md explaining the project layout, what the
containers project covers (Docker executor + SSH executor), why it
was renamed, and how to run it locally.
- Add docs/specs/ssh-executor/e2e-plan.md tracking the SSH e2e
coverage we'll implement on top of this rename (real sshd container,
no mocks, 17 spec files covering ~75 cases). Includes a "Deferred"
section that flags arm64 as something to revisit when
agentctl-linux-arm64 lands in the build pipeline.
* feat(web): add data-testid attributes to SSH settings UI
Sweep stable test ids across the SSH connection and sessions cards so
the incoming Playwright suite can hold steady against refactors. Splits
the formerly monolithic ssh-settings.tsx into three files to stay
within the per-file/per-function size limits:
- ssh-connection-card.tsx (form + test-then-trust flow)
- ssh-sessions-card.tsx (active-sessions table)
- ssh-settings.tsx (barrel re-export so existing imports keep working)
Test ids cover: card roots, every input, identity-source select +
options, test/save buttons, connection badge (with data-status), error
text, test-result panel (with data-success), per-step rows (with
data-success), observed/pinned fingerprint, fingerprint-change
warning, trust checkbox, sessions table + per-row cells, empty state,
refresh button, session status badge.
* feat(e2e): scaffold the SSH executor test harness
Lands the building blocks the SSH specs will compose on top of. Every
piece talks to a real sshd container — no mocks at any layer.
- fixtures/ssh-image.ts: builds kandev-sshd:e2e once per machine (Alpine
+ openssh-server + openssh-sftp-server + git + bash + sudo +
iproute2 + iptables, pre-bakes mock-agent at /usr/local/bin/mock-agent,
generates a fresh host key on entrypoint, reads the worker public key
out of a bind-mounted /authorized_keys). The agentctl binary is
deliberately NOT pre-baked so tests exercise the SFTP upload + sha256
cache path the production code uses.
- helpers/ssh.ts: per-worker sshd lifecycle (startSSHServer /
stopSSHServer), keypair generation, host-key fingerprint scanning,
fault injection (regenerateHostKey for host-key-rotation specs,
dropTrafficToPort22 / restoreTraffic for connection-drop tests,
killRemotePid for "agentctl died" recovery), and remote inspection
helpers (readRemoteFile / remotePathExists / listRemoteDir /
countSshdConnections).
- helpers/ssh-bastion.ts: two-container ProxyJump setup. Bastion is on
a private Docker network and has a published port; target is on the
same network with NO host port — any successful connection MUST have
traversed the bastion, which makes the proxy-jump assertion honest.
- fixtures/ssh-test-base.ts: worker-scoped fixture mirroring
docker-test-base. Skips when Docker isn't reachable. Spins up the
sshd container, generates keys, scans the fingerprint, then seeds a
workspace + workflow + bare repo + mock-agent profile + an SSH
executor with the fingerprint already trusted. Specs that need to
exercise the test-then-trust UI gate create their own executor on
top of the same target.
- pages/SSHSettingsPage.ts: Page Object covering the connection form,
test/save buttons, trust checkbox, observed/pinned fingerprint, per
step badges, and the sessions table.
- helpers/api-client.ts: new createSSHExecutor / updateExecutor /
getExecutor / testSSHConnection / listSSHSessions methods plus
matching DTO types.
* test(e2e): add SSH executor Playwright suite (17 specs, ~75 cases)
Every spec drives the real sshd container set up by the ssh-test-base
fixture; no mocks. Specs are grouped by concern (file per group),
mirroring the docker/ directory style. Each one maps 1:1 to a row in
docs/specs/ssh-executor/e2e-plan.md.
UI surface:
- connection-form.spec.ts — field rendering, save/test gating,
identity-source toggle, default port.
- test-result.spec.ts — successful + failed Test Connection paths,
per-step badges, fingerprint surfacing, cached/will-upload copy.
- trust-gate.spec.ts — save disabled until trust ticked, edits reset
result + trust, fingerprint-change warning when pinned != observed.
- sessions-card.spec.ts — empty state, row rendering, manual refresh,
status badges, truncation, polling pickup.
- persistence.spec.ts — fingerprint persists across reload, new form
starts blank, executor list label, partial state doesn't leak.
API contract:
- test-endpoint.spec.ts — POST /api/v1/ssh/test step ordering, invalid
body, host-required, handshake short-circuit, cached/uploaded states.
- sessions-endpoint.spec.ts — GET /api/v1/ssh/executors/:id/sessions
filters to ssh-runtime rows, populates metadata, monotonic uptime.
- executor-crud.spec.ts — type=ssh persists fingerprint, profile
workdir round-trip, edit-with-running-sessions keeps snapshots.
Real-SSH integration:
- launch-task.spec.ts — full smoke: agentctl upload + sha256 cache hit
on second launch, task/session dir layout, stop cleans session dir.
- recovery.spec.ts — backend restart reconnects, dead pid surfaces
failure, persisted metadata keys present.
- auth-methods.spec.ts — key + valid, missing key, passphrase-protected
rejection, agent without SSH_AUTH_SOCK, wrong-key auth failure.
- concurrency.spec.ts — two sessions share task dir distinct ports, two
tasks distinct dirs, traffic drop + restore exercises eviction +
reconnect.
- hostkey-rotation.spec.ts — regenerate host key surfaces new
fingerprint, stale pinned fingerprint blocks launch verbatim, re-trust
restores function.
- config-inheritance.spec.ts — ~/.ssh/config HostName / Port /
IdentityFile / ProxyJump inheritance with form-overrides-config rules.
- workdir-per-profile.spec.ts — per-profile ssh_workdir_root routes to
separate remote workdir trees; default falls back to ~/.kandev.
- proxy-jump.spec.ts — 2-container bastion topology asserts target is
unreachable directly, reachable via ProxyJump, chained ProxyJump fails.
- error-surfacing.spec.ts — TCP refused, auth failed, bastion unreachable
surface verbatim; backend 400 stays a 400 not a synthesised failure.
Also adds an AGENTS.md (CLAUDE.md symlink) line pointing at the
e2e/README.md so future agents discover the containers project +
KANDEV_E2E_CONTAINERS env var without grepping playwright.config.ts.
* fix(e2e): unlock sshd account, derive fingerprint from kandev probe
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.
* 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.
* 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.
* fix(ssh): resolve session executor_id via TaskSession join
GET /api/v1/ssh/executors/:id/sessions was filtering ExecutorRunning rows by
their ExecutorID column, but the lifecycle manager's buildRunningFromExecution
only sets that column from a prior row — for fresh sessions it's left empty.
The endpoint therefore returned [] for every just-launched SSH session.
Look up the TaskSession by session_id and compare its ExecutorID instead.
Keep the row's own ExecutorID as a fast-path for resume/recovery flows
where it's already populated.
* feat(web): register ssh entry in the new-executor type map
Without this entry, /settings/executors/new/ssh fell back to the
"unknown executor type" placeholder. Extracts the type map into its
own module so the page file stays under the 600-line lint cap.
The SSH-specific form is still TODO and will replace the standard
profile-create form for this type in a follow-up.
* test(e2e): poll listSSHSessions for SSH session row in group G
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.
* test(e2e): align H5 assertion with actual on-disk session-dir layout
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.
* feat(web): mount SSH connection + sessions cards on dedicated routes
Wires the existing SSHConnectionCard and SSHSessionsCard into the
settings UI so the SSH executor flow has a home:
- /settings/executors/new/ssh renders SSHCreatePage (just the connection
card; Save POSTs a new executor and routes to the edit page).
- /settings/executors/ssh/[executorId] loads the executor and renders
the connection card pre-filled + the sessions card.
- Adds the SSH entry to the executors landing-page tile grid.
- Adds createExecutor / fetchExecutor / updateExecutor helpers to
settings-api so the new pages can talk to /api/v1/executors directly.
Profile management for SSH executors still flows through the existing
profile pages; this commit only adds the executor-level chrome the
SSHSettingsPage e2e tests expect.
* fix(web): keep SSH trust checkbox visible after editing a connection field
Previously, editing any form field nulled out `result` so the entire
TestResultDisplay (including the trust checkbox) unmounted. The trust-gate
e2e specs assert that after editing host/port/user/identity_source, the
trust checkbox is still present but unchecked — which matches the
intended UX: the user can re-test against the new target and re-tick
trust without losing the prior result for reference.
Now an edit only clears `trust` when the field could route to a
different machine (host_alias, host, port, user, identity_source,
identity_file, proxy_jump); the result panel stays mounted. Editing the
display-only `name` leaves trust alone.
* test(e2e): point SSHSettingsPage at the new SSH executor routes
Updates the page object to navigate to the SSH-specific routes that now
host the connection + sessions cards:
- gotoNew(): /settings/executors/new?type=ssh → /settings/executors/new/ssh
- gotoExisting(): /settings/executors/[executorId] → /settings/executors/ssh/[executorId]
The old URLs collided with the standard profile-create / profile-edit
flow and were never going to render the SSH cards. The new paths give
the SSH UI its own surface, leaving the generic profile pages untouched.
* feat(api): add top-level DELETE /api/v1/executor-profiles/:profileId
Profile IDs are globally unique, so callers that only have the
profile id (e2e api client, CLI tooling) shouldn't have to plumb
the executor id through. Aliases the existing nested handler.
* test(e2e): probe SSH fingerprint via kandev, not ssh-keyscan, in K specs
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.
* test(e2e): wipe agentctl before F5 to assert from a fresh-install state
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).
* 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.
* 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.
* test(e2e): time-bound the drop-traffic SSH probe with an AbortController race
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.
* test(e2e): rerun ssh.test in hostkey-rotation afterEach to repin
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).
* test(e2e): restore the seeded executor's pinned fingerprint after R5
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.
* 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.
* 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.
* 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.
* 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.
* fix(ssh): drop stale polls + preserve port inheritance + sshd extra-keys
ssh-sessions-card: race-protect polls.
Polling setInterval + manual refresh shared one state setter, so a
slow request that resolved after a newer call (or after a parent
switched executorId) overwrote fresh data with stale rows. Bump a
monotonic sequence on each refresh and drop the response when the
sequence has moved on, plus reset on mount and invalidate on
unmount so an in-flight reply from a teardown can't land. Cubic P2.
ssh-config parseSSHExecutorConfig: don't default ssh_port to 22.
Reading an executor row that had no ssh_port set rewrote port=22
on the form, which then round-tripped back to the backend. That
silently broke ~/.ssh/config Port inheritance for aliases — a save
could redirect connections to 22 even though the underlying alias
was supposed to resolve to another port. Leave the field undefined
so the backend resolver can apply the alias' value. Cubic P2.
ssh-image entrypoint: guard authorized_keys.extra mount.
When only /authorized_keys.extra (and not /authorized_keys) was
bind-mounted, the >> redirect ran under set -e against a missing
destination and aborted container startup. mkdir + touch the
destination before the append and reapply the ownership/perms.
CodeRabbit P2.
* test(e2e): make G2 + F6 actually exercise the contract
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.
* refactor(e2e): extract SSH HTTP shapes into api-client-ssh.ts
api-client.ts was already over the 600-line lint cap on main (1528
lines); the SSH additions made it worse. Split the SSH HTTP shape
types into a dedicated module and re-export from api-client.ts so
existing callers keep working. The methods themselves stay on
ApiClient as thin pass-throughs since splitting them out across the
class boundary would change every test call site.
Also document the per-executor bound on the SSH listSessions per-row
GetTaskSession lookup (typical N < 10), and what a batch path would
need (a repo method to fetch task_sessions by id list). Cubic /
Greptile P2.
* docs(spec): correct SSH recovery keying after pool removal
The spec said ResumeRemoteInstance re-opens 'per (host, user) — one
connection serves all surviving sessions on that host', which was
the pool design. The current executor gives each session its own
SSH client, so the resume keys on the full (host, port, user,
identity_*, proxy_jump, host_fingerprint) tuple. That way two
profiles on the same host with different keys can resume side by
side without merging. Cubic P2.
* 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.
* 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.
* fix(ssh): use bare mock-agent name so the remote PATH resolves it
For SSH the agent runs on a remote host whose filesystem doesn't share
paths with the kandev backend's, so the host-absolute mock-agent path
configureMockAgent computes can't apply. Extend the bare-name branch in
MockAgent.BuildCommand to include RuntimeSSH (the SSH e2e image already
bakes mock-agent into /usr/local/bin) and drop the now-redundant SSH e2e
bind-mount that worked around this by mirroring the host path into the
container.
* 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.
* 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.
* test(ssh): cover ProxyJump parsers + sibling-session metadata filter
Addresses the two coverage gaps Claude flagged on PR review round 3:
- parseLiteralProxyJump / parseProxyJumpHostPort / parseBracketedHostPort
/ parsePortString are pure parsers that gate every ProxyJump dial,
including IPv6 (`[2001:db8::1]:22`). Table-driven tests pin the happy
paths and the bracket / port / range edge cases — silent regressions
in bracket stripping would otherwise break every IPv6 ProxyJump
session without a single test failing.
- applyExecutorRunningMetadata is the function commit
|
||
|
|
68aefc0e08 | feat(web): system settings pages (status, database, backups, logs, updates, licenses, about) (#942) | ||
|
|
5efe9f1df1 | fix(ci): disable track_progress for labeled event in claude-review-fork (#1067) | ||
|
|
372779ad32 | feat(preview): inspect mode with pin and area annotations (#917) | ||
|
|
122ea73f55 | feat(workflow): queue workflow messages during active moves (#1036) | ||
|
|
2430d351e6 | feat(release): credit external contributors in release notes (#975) | ||
|
|
35b18aa853 |
fix(release): build kandev binary with -tags fts5 (#1007)
* fix(release): build kandev binary with -tags fts5 The tasks_fts virtual table + sync triggers shipped in v0.50.0 (#914) require the fts5 SQLite build tag, which apps/backend/Makefile already sets. The release workflow did not, so published binaries lack the module and fail to open any DB that an FTS5-enabled build previously touched ("no such module: fts5" during schema init). Add -tags fts5 to the kandev build and propagate the standard version ldflags from the Makefile so kandev --version reports the released tag. Fixes #1006. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(release): propagate version ldflags to cross-compiled agentctl The linux/amd64 agentctl helper is built in a separate workflow step whose shell does not inherit LDFLAGS from the previous build step. Export LDFLAGS via \$GITHUB_ENV so the cross-compiled binary embeds the same Version/Commit/BuildTime metadata as the matrix-built agentctl, matching apps/backend/Makefile's build-agentctl-linux target. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Kandev Agent <agent@kandev.dev> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
9d2e572ffd |
ci: strip safe-to-review label on new fork pushes (#992)
* ci: strip safe-to-review label on new fork pushes Mirrors the strip-safe-to-test job in preview-env.yml so a once-approved fork PR cannot run the Claude review against new (potentially malicious) code on subsequent pushes without fresh maintainer re-approval. Closes a gap between the two pull_request_target workflows flagged by an external scanner. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * ci: close strip-label race on fork PRs Three reviewers (CodeRabbit, cubic, Greptile) flagged that strip-safe-to-review ran in parallel with claude-review-fork on the same synchronize event, so the label was still present in the webhook snapshot when the review job's if was evaluated — the strip only blocked the next push, not the current one. Same flaw existed for preview-env.yml's deploy-fork (worse there, since it runs the fork's cmd/preview code with SPRITES_API_TOKEN). Fix: - Add `labeled` to pull_request_target types so a maintainer applying the opt-in label triggers a review/deploy against the current head SHA. - Guard the label clause in claude-review-fork / deploy-fork with `github.event.action != 'synchronize'`. Trusted-author paths (OWNER / MEMBER / COLLABORATOR and the *_ALLOWLIST repo variable) still authorize on every event; only the label path is per-commit. - Wrap removeLabel calls in try/catch that swallows 404 on races. - Add workflow-level concurrency to dedupe rapid pushes (cancel-in-progress is false on `closed` for preview-env so cleanup can finish). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * ci: reorder fork PR if-gate branches to be fromJSON-safe cubic flagged that the previous reorder put the `fromJSON(vars.*_ALLOWLIST)` branch before the label clause. A non-empty but malformed allowlist value would throw and skip the whole job, masking a legitimate maintainer label approval. Reorder to: author_association -> label (with synchronize guard) -> allowlist, so the cheap/safe branches short-circuit before any potentially throwing fromJSON call. Applied to both fork workflows. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Kandev Agent <agent@kandev.dev> Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> |
||
|
|
ca1e72374b | ci(release): split docker onto native arm runners + reuse prebuilt bundles (#922) | ||
|
|
0b2085688b | feat(office): autonomous agent management layer (#914) | ||
|
|
a5108de926 | fix(windows): make conpty Close idempotent and kill orphan agentctl (#900) | ||
|
|
e144e8c530 |
fix: improve custom script menu readability (#905)
* fix: improve custom script menu readability * chore: address verification drift * chore: address custom script menu review |
||
|
|
f3397b4ad7 |
feat: docker and sprites executor improvements (#738)
* feat(orchestrator): generalize task environment reuse across executor types
Worktree, container, and sandbox IDs now flow uniformly from a persisted
TaskEnvironment back into LaunchAgentRequest through a single helper. This
gives Sprites session-to-session reuse the same first-class treatment that
Docker already gained from the GitHub-token-injection PR, and lets a fallback
launch refresh the environment row instead of leaving stale IDs in the DB.
- applyExistingEnvironment propagates WorktreeID, ContainerID (via metadata),
and SandboxID (as sprite_name metadata) onto the launch request
- persistTaskEnvironment refreshes those IDs when the executor ends up
creating new resources (e.g. the previous container was pruned)
- extractSandboxID lifts the sprite identifier out of executor response
metadata so it can be persisted on the TaskEnvironment row
* feat(backend): add task-level reset environment action
Users sometimes need a clean slate — broken packages, stale caches, a
container that drifted into a bad state. Reset Environment is the explicit
escape hatch: tear down the recorded container, sandbox, and/or worktree
for a task and delete the TaskEnvironment row so the next session starts
fresh.
- service.ResetTaskEnvironment(taskID, ResetOptions) is the entry point.
ResetOptions.PushBranch optionally git-pushes the workspace branch
before teardown so committed work isn't dropped on the floor; a failed
push aborts the reset and leaves the environment intact.
- A new EnvironmentDestroyer interface in the task service hides the
per-resource teardown behind a single seam, implemented in cmd/kandev
by delegating to lifecycle.Manager (containers, sprites) and
worktree.Manager (worktrees, branch preserved).
- lifecycle.Manager.DestroyContainer / DestroySandbox dispatch to the
Docker and Sprites executor backends without leaking model types into
the lifecycle package.
- Reset is blocked while any session on the task still has an
ExecutorRunning row (returns ErrSessionRunning → HTTP 409). Per-resource
teardown is best-effort but the DB row is only removed once everything
is confirmed gone or already missing, so a partial failure is retryable
by clicking Reset again.
- Endpoint: POST /api/v1/tasks/:id/environment/reset.
* feat(web): add executor settings popover with reset environment action
A new button on the task top bar opens a popover showing the active
TaskEnvironment — executor type, status, container/sprite/worktree
identifiers, and a copy-ready 'docker exec' command for local Docker.
The popover hosts the destructive Reset Environment action so users have
one obvious place to inspect and manage the environment without
cluttering the New Session dialog.
The reset confirmation dialog requires explicit acknowledgement that
uncommitted changes will be lost. When the environment has a host-side
worktree it offers an opt-in 'push branch before resetting' checkbox so
committed work can be pushed first; the backend aborts the reset on
push failure.
- task-environment-api.ts: fetchTaskEnvironment + resetTaskEnvironment
- executor-settings-button.tsx: popover trigger + identity panel
- task-reset-env-confirm-dialog.tsx: destructive confirm with push opt-in
- task-top-bar.tsx: wire the new button into the right-side controls
- e2e: replace fresh-env-toggle test with reset-from-popover flow
* fix(docker): expand {workspace} placeholder in container WorkingDir
The agent runtime config carries `WorkingDir: "{workspace}"` for most ACP
agents. Mount sources were already expanded via expandMountSource, but
WorkingDir was passed through verbatim, so Docker rejected new containers
with:
Error response from daemon: the working directory '{workspace}' is
invalid, it needs to be an absolute path
Run the same expansion on WorkingDir, defaulting to /workspace when no
host WorkspacePath is set (the clone-inside-container case introduced by
PR #654).
* fix(docker-profile): persist Dockerfile/image_tag and apply tag override at runtime
Two bugs:
1. The Dockerfile editor and Image Tag input on the executor profile page
stored their values in component-local useState inside DockerSections,
not in the form state. Saving the profile never wrote them back, so
"Use defaults" / typing in the editor / changing the tag never
persisted — and the Build button was the only thing that consumed
them. The same gap existed in the new-executor flow's
buildProfileConfig.
Lift `dockerfile` and `image_tag` into useProfileFormState (edit) and
useCreateProfileFormState (new), and emit them in buildSaveConfig /
buildProfileConfig so they round-trip through profile.config.
2. Even when an image_tag is set on the profile, the container builder
ignored it. imageName was always derived from the agent runtime's
hardcoded `kandev/multi-agent`, so a user who built a custom image
like `kandev/agent:latest` would still see Docker fail with
"No such image: kandev/multi-agent:latest".
Propagate `profile.config.image_tag` → metadata
(`image_tag_override`) → ExecutorCreateRequest → ContainerConfig,
and let it override `rt.Image` in buildContainerConfig when set.
Also align the frontend default image tag (kandev/multi-agent:latest)
with the backend's hardcoded runtime default so users who don't override
end up with a working build.
* test(docker): cover container config build + Docker profile persistence
Adds two regression tests for the recent Docker fixes:
- container_config_test.go (Go unit): asserts buildContainerConfig
expands `{workspace}` in WorkingDir (defaulting to /workspace when no
host WorkspacePath is set) and that ContainerConfig.ImageTagOverride
takes precedence over the agent runtime's hardcoded `rt.Image`. No
Docker daemon required.
- docker-profile-persistence.spec.ts (Playwright E2E): creates a
local_docker executor + profile, opens the profile edit page, clicks
"Use defaults" to populate the Image Tag input + Dockerfile editor,
hits Save, then refetches the profile via API and reloads the page to
verify both fields round-trip through `profile.config`. Catches the
prior regression where DockerSections owned the form state locally
and never wrote it back. Runs against the existing E2E backend that
has KANDEV_DOCKER_ENABLED=false — we only test the persistence path,
not container creation.
Adds an api-client helper (`getExecutorProfile`) for the round-trip
assertion.
* feat(docker): self-contained Dockerfile + mount agentctl + capabilities adapter
Make the Docker executor work with a Dockerfile users can actually build,
without depending on a maintainer-shipped image:
- The container now launches a small bootstrap (`sh -c "$KANDEV_PREPARE_SCRIPT
; exec /usr/local/bin/agentctl"`) regardless of the image's ENTRYPOINT.
The agent CLI is no longer wired through container Cmd; agentctl receives
it later via its CreateInstance HTTP API.
- Host agentctl-linux binary is mounted into the container at
/usr/local/bin/agentctl via the existing AgentctlResolver. User-built
images don't have to bake it in.
- Default Dockerfile in the executor profile UI replaced with a working
node:22-slim + git + curl image — runs npx-based ACP agents (Claude,
Codex, OpenCode, Auggie) out of the box.
- lifecycleAdapter now exposes RequiresCloneURL / ShouldApplyPreferredShell
so the orchestrator can correctly identify containerized executors and
resolve the clone URL. Without this, applyRepositoryConfig short-circuits
on `e.capabilities == nil`, the cloneURL is never resolved, and the
prepare script fails with `repository '{{repository.clone_url}}' does
not exist`.
- applyRepositoryConfig also surfaces the resolved clone URL to script
metadata so the prepare script can interpolate {{repository.clone_url}}.
- docker.ContainerConfig gains an Entrypoint field, plumbed to the docker
client.
Verified end-to-end on a local stack with the new minimal image: the
container starts, bootstrap clones the GitHub repo, agentctl launches and
hands off to claude-acp inside the container.
* fix(docker): patient health check + resilient bootstrap
Two follow-ups to the Docker bootstrap work:
- The 30 retries × 500ms = 15s budget for agentctl health was too tight.
In the previous flow agentctl was a separate exec from the prepare
script, so it came up immediately. Now it runs after the prepare
script (which does git clone, optional installs), so first launch can
easily exceed 15s and produce a misleading "agentctl not healthy after
30 retries" error. Bumped to 240 retries (120s) and surfaced the last
underlying error in the message so failures point to the real cause.
- Prepare scripts commonly start with `set -e`/`set -eu`; running them
via `eval` propagated the option to the outer bootstrap shell, so any
failure killed the bootstrap before `exec /usr/local/bin/agentctl`.
Run prepare in a subshell so its `set -e` is contained, and start
agentctl regardless. If prepare fails, agentctl still comes up — the
host can connect, surface the failure, and let the user debug from
the Executor Settings popover.
* fix(executor-profile): persist remote auth secrets/credentials for Docker profiles
The Remote Credentials card (per-agent secret picker, plus the GitHub
credential checkbox) is rendered for any remote executor — Sprites,
local_docker, or remote_docker — but the save path in both the create
and edit pages only emitted `remote_auth_secrets` and `remote_credentials`
when `isSprites` was true. Selecting a secret for the Claude / Codex /
Auggie ANTHROPIC_API_KEY-style entry on a Docker profile updated form
state, the dropdown showed it as picked, but Save Changes silently
dropped the value before posting to the API. On reload the agent showed
as "Not configured", and any session start failed with
`{"code":-32000,"message":"Authentication required"}`.
Switch the guards to `isRemote` so the same data persists for all remote
profile types. The backend orchestrator already propagates
`profile.config.remote_auth_secrets` and `remote_credentials` through to
metadata for any containerized executor and resolves them via
`applyContainerCredentials` → `resolveAuthSecrets`, so no backend change
is needed once the values actually land in the saved profile.
* fix(executor-profile): live Configured badge + session/load resilience + live container status
Three independent fixes that all surface together while iterating on the
Docker executor flow:
- Remote Credentials card: the per-agent "Configured" / "Not Configured"
badge was driven by useState that only seeded once on mount. Picking a
secret in the dropdown updated the parent's `agentEnvVars` map but the
local `choice` never moved to `"env"`, so the badge stayed grey until
the page was reloaded. Derive `choice` straight from props every render
so the badge tracks live state.
- ACP session/load: when the agent process restarted between turns
(common with claude-agent-acp because its session map is in-memory),
the host's session/load call failed with JSON-RPC -32002 "Resource not
found" and the entire launch errored out. Treat that as the same kind
of "session is gone" signal as -32601 / capability=false and fall back
to session/new. Worst case the agent loses prior context; best case
the user just keeps typing.
- Executor Settings popover: dropped Execution ID and Environment ID
rows (debug-only noise users don't need), kept Container ID + the
copyable `docker exec -it … bash` line, added a live status badge
with tones for running / exited / paused / dead / error / missing,
and a 3-second polling interval so the badge follows the real
container state. New `GET /api/v1/tasks/:id/environment/live`
endpoint plumbs Docker `inspect` data through the lifecycle manager
→ task service → handler. ContainerLiveStatus mirror type at the
service layer keeps lifecycle types out of the public surface.
* feat(web): gate agent profiles by executor profile credentials
Filter the agent profile dropdown in both the Create Task dialog and the
New Session dialog to only show agents that are actually configured for
the selected executor profile.
For local/worktree executors, no gating (agents need no extra creds).
For Docker/Sprites profiles, an agent is "configured" iff one of its
remote-auth methods has a value in `remote_credentials` or a non-null
secret in `remote_auth_secrets`. Agents without an entry in the remote
auth catalog (Copilot, Amp, OpenCode, TUI, mock) cannot carry creds on
remote executors and are blocked.
Spec IDs match `AgentProfileOption.agent_name` (the registry-type string
like "claude-acp"); `agent_id` is a DB UUID and unrelated to the catalog.
The Create Task footer surfaces a "No agent profile is configured for X"
tooltip and disables submit when the selected executor profile has no
compatible agents. The New Session dialog auto-swaps to the first
compatible agent and shows the same banner inline.
`useRemoteAuthSpecs` returns a `loaded` flag so callers don't filter
during the initial fetch (would otherwise hide everything).
* feat(docker): make mock-agent runnable inside Docker for E2E
Lay the backend groundwork so the existing mock-agent can run inside
local_docker containers — needed for real-Docker E2E tests that don't
require Anthropic/OpenAI API keys.
Container builder bind-mounts a host mock-agent binary at
/usr/local/bin/mock-agent when KANDEV_MOCK_AGENT_LINUX_BINARY is set
(or build/mock-agent-linux-amd64 sits next to the kandev binary), the
same pattern already used for agentctl. MockAgent.BuildCommand always
resolves via PATH so the same command works on host (E2E PATH includes
bin/) and in containers (mounted at /usr/local/bin/mock-agent).
Mock now declares non-nil empty RemoteAuth so it counts as a
remote-capable agent. The remoteauth catalog includes specs even with
zero methods, and the frontend gating helper treats an empty-method
spec as "no credentials needed" — letting mock-agent pass the
configure-on-executor check that hides uncatalogued agents on remote
executors.
repositoryCloneURL falls back to the local origin remote URL when the
repo has no provider info, so local-only repos with a real remote
(including file:// remotes used in E2E) work on remote executors that
clone inside the container.
Makefile: build-mock-agent-linux target mirrors build-agentctl-linux.
* test(docker-e2e): add real-Docker Playwright project + first spec
A new Playwright project — \`docker\` — runs container-bound E2E specs
against a backend with KANDEV_E2E_DOCKER=1. The fixture probes for a
Docker daemon and skips the worker entirely when none is reachable, so
contributors without Docker can still run the rest of the suite.
The fixture builds a tiny kandev-agent:e2e image once per machine
(node:22-slim + git/curl), pre-seeds a local_docker executor profile,
and seeds a local repo with a file:// origin so the executor's
clone-inside-container path resolves without network access. A small
WS-based session.launch helper is added to the api-client for tests
that need to launch additional sessions on an existing task.
First spec — docker-launch.spec.ts — covers the smoke path: a session
launches in a real container, the task environment records its
container_id, and \`docker inspect\` confirms the container exists.
Two follow-up specs (multi-session env reuse + recovery from external
container removal) are wired up but marked test.fixme with comments
pinpointing the backend gaps that block them: persistTaskEnvironment
isn't called for workflow-prepared sessions (so task_environment_id
stays empty on those rows), and DockerExecutor.reconnectToContainer
loses the agentctl handshake auth token across launches (so reconnect
401s and falls back to a fresh container every time).
Run locally with: \`pnpm --filter @kandev/web exec playwright test
--config e2e/playwright.config.ts --project=docker\`.
* ci(e2e): add dedicated Docker E2E job
The new \`docker\` Playwright project needs a Docker daemon and the
linux/amd64 agentctl + mock-agent binaries bind-mounted into containers.
Pin the existing sharded job to chromium + mobile-chrome so the docker
project no longer leaks into every shard (where it would fail seeding
without KANDEV_DOCKER_ENABLED), and add an \`e2e-docker\` job that:
- Verifies the Docker daemon is reachable on the runner.
- Downloads the same backend artifact (now including the linux helper
binaries, built via \`make build-backend-linux-helpers\`).
- Runs \`playwright test --project=docker\` with KANDEV_E2E_DOCKER=1.
- Cleans up any kandev-managed containers at teardown so leaks don't
poison the next run.
* ci(e2e): include docker job in merged HTML report
Upload the docker job's blob report under the \`blob-report-*\` glob the
merge job already consumes, and add e2e-docker to e2e-report's needs so
the final HTML report covers both the sharded chromium runs and the
docker run.
* ci(e2e): add workflow_dispatch for ad-hoc runs and draft-PR verification
* fix(build): always produce linux/amd64 agentctl as part of \`make build\`
Remote executors (local Docker, remote Docker, Sprites) bind-mount the
linux/amd64 agentctl binary into the container. On non-Linux hosts (e.g.
macOS dev installs) running plain \`make build\` produced only the host
binary, so the first Docker session would fail with:
failed to build container config: agentctl linux binary not found
The top-level \`make dev\` already special-cased non-Linux hosts to chain
\`build-agentctl-linux\`, but \`make build\` and \`make start\` skipped it.
Add it as a dependency of the standard backend build so every install
ships a usable linux variant. Cross-compiling agentctl is fast (~1-2s)
and the binary is ~20 MB, so the cost is negligible.
Users who hit the error can run \`make build-backend\` (or \`make build\`
in apps/backend) to regenerate; setting KANDEV_AGENTCTL_LINUX_BINARY
remains a manual override.
* fix(release): ship linux/amd64 agentctl in every release artifact
Remote executors (local Docker, remote Docker, Sprites) bind-mount the
linux/amd64 agentctl into the container. The release pipeline only
shipped a host-arch agentctl, so users who installed via the bundled
binaries (npm/Homebrew/etc.) hit the same "agentctl linux binary not
found" error the first time they tried a Docker executor — even on
Linux, since the resolver expects the -linux-amd64 suffix.
Changes:
- .github/workflows/release.yml: cross-compile agentctl-linux-amd64 in
the build-bundles matrix step (CGO off so it works from every host).
Bundle ships kandev + host agentctl + agentctl-linux-amd64 in
dist/kandev/bin/. The AgentctlResolver finds it next to the kandev
binary automatically.
- Dockerfile: same idea for the container image — produce both variants
in the Go builder stage and copy the linux-amd64 helper into
/app/apps/backend/bin/ so the resolver picks it up inside the image.
A previous commit added the same dependency to \`make build\` for local
dev installs; this commit closes the gap for end users.
* fix: address PR review feedback (CI, CodeRabbit, Greptile, Claude)
Backend:
- service_task_environments: fail closed when checkAnySessionRunning
errors so a transient DB hiccup can't bypass the running-session
guard and tear down a live container/sandbox; teardown is now truly
best-effort with errors.Join across container/sandbox/worktree, so a
failed Docker call doesn't permanently orphan the worktree on retry.
Adds tests for the running-check error path and the multi-resource
best-effort path.
- executor_execute: propagate GetTaskEnvironmentByTaskID errors instead
of silently launching a fresh environment that orphans existing
containers/worktrees; gate environment reuse on matching ExecutorType
so a profile switch doesn't pass stale PreviousExecutionID/container
IDs to the wrong backend.
- task_environment_handlers: reject malformed JSON on POST
/tasks/:id/environment/reset (still allow empty body) so a destructive
endpoint doesn't silently proceed with a bad payload.
- worktree (PushEnvironmentBranch): bound git push with a 30s context
timeout and disable interactive prompts via GIT_TERMINAL_PROMPT=0
so a stalled remote can't hang the request thread.
- manager_environment: explicitly call ensureClient() before
ContainerMgr() in DestroyContainer/GetContainerLiveStatus so a missing
Docker daemon surfaces as "docker unavailable" rather than the
generic "container manager not initialized".
- container: WorkingDir always uses the in-container /workspace path
rather than the host workspace path (Docker would happily start in an
unrelated directory in host bind-mount mode); bootstrap now logs the
prepare-script exit code so failures show up in container logs.
- docker/client: CreateContainerInteractive honors ContainerConfig.Entrypoint
so the same config struct produces consistent behavior on both creation
paths.
Frontend:
- executor-settings-button: extract useTaskEnvironment hook (component
shrinks below the 100-line limit); only treat 404 as empty env (other
errors no longer wipe a valid environment); track "loaded once" via a
ref so loadEnv doesn't depend on env state — that dep was creating a
new function identity on every successful poll, cancelling the
3-second interval and turning the popover into a tight loop hammering
/environment/live; copy-shell command uses sh instead of bash so it
works on user-built minimal images.
- task-reset-env-confirm-dialog: AlertDialogAction onClick calls
e.preventDefault() so Radix's auto-close doesn't dismiss the dialog
before the async reset completes (loading spinner now visible; close
is parent-controlled via onOpenChange).
- remote-credentials-card: track env method in selectedIds the same way
files/gh_cli_token are tracked, and let initialChoice match either
selectedIds OR envSecretId. First-time env-credential setup for an
agent that exposes both files+env was broken: clicking the env radio
never updated state, choice re-derived to "none", and the secret
picker disappeared before the user could pick anything.
- e2e/executor-reuse: select the acknowledge checkbox by label, not by
positional last(), so adding another checkbox to the dialog can't
silently target the wrong control.
Format pass on prettier-flagged files; gofmt fixed two whitespace
nits in lifecycle files.
* test(executor): cover applyExistingEnvironment executor-type mismatch guard
* fix(docker): connect agentctl through published ports
* fix(web): keep remote auth selection stable
* fix(docker): reuse task containers across sessions
* fix(docker): resume task environment after refresh
* fix(docker): track executor containers for cleanup
* fix(docker): explain missing agent credentials
* fix(docker): surface container launch progress
* fix(docker): stabilize container reuse and feedback
* fix(docker): show task titles in container lists
* fix(docker): use container icon for docker tasks
* fix(orchestrator): align environment reuse after rebase
* fix(sprites): reconnect sessions on existing sandboxes
* fix(docker): use container icon for local docker executor too
Local docker tasks were rendering the Docker brand "chip" mark while
remote docker uses the container glyph. Map both variants to
IconContainer so the executor surfaces look consistent.
* feat(sprites): provision a fresh sandbox when the persisted one is gone
Resuming a Sprites task whose sandbox was destroyed upstream returned
"sprite not found" and dead-ended the user. Detect the upstream 404 via
a typed ErrSpriteNotFound sentinel, mark the reconnect step skipped with
a user-facing warning explaining what happened, swap the prepare plan
in place to the fresh-provision shape, and continue the launch under a
new sandbox name on the same branch.
The progress plan is now mutable so the reporter can stream the fresh
steps live after the reconnect-then-fresh transition. Only the explicit
not-found case triggers fallback; other reconnect failures still
surface as before, and the fallback fires at most once per launch.
* feat(web): friendlier header for sandbox-recovered prepare runs
When the only warning on a completed prepare is the missing-sandbox
fallback notice, render "Environment prepared on a fresh sandbox" in
the header instead of the generic "Environment prepared with warnings".
The fallback row still carries its own amber warning callout so the
user understands why a fresh sandbox was provisioned.
* feat(web): unify executor surfaces in the right-side popover
The desktop task top bar carried a small cloud icon (RemoteCloudTooltip)
in the breadcrumb cluster while the right-side Executor popover showed
the same kind of info — two surfaces for one concept. Remove the cloud
icon from the desktop top bar; kanban cards, task list rows, and the
mobile top bar keep theirs since they have no popover.
The popover button now picks its icon by executor type (cloud for
sprites, container for docker, server for local PC) using a shared
getExecutorStatusIcon helper. While the prepare progress is preparing
the icon swaps to a spinner so the user can see at a glance that work
is in flight.
The popover content gains a Preparing-environment section that streams
"Step N of M: <step>", an amber inline note when the missing-sandbox
fallback is in progress, an Environment-ready row with duration on
success, and a failed-step summary on failure. Polling promotes to the
active 3s cadence while preparing so the icon flips to ready without
the user opening the popover.
* fix(docker): swap container glyph for a plain box icon
IconContainer's vertical ribbing reads as a microchip on small sizes.
IconBox is an unambiguous outlined cube. Use it for both docker
variants so the executor surfaces look like a box, not a chip.
* fix(orchestrator): preserve base branch on resume for clone-based executors
Sprites and Docker resumes were sending the launch request with an empty
BaseBranch, so the in-sandbox prepare script's `git clone --branch <X>`
shifted positional args and failed with "repository '/workspace' does
not exist". applyResumeRepoConfig only set BaseBranch inside the
worktree-specific block; widen that to all executors that require a
clone URL.
Local executors are intentionally left untouched: LocalPreparer reads
BaseBranch and forces a `git fetch && git checkout` against the user's
own workspace, which would clobber the "use current state" path.
* feat(sprites,docker): generate a kandev feature branch for clone-based executors
Sprites tasks committed straight onto the chosen base branch because
SpritesPreparer didn't populate WorktreeBranch and the orchestrator's
"forward env.WorktreeBranch on reuse" check only fired for Docker.
Mirror the Docker preparer's TaskBranchName generation in Sprites,
extract a shared `nonWorktreeTaskBranch` helper that uses a
deterministic suffix derived from the task ID (so legacy env rows that
predate this change resolve to the same branch on every resume), and
let the orchestrator forward env.WorktreeBranch for any clone-based
executor — Docker or Sprites.
* fix(sprites,docker): prefer existing remote feature branch on resume
When a sandbox is destroyed and we provision a fresh one for the same
task, the agent may have previously committed and pushed onto the
feature branch. The previous prepare scripts only ran `git checkout -b
{{worktree.branch}}` which would overwrite that progress with a fresh
branch off the chosen base.
Try `git fetch origin <feature>` first; on success, `git checkout -B`
against the remote tip so the agent picks up where it left off. Falls
back to a fresh local branch when origin has nothing.
Also add the same checkout block to the Sprites prepare script — it was
missing entirely, so every Sprites session was committing onto main.
* test(sprites): lock in informative branch name in fallback warning
Now that BaseBranch is preserved on resume for clone-based executors,
the missing-sandbox fallback warning detail names the actual branch
("Cloning … on the same branch main") instead of the "(unknown)"
placeholder. Add a regression test so a future change to the resume
metadata propagation can't silently regress that copy.
* fix(sprites): preserve sandbox on plain agent stop so resume is fast
StopInstance was calling sprite.Destroy() unconditionally, so every
session stop tore down the cloud sandbox. Resume then always fell into
the missing-sandbox fallback, re-cloned the repo, re-ran the prepare
script, and lost the agent's working tree. That's a 30s+ round-trip
for what should be "stop the process, then re-attach later".
Limit Destroy to terminal lifecycle reasons (task/session
deleted/archived), the same set runTerminalCleanupScript already uses.
Plain stops just close the proxy and leave the sandbox alive.
Also update the existing Docker e2e regex: the feature-branch suffix is
now a deterministic 6-hex-char prefix of the task UUID rather than a
random 3-char tail, so resume on legacy env rows lands on the same
branch.
* test(docker): cover stop + resume → same container, branch preserved
Adds a Docker e2e test that exercises the user-visible stop-then-resume
flow: launch session, stop it via WS session.stop, launch a fresh
session on the same task, and assert the same task environment +
container ID are reused and the kandev feature branch is preserved.
Also nudge the executor popover padding: when the prepare-status
section is rendered above the Sprites/Docker info block, the cumulative
top+bottom paddings looked too generous around "Sprites sandbox" /
"Local Docker". Tighten both sections vertically without affecting the
horizontal rhythm or the bottom action row.
* feat(web): show "Resuming session" feedback in popover during reconnect
The bottom Resume button on the chat panel was leaving users staring at
nothing for the 5-30s reconnect window: prepare-progress events are
intentionally suppressed on resume (the workspace is already there), so
the popover sat in the idle phase even while session.state was STARTING.
Synthesize a "resuming" phase from session state when no prepare events
are flowing, rendered as a spinner row in the popover with copy
"Resuming session — Reconnecting to the existing environment…". The
button icon also flips to the spinner so the toolbar advertises the
in-flight work without the user having to open the popover.
Extract the prepare-status reducer + section component into their own
file so the main popover module stays under the 600-line ESLint cap.
* fix(codex): align with the standard SessionDirTemplate pattern
Codex was the only agent that bind-mounted its host home into the
container via an explicit `Mounts: [{home}/.codex → /root/.codex]`
entry, while every other ACP agent uses `SessionConfig.SessionDirTemplate`
+ `SessionDirTarget`. Move codex onto the same surface so the
host-state isolation work in the next commit covers it uniformly.
This change alone does not stop the host-state leak — the bind mount
still resolves to the host's ~/.codex for now — but it removes the
last agent-specific branch in the runtime config so the docker
container manager can apply one rule for all agents.
* fix(docker): isolate per-container agent state from the host home
Every ACP agent's session dir was bind-mounted from ~/.<agent> on the
host into /root/.<agent> in the container. That exposed host state DBs,
session caches, and lock files to the container — codex's rollout DB
caches absolute /Users/... paths, which don't resolve inside Linux and
the agent silently loses prior thread context on resume.
Replace the host bind mount with a kandev-managed per-container session
dir under <kandev-home>/agent-sessions/<instance_id>/. Before the
container starts, selectively copy the auth-relevant files (the same
files Sprites already copies via RemoteAuth.SourceFiles) into that
dir; everything else — state DBs, sessions/, lock files — stays on the
host and the agent rebuilds them inside the container with
container-local Linux paths.
The change is agent-agnostic: any agent that has SessionDirTemplate
+ SessionDirTarget gets the new behavior automatically. Cleanup runs
on destructive stops only (task/session deleted/archived), mirroring
the Sprites preserve-on-stop rule so plain stops let resume re-attach
to the same agent state.
* fix(executor): make the kandev feature-branch checkout an invariant postlude
Profiles created in the UI snapshot the *then-current* DefaultPrepareScript
into their stored prepare_script field. Updates to the default — like
shipping the worktree-branch checkout block — never reach those older
profiles, so users keep landing the agent on the chosen base branch
(main) even though the env row carries a feature branch name.
Append a kandev-managed postlude to whatever prepare_script ran (custom
or default), wrapped in a subshell + `|| true` so it's idempotent and
never blocks agentctl from starting. The postlude prefers the remote tip
when origin already has the branch (resume after a destroyed sandbox),
falls back to checking out the existing local branch, and finally
creates one off the current HEAD.
Sprites needed the WorktreeProvider in resolvePrepareScript so the
postlude's `{{worktree.branch}}` actually expands; Docker already had
it.
The Docker e2e gains a regression case that explicitly stores an older
default-style script (clone, no checkout) and asserts the agent still
ends up on the feature branch — locking the contract in regardless of
which default the user happens to have stored.
* fix(web): tighten executor popover padding under the env header
The Sprites/Docker info block under the Environment-ready row had too
much vertical breathing room — the header-to-fields gap and the
inter-row spacing piled up and the popover felt airy. Trim the section
to match the snug rhythm of the rest of the popover (px-3 py-2,
1.5-unit gap before the field list, 1-unit gap between rows). Also
unify the Environment-ready row from p-3 to px-3 py-2 so the dividers
between sections sit at the same vertical cadence.
* fix(web): tighten executor popover bottom padding too
The previous tighten only fixed the top half — pb under the field rows
plus the Reset-row's full p-2 still left a generous gap below the last
"Sprite ..." line. Same fix applies whether the popover is showing
Docker or Sprites.
EnvironmentInfo: pt-2 pb-1.5 (was py-2) so the bottom field hugs the
divider, and the Reset-row goes from p-2 to px-2 py-1.5 so the button
sits closer to the text above.
* fix(web): drop the redundant Environment-ready row + tighten popover top
The READY badge next to the executor name already conveys ready-state;
the dedicated "Environment ready · 12s" row above the executor info
just added vertical noise. Skip rendering for the ready phase (other
phases that surface non-redundant info — preparing, resuming, failed —
still render). Drop top padding on the executor info section now that
nothing sits above it on the happy path.
* fix(web): move executor info to the left top bar, hover-to-open, no border
The executor section is informational, not a button — readiness state +
sandbox/container/branch fields. Move it to the left top bar (next to
the integrations menu) where it sits alongside the other contextual
info, drop the outline-button styling, and switch from click-to-open to
hover-to-open via HoverCard with 150ms open / 250ms close so the cursor
can travel to the destructive Reset button without flickering.
The trigger is a borderless icon-with-status-dot span; tabIndex keeps
it keyboard-accessible. Reset still works — it opens the existing
confirmation dialog independently of the hover card.
Tests: pointerEnter instead of click for the hover-to-open path.
* refactor(cleanup): drop dead code, dedupe predicates, unify token injection
Tier 1 of the post-review cleanup. Each item is independently safe; bundling
because they're all small, mechanical, and lint-passing.
- fix(clone): unify injectTokenIntoURL across Docker and Sprites — Sprites
used to ignore GH_TOKEN entirely (a user with only GH_TOKEN set got 401
on Sprites clone but worked on Docker). Promote one
`injectGitHubTokenIntoCloneURL` helper, accept both env vars
(GITHUB_TOKEN wins, matching Docker), and use the documented
`https://x-access-token:T@github.com/` form on both surfaces.
- fix(executor-popover): swap the <span tabIndex role="button"> trigger
for <Button variant="ghost"> so Enter/Space activation, focus rings on
Safari, and disabled semantics come for free.
- test(docker): rewrite TestDockerStopInstance_PreservesSessionDirOnPlainStop
to drive the real DockerExecutor.StopInstance path with ContainerID=""
(skips the docker daemon, exercises the kandev-side cleanup branch).
Previous version only iterated shouldRunExecutorCleanup strings — would
pass even if StopInstance always cleaned up. Now table-driven over
preserve+destructive reasons with on-disk asserts.
- refactor(orchestrator): drop dead `_ = arg` parameters from persist*
helpers (persistResumeState, persistModelSwitchState, persistLaunchState).
Brings signatures from 9→6 args, under the funlen lint cap.
- refactor(orchestrator): collapse byte-identical isContainerizedExecutor
≡ isCloneBasedExecutorType predicate into one.
- refactor(lifecycle): drop dead getMetadataBool, FileUploader.RunCommand,
expandMountSource {home} arm (no production agent ships {home} in
Mounts; codex_acp_test asserts it), dockerTaskBranch one-line wrapper.
* refactor(scripts): postlude owns worktree-branch checkout
Strip the inline `git checkout -B` block from the Docker and Sprites
default prepare scripts. The kandev-managed postlude
(KandevBranchCheckoutPostlude) is appended unconditionally after every
user prepare script, so keeping the same logic inline made the default
and the postlude two sources of truth that could drift.
* refactor(lifecycle): drop dead instance_port persistence entry
`instance_port` is only used as a zap log field — it's never written to
the executor metadata map, so carrying it across resume in
`persistentMetadataKeys` was a no-op.
* refactor(lifecycle): drop legacy {home} session-dir fallback
ExpandSessionDir kept a `{home}/<dotdir>` fallback for the case where
kandevHomeDir was unresolved. In production, ResolvedHomeDir() always
returns a non-empty path, so the fallback only ever fired in tests
that hadn't been updated to pass an instance ID. Returning the empty
string in that case makes container.go skip the bind mount — the
correct behaviour, since without a kandev home there's no session dir
to mount.
* refactor(orchestrator): collapse env-reuse helpers into reuseExistingEnvironment
The two env-reuse helpers (`applyExistingEnvironment` and
`applyExistingEnvironmentRuntimeMetadata`) were always called together
and shared the same intent: carry the previous TaskEnvironment forward
into the launch request. Merge them into a single
`reuseExistingEnvironment(ctx, req, env)` so the executor-type gate
runs once for both env-level fields and runtime metadata, and callers
have a single entry point.
Document the layered metadata rule (running wins for container_id;
env wins for sprite_name and other stable IDs) inline so the next
reader doesn't have to reverse-engineer it from two functions.
* refactor(web): lift prepare-summary reducer + isFallbackNoticeStep into lib/prepare
Move the popover's prepare-summary reducer (and the
`isFallbackNoticeStep` helper duplicated word-for-word in
`prepare-progress.tsx`) into `apps/web/lib/prepare/summarize.ts`. The
hook moves to `hooks/domains/session/use-prepare-summary.ts` to match
the project's domain-organized hook layout.
The popover's PrepareSummary type and the chat panel's EffectiveStatus
serve different UIs, so the panel keeps its own `deriveStatus` — only
the literal duplicate (`isFallbackNoticeStep`) is shared.
* refactor(web): split executor-settings-button by concern
Slice the 471-line button file into three modules with single
concerns: the popover shell stays in `executor-settings-button.tsx`
(168 lines, just trigger + popover layout + icon/status dot),
`executor-environment-info.tsx` owns the env detail rows + status
badge, and `hooks/domains/session/use-task-environment.ts` owns the
fetch/poll/reset lifecycle alongside other session hooks.
* refactor(e2e): extract docker + session helpers from docker-launch.spec
Pull the inline docker CLI helpers (`dockerInspectExists`, `dockerStop`,
`dockerCurrentBranch`, etc.) and the generic session-state pollers
(`waitForLatestSessionDone`, `waitForSessionDone`,
`waitForSessionEnvironment`) into `e2e/helpers/docker.ts` and
`e2e/helpers/session.ts`. The spec drops from 763 to 647 lines and the
helpers can be reused by future docker / session tests.
* test(scripts): execute postlude under bash -e against a real git repo
Pair the static-content assertion with a behaviour test that runs the
postlude as a real shell script. Three scenarios exercise the three
branches inside the snippet: remote tip exists (track origin), no
remote (fresh local branch), and already on the target branch
(idempotency). This catches regressions where the snippet still parses
as bash but no longer does the right thing — wrong refspec, missing
-B, swallowed exit code, etc.
* test(sprites): integration check for stop preserve + resume reconnect path
Each piece of the resume-fast path is unit-tested separately
(StopInstance keeps the token cache, spritesShouldReconnect reads
sprite_name, the reconnect plan has 3 steps). This new test wires them
together end to end so a future change that accidentally strips
sprite_name on stop or forgets to look at metadata on launch fails
loudly here, not at runtime.
* test(sprites): assert plan ordering instead of absolute indexes
Replace `plan.index(spriteStepWaitHealthy) == 1` with an
`assertPlanOrder` helper that checks "X runs before Y". The contract
the test guards is the order of create-sprite → wait-healthy →
agent-instance, not their absolute slot numbers — adding a new step
shouldn't break the test if the order is preserved.
* test(web): hoist test constants above vi.mock factories
Move SESSION_ID and friends above the vi.mock factories so the mock
closures don't depend on vitest hoisting magic capturing TDZ-undefined
locals. The previous layout worked because factories aren't invoked
until the module under test is imported (after const initialization),
but a refactor that imports earlier would break the closure silently.
* fix(seeder): contain localFileUploader writes inside the session root
CodeQL flagged go/path-injection on the localFileUploader.WriteFile
sink: instanceID flows into the write path, and a malformed value
would let writes escape the kandev session root and clobber arbitrary
host files. Bind the uploader to its session root and reject any path
that filepath.Rel resolves to a "../" escape. Production callers
always pass an internally generated instanceID; the check is defense
in depth against a future caller plumbing in user input.
* refactor(docker): split reconnectToContainer + cover reconnect helpers with unit tests
Address recurring review feedback on the docker reconnect path:
- reconnectToContainer was 98 lines (over the 80-line repo limit). Split
into ensureContainerRunning (inspect + start + re-inspect) and
bringupAgentctl (health-check, find-or-create instance, transparent
re-handshake). The orchestration body now reads top-down at 49 lines.
- resolveDockerEndpoint and waitForAgentctlHealth grew real branches in
this PR (host-port lookup with container-IP fallback; 240×500ms
retry loop) but had no direct tests. Introduce narrow hostPortLookup
/ healthChecker interfaces, satisfied by *docker.Client and
*agentctl.ControlClient, so both functions are now driven by stubs
that exercise happy-path, fallback, retry-until-healthy, exhaustion,
and context-cancellation branches.
- Add direct unit tests for the docker package's pure helpers
(normalizeDockerHostIP, buildDockerPortBindings) and for
dockerAgentctlPortBindings — a regression in the published port
range or host IP would silently break container reconnect.
* test(handlers): cover ensureShellExecution stale-execution recovery
The recovery branch (Failed execution → CleanupStaleExecutionBySessionID
→ relaunch) is the load-bearing piece of shell session resilience and
had no direct test. Extract the body into a free function that takes a
narrow shellLifecycle interface so the four branches are testable
without standing up a real lifecycle.Manager:
- happy path (running execution, no cleanup)
- stale recovery (Failed → cleanup → fresh)
- initial GetOrEnsureExecution failure
- cleanup failure must NOT trigger a re-launch loop
- relaunch failure surfaces a clear "recover execution" error
* fix(backend): clear lint blockers from CI lint job
CI's only-new-issues lint surfaced three real items left over from
recent additions:
- container.go bootstrap snippet had two `fi` shell tokens flagged by
dupword. Tagged with the same nolint directive used by the
KandevBranchCheckoutPostlude (two distinct shell blocks closing).
- DockerExecutor.CreateInstance was 89 lines (over funlen 80). Split
the body into helpers: reportCreateInstanceProgress (deferred
progress reporter), tryReconnect (gates the resume path),
seedSessionDir (per-container auth seed), buildContainerLaunchConfig,
and buildCreatedInstance. CreateInstance is now a 30-line top-down
orchestrator.
- executor_execute.go was 887 effective lines (over revive 800). Move
reuseExistingEnvironment + its helpers (latestExecutorRunningForEnv,
executorRunningMatchesEnvironment, applyExecutorRunningMetadata,
ensureLaunchMetadata) into a sibling executor_environment_reuse.go.
* fix(web): only block submit during real env prepare, not every STARTING
The "agent still being set up" submit gate was triggering on every
session that briefly transitions through STARTING — including local
quick-chat, which has no prepare phase and has its agent live within
~100ms. The eager-init quick-chat e2e tests (chat/quick-chat.spec.ts
and chat/message-queue.spec.ts) wait for the editor to become
editable, then press Cmd+Enter. The race made wrappedSubmit a no-op
exactly when the test was firing — message dropped, agent never saw
it, downstream assertions failed.
Split the merged isStarting flag in useSessionState: keep the broad
flag (still used for the workspace-prep loading UI), and expose
isPreparingEnvironment as a separate signal that's only true while
prepareProgress.status === "preparing". Thread it through chat-input-
area → chat-input-container → useChatInputContainer and gate the
submit-disabled tooltip on it instead of the broad isStarting.
Net: Docker / Sprites bootstrap still surfaces the "agent still being
set up" message and disables submit; quick-chat / local sessions stop
silently dropping in-flight messages.
* fix(web): keep editor disabled during STARTING so quick-chat sends don't race
My previous fix made submit no longer block on isStarting, with the
goal of keeping local quick-chat sessions from silently dropping
messages. The trace from CI showed it traded one failure for another:
the editor became contenteditable while the session was still
STARTING, the e2e suite pressed Cmd+Enter immediately, and the
backend rejected with "Failed to send message to agent" because the
agent process wasn't ready yet.
Restore the original semantics — STARTING blocks both editor and
submit — but keep `isPreparingEnvironment` as the narrower trigger for
the "agent still being set up" tooltip. Net behaviour:
- STARTING transition: editor disabled, no tooltip (signal is the
disabled state itself)
- container/sandbox prepare: editor disabled + tooltip explaining why
- quick-chat sessions: editor stays disabled until RUNNING, no race
* docs(web): correct isPreparingEnvironment JSDoc after STARTING re-block
The
|
||
|
|
2544fd85f8 | feat(image): publish universal image flavor with toolchains + customize docs (#891) | ||
|
|
d031ba9824 |
fix(ci): harden release package publishing (#862)
* fix: pin pnpm for Docker release builds * test: harden release pnpm version guard * test: improve release guard diagnostics * test: cover named pnpm setup steps * fix: make npm release publishing rerunnable |
||
|
|
e8780d54bb |
fix(release): add repository field for npm provenance, patch CLI perms (#826)
* fix(release): add repository field for npm provenance The publish-npm step in release run 25350606233 (v0.39.1) failed for all 6 packages with: npm error code E422 npm error 422 Unprocessable Entity Error verifying sigstore provenance bundle: Failed to validate repository information: package.json: "repository.url" is "", expected to match "https://github.com/kdlbs/kandev" from provenance We added `--provenance` to `npm publish` earlier so the registry attaches a sigstore attestation linking each tarball to its source repo + workflow run. The attestation is signed using info from the OIDC token (which includes the repo). At publish time, npm cross-checks the package.json's `repository.url` against the OIDC repo claim — if the package.json doesn't have one, or it doesn't match, the publish is rejected. Both the runtime template (package-npm-runtime.sh) and the main apps/cli/package.json were missing `repository`. Adding `repository` + `homepage` to both. The runtime template gets it inline via the heredoc; the main package.json gets static fields. This fix unblocks the next release. v0.39.1 itself can't be republished (version conflict on npm if it had landed; on GitHub release it already exists). The next release will be v0.39.2 and will go all the way through. Side state notes: - v0.39.1 git tag + GitHub release exist with all 5 platform tarballs. - Homebrew tap was updated to v0.39.1 — `brew install` works as v0.39.1. - npm has nothing at v0.39.1 (all 6 publishes 422'd). - v0.39.2 will replace the npm side; Homebrew formula will move forward; v0.39.1 GitHub release stays as a historical artifact. * fix(release): re-chmod cli.js after artifact download (lost in zip roundtrip) `brew install kdlbs/kandev/kandev` followed by `kandev` failed with: /opt/homebrew/bin/kandev: line 2: /opt/homebrew/Cellar/kandev/0.39.1/libexec/cli/bin/cli.js: Permission denied cannot execute: Undefined error: 0 Inspected the published v0.39.1 macos-arm64 tarball: -rwxr-xr-x kandev/bin/kandev (755 — executable, correct) -rwxr-xr-x kandev/bin/agentctl (755 — executable, correct) -rw-r--r-- kandev/cli/bin/cli.js (644 — NOT executable) Root cause: `actions/upload-artifact@v4` + `actions/download-artifact@v4` zip-encode the artifact during upload and extract during download. **Executable bits do not survive that round-trip.** The Go binaries (kandev, agentctl) keep their +x because they're built inside the same build-bundles job (no artifact crossing); cli.js is built in build-cli, uploaded as an artifact, downloaded in build-bundles, and emerges as 644. The chmod +x in package-cli.sh runs before upload, so it's effectively erased. Fix: re-apply chmod +x to dist/kandev/cli/bin/cli.js right before tarballing, in the Package bundle step (which runs in build-bundles after the artifact download). Could not have been caught by the Tier 1.1 local smoke test we did before merging — that test cp -R'd the cli/ dir directly without an artifact roundtrip, so the chmod survived. This bug only manifests in CI's matrix builds. Manual recovery for v0.39.1 brew users: chmod +x /opt/homebrew/Cellar/kandev/0.39.1/libexec/cli/bin/cli.js Or wait for v0.39.2 and `brew upgrade kandev`. |
||
|
|
cfcc755702 |
fix(release): restore PR-and-merge pattern (#824)
* fix(release): restore PR-and-merge pattern — main is protected by a ruleset
The first real run of the new release workflow (run 25349824365) failed at
the push step with:
remote: error: GH013: Repository rule violations found for refs/heads/main.
remote: - Changes must be made through a pull request.
remote: ! [remote rejected] main -> main
main is protected by a ruleset that requires PRs. Earlier I checked
/repos/kdlbs/kandev/branches/main/protection (the classic branch-protection
endpoint), which reported "not protected" — but rulesets are a separate,
newer mechanism enforced at push time, not surfaced via that API. So the
"simplify to direct push" refactor in
|
||
|
|
379230892b |
feat: brew install + npm sibling channels via OIDC trusted publishing (#806)
* chore: ignore local secret file
* feat(cli): replace self-update with package-manager-owned runtime resolution
The CLI no longer downloads runtime bundles from GitHub on first run. Instead:
- A new resolveRuntime() in apps/cli/src/runtime.ts resolves the bundle in
three steps: KANDEV_BUNDLE_DIR env var (Homebrew wrapper, tests) → installed
@kdlbs/runtime-{platform} npm package → cached download (only when
--runtime-version is given explicitly).
- apps/cli/src/run.ts is split: launch logic separated from download logic.
The download path stays for the explicit --runtime-version flag only.
- The --version flag now prints CLI semver (was: runtime tag selector). The
old behavior moves to --runtime-version, documented as advanced/debug.
- The npm self-update prompt (apps/cli/src/update.ts) is removed entirely.
- apps/cli/package.json declares optionalDependencies for five
@kdlbs/runtime-{platform} packages and engines.npm: ">=7" (required for
optional-dep resolution during npx).
- Adds esbuild + a "bundle" script that produces dist/cli.bundle.js — a
single-file CJS bundle with all Node deps inlined. The Homebrew formula
uses this; the npm package keeps the unbundled dist/cli.js.
Tests: 130/130 passing. Bundle smoke-tested in a directory with no
node_modules to confirm tree-kill is correctly inlined.
* feat(release): unified vX.Y.Z release flow with brew + npm sibling channels
GitHub release artifacts become the canonical build output. npm and Homebrew
are independent sibling channels — both consume the same GitHub release
tarballs; neither depends on the other.
Workflow changes (.github/workflows/release.yml):
- Trigger is now workflow_dispatch only (maintainer-only, gated by repo write
access). Inputs: bump dropdown (patch/minor/major) + dry_run checkbox.
- Single job graph orchestrates everything in one run:
prepare (bump, PR, merge, tag) → build-web/build-cli → build-bundles (5
platforms) → docker → publish-release → publish-npm + update-homebrew-tap.
- The prepare job inlines version bump, optionalDependencies sync, CHANGELOG
generation, PR create + squash-merge, and tag push. Replaces what
release-pr.sh used to do locally.
- Tag-push trigger removed; everything runs from the dispatch flow.
New scripts (called by the workflow):
- package-cli.sh: builds + esbuild-bundles the CLI for inclusion in release
bundles (cli/bin/cli.js + cli/dist/cli.bundle.js + package.json).
- package-npm-runtime.sh: generates per-platform @kdlbs/runtime-* npm
packages from release bundles. Sets npm os/cpu fields so npm 7+ skips
non-matching platforms during npx kandev install.
- publish-npm.sh: verifies GitHub assets exist, downloads them, repackages,
publishes all 5 @kdlbs/runtime-* packages + main kandev@X.Y.Z.
- update-homebrew-tap.sh: clones kdlbs/homebrew-kandev via SSH using the
HOMEBREW_TAP_DEPLOY_KEY repo secret, generates Formula/kandev.rb pointing
at the GitHub release assets, pushes directly to main.
Removed scripts:
- release.sh and release-pr.sh — both are redundant with the new workflow.
Required CI secrets:
- NPM_TOKEN: granular access token with publish access to kandev + @kdlbs/*
- HOMEBREW_TAP_DEPLOY_KEY: SSH private key for kdlbs/homebrew-kandev
* docs: brew install, runtime resolution, and GHA-driven release flow
- README.md: add Homebrew quick start (brew install kdlbs/kandev/kandev),
update NPX docs to require npm 7+, document the three update paths.
- docs/cli.md: rewrite architecture diagram to show three-step runtime
resolution. Replace --version <tag> docs with --version (CLI semver) +
--runtime-version <tag> (advanced/debug). Remove KANDEV_NO_UPDATE_PROMPT.
- apps/cli/README.md: add Homebrew install path; document
package-manager-owned updates.
- apps/cli/README_internal.md: rewrite to describe the npm package vs
GitHub bundle artifact-shape distinction and the three-step resolver.
Document the GHA-driven release UI (Run workflow + bump dropdown).
- AGENTS.md: add Release & Versioning Convention section describing the
unified single-SemVer flow, the sibling channel model, and the GHA flow.
* fix(release): release-bundle bin/cli.js must require the esbuild bundle
The previous package-cli.sh copied apps/cli/bin/cli.js verbatim into the
release bundle. That file requires `../dist/cli.js` (unbundled), which
only works when node_modules is present alongside it.
In the GitHub release bundle (used by Homebrew and manual installs),
there is no node_modules — `require('tree-kill')` and `require('tar')`
inside dist/cli.js would crash at startup.
Now package-cli.sh writes a release-specific bin/cli.js that requires
`../dist/cli.bundle.js` instead. The bundled file has all Node deps
inlined via esbuild, so it runs standalone in the Cellar.
The npm package keeps its own bin/cli.js (in apps/cli/bin/) requiring
the unbundled dist/cli.js, since npm installs tree-kill etc. normally.
Caught while smoke-testing the release bundle locally with
KANDEV_BUNDLE_DIR — backend + web boot succeeded after this fix.
* feat(release): switch npm publishing to Trusted Publishers (OIDC)
The previous flow required an NPM_TOKEN secret. Local testing with that
token failed with EOTP because the granular access token still required a
2FA OTP on publish despite the account-level setting change. npm now
recommends Trusted Publishers (OIDC) over long-lived tokens.
Workflow change (release.yml):
- publish-npm job declares `permissions: id-token: write` so GitHub Actions
can mint OIDC tokens.
- Removes NODE_AUTH_TOKEN env var. The npm CLI auto-detects the OIDC
environment and exchanges the GitHub OIDC token for a short-lived
npmjs.com publish credential.
- Comment block updated to reflect the new auth model. NPM_TOKEN is no
longer listed in required secrets (already deleted from repo secrets).
Script change (publish-npm.sh):
- Updates `npm publish` calls to include `--provenance` so each published
package ships with a signed attestation linking it back to this commit
and workflow run.
- Header comment explains the OIDC auth model.
Setup required on npmjs.com (per package, before first OIDC publish):
For each of the 6 packages — kandev plus @kdlbs/runtime-{linux-x64,
linux-arm64,darwin-x64,darwin-arm64,win32-x64} — go to the package's
"Settings → Publishing access" and add a Trusted Publisher pointing at
this repo (kdlbs/kandev), workflow filename release.yml, and (optionally)
an environment.
* fix(release): pin optionalDependencies to bootstrap version in committed source
CI was failing because apps/cli/package.json declared optionalDependencies
at 0.16.0 but only 0.0.0-bootstrap exists on npm for those packages. The
lockfile couldn't resolve, and `pnpm install --frozen-lockfile` rejected
the working tree.
Pin optionalDependencies to 0.0.0-bootstrap in the committed source so the
lockfile resolves cleanly. The release workflow now bumps them to the real
version inside publish-npm.sh, just before publishing main kandev — at
which point the matching @kdlbs/runtime-*@VERSION tarballs have already
been published earlier in the same step. This avoids the chicken-and-egg
where the prepare job tried to bump optional deps to a version that didn't
yet exist on npm.
Workflow changes:
- prepare job: only bumps the main package version, not optional deps.
- publish-npm.sh: rewrites optionalDependencies to $VERSION right before
the main `npm publish`, after the 5 runtime packages have been published.
The published kandev@X.Y.Z still has its optionalDependencies pinned to
matching @kdlbs/runtime-*@X.Y.Z. Only the local source uses the bootstrap
pin for development convenience.
* fix: address PR review comments on release flow + runtime resolution
PR reviewers (Greptile, Claude, CodeRabbit) flagged six concrete issues.
Each is addressed below.
Critical:
- publish-npm.sh: previously exited 0 even when runtime packages failed to
publish, which would leave kandev@VERSION on the registry pointing at
@kdlbs/runtime-*@VERSION that never got published. Now: capture full npm
output on failure, distinguish "already published" (idempotent skip)
from real failures, abort before publishing main kandev if any runtime
fails.
- release.yml: build-bundles referenced needs.prepare.outputs.tag without
declaring prepare in its needs array. Static analysis flagged this.
Added prepare to the needs list.
Major:
- update-homebrew-tap.sh: the formula's write_env_script only injected
KANDEV_BUNDLE_DIR. run.ts:174 reads KANDEV_VERSION to display the
running version in startup logs; without it Homebrew users would see
"release: (env)" instead of "release: 0.17.0". Pass KANDEV_VERSION:
version.to_s through the wrapper.
- runtime.ts: the catch {} block silently swallowed validateBundle errors,
hiding "package installed but corrupt" cases behind the generic
"no runtime found" message. Split the lookup so MODULE_NOT_FOUND falls
through, but validation errors propagate.
- run.ts: the --runtime-version code path skipped validateBundle, missing
the file-existence checks that the npm/env paths get. Added it.
Minor:
- release.yml: workflow-level permissions defaulted to write for everyone.
Tightened to read-only at the workflow level; jobs that need writes
(prepare, docker, publish-release) declare them explicitly.
- args.test.ts: added missing tests for --runtime-version with no value
and --runtime-version followed by another flag, parallel to the
existing --port tests.
Tests: 132 passing (was 130; +2 new).
* fix: address second-round PR review (cleanup traps, host-key pinning, supply chain)
Latest review (Claude + Greptile) flagged three security/cleanup items
on top of the previous fix commit. All three addressed below.
publish-npm.sh:
- Add `trap 'rm -rf "$WORK_DIR"' EXIT` immediately after `mktemp -d` so
the tmpdir is cleaned up even when `pnpm build` or `npm publish` fails
partway through (set -e would otherwise abort before the explicit
rm at the end).
- Remove redundant `pnpm build` before `npm publish` — the `prepublishOnly`
script in package.json already runs the build.
update-homebrew-tap.sh:
- Combine `cleanup_ssh` + WORK_DIR cleanup into one EXIT trap so `git clone`
/ `git push` failures don't leak the tmpdir.
- Pin GitHub's published SSH host keys (ed25519, ECDSA, RSA) into a temp
`known_hosts` file instead of using `accept-new` + `/dev/null`. This
closes the "trust any host key on first connect" gap. The pinned keys
come from https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/githubs-ssh-key-fingerprints.
release.yml:
- Replace the curl|tar git-cliff install with `orhun/git-cliff-action@v4`
(already used in publish-release). The action pins to a digest, so we
no longer need to verify a tarball checksum manually. Removes a
supply-chain attack surface where a tampered git-cliff binary could run
during `prepare` (which has contents:write + pull-requests:write).
* revert: keep curl install for git-cliff, add sha512 verification instead
The previous commit (18b2a5c) replaced our `curl | tar` install with
orhun/git-cliff-action@v4 under the banner "supply chain". On reflection
that swap doesn't actually improve anything:
- @v4 is a moving tag, not a digest pin — same trust as a tagged curl URL.
- Both depend on trusting the same maintainer (orhun).
- The action adds a third-party step we don't audit.
The legitimate concern from the review was "no integrity verification on
the downloaded tarball." Address that directly: pin the asset version,
embed its published sha512, and abort if the download doesn't match.
The hash for v2.6.1's x86_64-linux-gnu tarball came from
https://github.com/orhun/git-cliff/releases/download/v2.6.1/<asset>.sha512.
This restores the original setup with a real integrity check and one
fewer external dependency.
* fix: address remaining PR nits (env restore, sha256 download dir)
runtime.test.ts: drop the redundant `delete process.env.KANDEV_BUNDLE_DIR`
in afterEach — `process.env = { ...origEnv }` already restores the
original environment. The redundant delete would silently un-set the var
if a developer had it in their shell, leaking test side effects across
suites.
update-homebrew-tap.sh: fetch_sha256 now downloads into $WORK_DIR instead
of /tmp/. The EXIT trap covers WORK_DIR, so the .sha256 files are cleaned
up automatically. Avoids leaking 5 tmpfiles per release run on long-lived
runners.
* fix: address third-round PR comments
- update-homebrew-tap.sh: Homebrew formula was calling
`bin.write_env_script` on the bin directory with target `cli/bin/cli.js`,
which would create the wrapper at `$HOMEBREW_PREFIX/bin/cli.js` (named
after the target's basename) instead of `$HOMEBREW_PREFIX/bin/kandev`.
Result: `brew install kandev` would have produced `command not found`.
Use `(bin/"kandev").write_env_script(...)` to name the wrapper
explicitly. Caught by Claude as a P0 blocker.
- publish-npm.sh: main `kandev` publish lacked the EPUBLISHCONFLICT
idempotency handling that the runtime-package loop has. If publish-npm
was re-run after a partial failure (e.g. the runtime packages succeeded
but the main package failed and was then manually published), the
re-run would fail on the main package even though the version was
already on the registry. Now: capture output, treat
"already published" as success, fail loudly with full output on real
errors. Caught by Greptile as P1.
* refactor(release): direct push to main instead of PR-and-merge dance
The prepare job was opening a release branch, creating a PR, and
immediately squash-merging it via gh CLI. That pattern was carried over
from the old release-pr.sh, but in this workflow it earns nothing:
- main has no branch protection, so direct push is allowed.
- The PR was force-merged in the same step (no `--auto`, no waiting for
required checks), so it provided no pre-merge validation.
- Discoverability is fine without a PR — git tag, GitHub release, workflow
run, and main commit are all visible.
The PR-and-merge sequence had ~5 GitHub API roundtrips, each a potential
failure point, plus added 30+ lines of YAML and required pull-requests:
write on the job. Replaced with a straight-line commit + tag + push:
git fetch origin main && git checkout main && git reset --hard origin/main
... bump pkg.json, regen CHANGELOG, npm install ...
git add ... && git commit && git tag -a && git push origin main "$TAG"
Job permissions tightened to contents: write only.
Race condition note: if main advances between fetch and push, the push
fails non-fast-forward — same outcome as the old PR pattern, where the
auto-merge would have failed. For workflow_dispatch releases with the
maintainer actively driving, the race is essentially never going to
happen, and the recovery (re-run the workflow) is the same.
The dry-run path still bumps + regenerates CHANGELOG inside the runner
so the maintainer can verify the computed values look right, just
without committing or pushing anything.
* chore(release): drop verbose comment about direct-push rationale
* docs: drop 'no first-run download' note from user-facing docs
The phrasing only makes sense to readers who knew the previous model
required a first-run download. New users have no baseline to compare
against, so it adds noise. Removed from docs/cli.md and apps/cli/README.md
(which renders on npmjs.com). Kept in apps/cli/README_internal.md as a
design note for contributors.
* Revert "chore: ignore local secret file"
The /secret entry was added defensively after the bootstrap test, but it
clutters .gitignore with a project-specific path that nobody else needs.
Drop it; secrets stay out of the repo by other means (don't commit them).
* fix: address third-pass review (stale workflow comments + missing test)
release.yml:
- The header comment block and dry_run input description still referenced
the PR-and-merge step that was removed in
|
||
|
|
9496068558 | docs: add issue templates (#754) | ||
|
|
5644b0ad18 | feat(preview): add PR preview environments via Sprites (#707) | ||
|
|
367700f507 | ci: stop Claude code review workflow from committing to PRs (#683) | ||
|
|
4e5f16a0b0 | ci: split Claude code review into separate same-repo and fork jobs (#642) |