Commit Graph

112 Commits

Author SHA1 Message Date
Carlos Florêncio 3cb965d0fd feat: add durable plan implement action (#1645) 2026-07-10 12:56:05 +01:00
José Almeida 3e237e177a feat: add agent-authored code walkthroughs (#1647)
* feat: add code-walkthrough backend (MCP tool, service, persistence, WS events)

* feat: render agent code walkthroughs as line-anchored cards + editor window

* test: add code-walkthrough re-emit mock-agent scenario

* fix: rework code-walkthrough UX (floating card, line reveal, minimize, clickable file)

* fix: improve code walkthrough editor UX

* fix: backfill walkthrough launcher after websocket connect

* fix: improve walkthrough requests and editor anchors

* fix: clear walkthrough highlights on close

* feat: add walkthrough discard and prompt customization

* fix: send walkthrough requests through saved prompt

* fix: support postgres built-in prompt seeding

* fix: address walkthrough review feedback

* fix: address walkthrough review follow-ups

* fix: harden walkthrough lifecycle races

* fix: reuse walkthrough notification cleanup

* fix: guard stale walkthrough file opens

* fix: abort stale file opens before paint

* fix: stabilize github settings e2e readiness

* fix: use stable github settings e2e selector
2026-07-10 08:34:57 +01:00
Carlos Florêncio 1953420939 fix: sync task completion with terminal workflow steps (#1639) 2026-07-09 21:37:56 +01:00
Carlos Florêncio ff2647bf13 feat: add configurable branch names (#1615) 2026-07-09 19:59:29 +01:00
point-source e1cf99f821 fix(executors): truthful executor rows, startup reconciliation, resume-safe cleanup (#1597) (#1608)
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
2026-07-08 23:37:02 +01:00
Carlos Florêncio b4824bd4c7 fix: preserve inherited task worktrees (#1621) 2026-07-08 17:11:50 +01:00
José Almeida 1ed2c59997 feat: persist and expire quick chats (#1612)
* feat: persist and expire quick chats

* fix: address quick chat review feedback

* fix: resolve quick chat review findings

* fix: close remaining quick chat review gaps

* fix: avoid repeated quick chat expiration rechecks

* fix: guard quick chat expiration deletes

* fix: expire abandoned starting quick chats

* fix: ignore missing quick chat expiration candidates
2026-07-08 12:11:28 +01:00
Carlos Florêncio 61f4427eea fix: encode workflow hidden flag for postgres (#1614) 2026-07-07 18:54:46 +01:00
Carlos Florêncio c18a13c361 feat(tasks): adopt Office-style /tasks list UX (#1593) 2026-07-03 22:56:13 +01:00
Carlos Florêncio 657b92eb69 fix: restore workspace deletion from settings (#1547)
* fix: restore workspace deletion from settings

* fix: validate workspace delete in service

* fix: clarify workspace delete errors

* fix: address workspace delete review feedback

* fix: guard confirmed workspace delete before cascade

* fix: simplify workspace delete cascade guard

* fix: make confirmed workspace delete atomic

* fix: preserve confirmed workspace delete side effects

* fix: snapshot confirmed workspace cascade rows

* fix: clarify confirmed workspace delete cleanup

* fix: preserve office workspace deletion cleanup

* fix: preserve workspace delete error mapping

* fix: streamline workspace delete cascade

* fix: harden workspace delete cleanup

* fix: keep workspace delete failure logs

* fix: fail workspace delete on missing session inventory

---------

Co-authored-by: root <root@kandev.ts.net>
2026-06-30 08:24:43 +01:00
Carlos Florêncio 831a15a9b7 fix: repair postgres schema migration coverage (#1529) 2026-06-29 09:35:40 +01:00
Carlos Florêncio 211e738ec0 fix: reuse worktrees for same-task handoffs (#1510) 2026-06-27 15:00:38 +01:00
Carlos Florêncio a28c4226de fix: make schema migrations replayable (#1489) 2026-06-25 10:28:42 +01:00
Carlos Florêncio 6126edb203 feat: add subtask completion trigger (#1473) 2026-06-23 16:07:10 +01:00
Carlos Florêncio 991c7817ad fix: stop task runtimes from leaking (#1465) 2026-06-23 12:07:46 +01:00
Carlos Florêncio aee1846cd5 feat: remove nextjs production runtime (#1389) 2026-06-18 18:36:48 +01:00
Carlos Florêncio 327f7d4ec0 fix: persist agent error dismissal (#1397) 2026-06-16 22:27:28 +01:00
Carlos Florêncio a140c5817b fix: preserve recoverable agent errors (#1368) 2026-06-14 21:40:52 +01:00
Carlos Florêncio 66871a6282 fix: initialize postgres schemas (#1365) 2026-06-14 17:47:40 +01:00
Carlos Florêncio f444fba455 perf(chat): eliminate redundant re-renders and markdown re-parsing (#1357) 2026-06-13 19:26:54 +01:00
Nicolas Lenepveu 66e2ed226d fix(orchestrator): apply max inflight cap to Sentry watcher tasks (#1326) 2026-06-11 23:13:56 +01:00
Carlos Florêncio 1ba3f086d6 fix(web): update session tab title on model changes (#1335) 2026-06-11 22:27:06 +01:00
Nicolas Lenepveu 5d3ddff8b6 fix(task): reap active sessions when archiving a task (#1275) 2026-06-11 14:51:34 +01:00
José Almeida 4601dbc2d2 feat(tasks): search tasks by PR number in command panel (#1268)
* feat(tasks): search tasks by PR number in command panel

Typing a PR number (e.g. 1243 or #1243) in the Cmd+K panel now surfaces
the task that PR is associated with, alongside the existing
title/description/repo search.

The github store gains a workspace-scoped ListTaskIDsByPRNumber lookup
over github_task_prs; the task service resolves it through an injected
PRTaskResolver interface (github service wired in cmd/kandev) and merges
PR matches into search results, deduped and respecting the
archived/ephemeral/config filters. Keeping the lookup at the service
layer avoids coupling the task repository to the github schema, so
existing search queries and tests are untouched.

* fix(tasks): scope PR-number search to page 1 and unfiltered queries

Address review feedback on PR-number search:

- Only augment results on the first page of an unscoped search. Running
  on every page re-prepended the PR match and truncated a real result
  off each subsequent page; running under a workflow/repository filter
  leaked tasks that don't satisfy the filter.
- Add idx_github_task_prs_pr_number so the lookup is index-backed
  instead of a full scan (pr_number is the 3rd column of the existing
  UNIQUE constraint, unusable as a leading key).
- Log instead of silently dropping a task when GetTask fails in the
  PR-match loop.

* test(github): dedup PR-lookup test setup into shared newTestStore

Add workspace_id to newTestStore's tasks table and drop the near-identical
newTestStoreWithWorkspaces helper, per review feedback.

* perf(tasks): batch-fetch PR-matched tasks instead of N+1 GetTask

Add a repository GetTasksByIDs and use it in augmentWithPRMatches so PR-number
search loads all matched tasks in one query instead of one GetTask per id,
addressing the N+1 noted in review. Fetch+filter is extracted into a
fetchPRMatchedTasks helper that preserves resolver order and dedup.
2026-06-05 16:20:30 +01:00
Nicolas Lenepveu 0eac403499 fix(repo): exclude archived tasks from repository delete guard (#1274) 2026-06-05 15:59:21 +01:00
João Salavisa d83c1dd982 feat(diff): use stored base_branch for diff stats + per-task compare picker (#1273)
* feat(diff): use stored base_branch for diff stats + per-task compare picker

Task-card additions/deletions and the Changes panel were computed against a
hardcoded origin/main → master priority list inside agentctl. For tasks
branched off a release/staging branch this inflated the counts by every
commit since the actual fork point, and the Changes panel grouped unrelated
files alongside the task's real work.

This wires the per-repo task_repositories.base_branch end-to-end into the
agentctl WorkspaceTracker so each repo's diff stats resolve against the
recorded base instead of the integration-branch fallback. A picker on the
Changes panel branch hover card lets the user change the base mid-task; the
new value is persisted via PATCH /tasks/:id/repositories/:repo_id, pushed
live to the running agentctl instance, and the session's cached
base_commit_sha is cleared so the commits panel + cumulative diff refetch
against the new base without a session restart.

Falls back to the existing origin/main → master list for legacy tasks
(empty base_branch) and to the branch tip when no merge-base exists with
HEAD (unrelated histories — local rebase backups, etc.) so the stats and
commits panels stay anchored on the same SHA in every failure mode.

* fix(agentctl): sanitize user-controlled base_branch refs before git exec

CodeQL flagged the picker's user-controlled base_branch payload reaching
exec.Command("git", ..., baseBranch) inside workspace_git_cmd.go as a
command-built-from-user-controlled-source vulnerability. A malicious caller
could smuggle a leading-"-" argument like "-upload-pack=..." that git
itself reinterprets as a flag, even though exec.Command keeps args
separate from the shell.

Adds an IsSafeGitRef sanitizer at every boundary the value crosses on its
way to git: the service write path, the agentctl HTTP handlers
(/workspace/base-branches, /git/log, /git/cumulative-diff), and
WorkspaceTracker.SetBaseBranch itself. Unsafe values are rejected at the
service tier and silently downgraded to the no-override fallback inside
agentctl so a misbehaving caller can't half-break the tracker.

* fix(diff): address PR review blockers and CI lint

- Split UpdateRepositoryBaseBranch into validate/load/apply helpers to drop
  cyclomatic complexity below the cyclop limit that gated Run Backend Tests.
- Replace direct m.cfg.BaseBranches reads/writes with getBaseBranches /
  setBaseBranches under a dedicated RWMutex. UpdateBaseBranches used to
  write under repoTrackersMu while rescan + lazy-subpath callers read under
  workspaceTrackersMu, leaving an unsynchronized data race on the map.
- Drop the false 'PR target is auto-wired' claim from the MCP tool
  description — GitOperator.CreatePR takes baseBranch as an explicit arg
  and never reads the tracker's value.
- Picker no-op guard now compares against currentBase instead of storedBase
  so selecting the displayed fallback (legacy task before any picker
  change) is treated as no-op instead of triggering a spurious save.
- HTTP handler now maps non-validation service errors to 500 instead of
  400, classifying via the existing isValidationError helper plus the new
  unsafe-git-ref message.

* fix(diff): regex-based ref sanitiser at every git subprocess sink

CodeQL kept flagging workspace_git_cmd.go:43 even after SetBaseBranch
rejected unsafe values — the taint tracker doesn't follow sanitised stores
through struct fields, so it still saw user input flowing into
exec.Command("git", ..., baseBranch).

Switches IsSafeGitRef to a single regex check (a pattern CodeQL recognises
as a sanitiser barrier) and adds SanitizeGitRef calls inline at every
agentctl call site that splices a user-controlled ref into a git arg:

  - workspace_git_status.resolveStoredRef (before the rev-parse probes)
  - workspace_git_status.computeBaseCommit (before merge-base/rev-parse)
  - workspace_git_status.getAheadBehindCounts (before rev-list)
  - agentctl/server/api/git.go runGitLogForRepo + handleGitCumulativeDiff
    (before computeMergeBase/GetRevParse on the HTTP TargetBranch)

Behaviour is unchanged for valid refs — only narrows the allowlist (now
[A-Za-z0-9_./-]) so the sanitiser barrier is recognisable to static
analysis. SetBaseBranch's field-store sanitiser stays as defence in depth.

* fix(diff): transform sanitiser at every map read for CodeQL taint flow

CodeQL still flagged workspace_git_cmd.go:43 because:
- handleSetBaseBranches used an IsSafeGitRef bool guard before a verbatim
  map[k] = v copy. CodeQL recognises regex-transforming sanitisers but not
  bool-guarded passthroughs.
- lookupBaseBranch returned m.cfg.BaseBranches[k] verbatim. Even though
  the map was sanitised on write, taint flow across map stores + field
  stores loses the sanitised state in the analyser.

Switches both sites to the transforming SanitizeGitRef helper so every
read produces a freshly-sanitised value before it flows to a downstream
git argument. Behaviour is unchanged for valid refs.

* fix(diff): regex transformer sanitiser + guard live push from empty map

CodeQL still flagged the previous SanitizeGitRef because it returned the
input string unchanged on success — an identity passthrough that the taint
tracker doesn't recognise as a sanitiser barrier.

Switches to safeGitRefPattern.FindString so the output comes from the
regex match (not the parameter). IsSafeGitRef now wraps SanitizeGitRef so
the predicate and the transformer share one allowlist.

Also addresses two Claude review blockers:

- collectTaskBaseBranches returns a nil/empty map when the task has no
  recorded base_branches; pushing that to agentctl wipes every tracker's
  override. Now skipped — the DB row is the source of truth for the next
  session launch.

- applyBaseBranchSideEffects now runs on context.WithoutCancel so an HTTP
  / WS request cancel after the task_repositories UPDATE commits can't
  leave session.base_commit_sha stale + the live agentctl tracker map
  un-pushed, drifting from the persisted row.

* fix(diff): share securityutil.IsValidBranchName + harden error classification

CodeQL still flagged the picker's regex sanitiser as taint passthrough.
Refactors SanitizeGitRef / isSafeBaseBranchRef to delegate to the existing
securityutil.IsValidBranchName helper that agentctl's Rebase / Merge /
RenameBranch already use without being flagged. The shared allowlist also
fixes a Claude review blocker: 'feature/@2024' used to pass the service
validator but get dropped by the agentctl-side check at push time,
silently wiping every tracker's override.

Plus the two error-classification blockers Claude raised on the previous
HEAD:

- WS handler (task_ws_handlers.go wsUpdateTaskRepository) now distinguishes
  validation errors (surfaced verbatim) from internal faults (opaque
  500-style message). HTTP path already did this via the existing
  isValidationError helper.
- MCP handler (handlers.handleUpdateRepositoryBaseBranch) gets the same
  treatment via a new isValidationError local to the mcp/handlers
  package so raw service error strings stop leaking across the MCP
  boundary.

* fix(diff): inline securityutil.IsValidBranchName at every git subprocess sink

CodeQL's interprocedural taint tracking wasn't recognising SanitizeGitRef
as a sanitiser even after it delegated to securityutil.IsValidBranchName
— the wrapper helper + map/field hops between source and sink kept the
flow tainted in the analyser.

Inlines the IsValidBranchName allowlist check (already used inline by
Rebase/Merge in git.go without being flagged) at every call site that
splices a user-controlled ref into a git subprocess argument:

- workspace_git_status.resolveStoredRef (before rev-parse --verify)
- workspace_git_status.computeBaseCommit (before merge-base / rev-parse)
- workspace_git_status.getAheadBehindCounts (before rev-list)
- agentctl/server/api/git.go runGitLogForRepo + handleGitCumulativeDiff
- agentctl/server/api/workspace_base_branches.go handleSetBaseBranches

Behaviour unchanged: same allowlist as Rebase / Merge / RenameBranch
already use. SanitizeGitRef / IsSafeGitRef survive as defence-in-depth
helpers for callers that want a one-line check; they delegate to the same
securityutil helper so the rules stay aligned.

* fix(diff): inline regexp.MatchString at every git subprocess sink

CodeQL still flagged workspace_git_cmd.go:43 even with the inline
securityutil.IsValidBranchName helper — the taint tracker apparently does
not follow into helper functions for the go/command-injection rule on
this path.

Hoists the regex to package-scope vars (safeBranchRefPattern in both
process and api packages) and calls regexp.MatchString directly at each
sink site. Behaviour is identical to securityutil.IsValidBranchName; only
the call form changes.

* fix(diff): SHA-shape allowlist on BaseCommit before downstream git diff

CodeQL still flagged workspace_git_cmd.go:43 because update.BaseCommit
is the OUTPUT of an earlier merge-base / rev-parse call whose argument
list contained a user-controlled branch ref — the analyser treats that
command's output as still tainted even when the input ref was sanitised.

Adds an inline 40-hex SHA-shape allowlist check in enrichWithBranchDiff
right before `git diff --numstat <update.BaseCommit>` so the sanitiser
barrier sits in the same function as the next subprocess call. Behaviour
is unchanged: merge-base / rev-parse already produce SHA-shaped output.

* fix(diff): async tracker refresh + tighten init spread order

Two CodeRabbit/Claude review fixes:

- UpdateBaseBranches stamps each tracker's new baseBranch synchronously
  then runs RefreshGitStatus on a background goroutine instead of inside
  the picker-save HTTP handler. Per-tracker git subprocesses can each
  take ~3-5 calls; serialising them on the request goroutine made the
  PATCH response wait for every refresh to finish. The picker UI re-fetches
  via the existing WS stream once Refresh emits, so the handler doesn't
  need to block.

- updateTaskRepositoryBaseBranch now spreads options.init BEFORE the
  required method/body fields so an upstream caller's init overrides
  can't replace the PATCH method or the base_branch payload. Matches
  the convention deleteTask / moveTask already follow.

* fix(diff): narrow mcp isValidationError + opaque 500 + post-sanitize log count

Three final Claude review threads on the latest commit:

- mcp/handlers isValidationError drops the broad 'invalid' substring so
  DB / IO errors whose messages happen to contain 'invalid' don't
  misclassify as ErrorCodeValidation.

- task_http_handlers httpUpdateTaskRepository now logs the raw service
  error server-side and returns an opaque 'failed to update task
  repository' on the 500 path. Stops potential DB / IO detail leakage
  through the HTTP boundary.

- workspace_base_branches debug log reports accepted + rejected counts
  (post-sanitisation) instead of the pre-filter total so the metric
  actually reflects what hit UpdateBaseBranches.

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
2026-06-05 15:53:39 +01:00
João Salavisa fd79d6170f feat(tasks): multi-branch tasks — N (repo, branch) pairs per task (#1226)
* feat(tasks): multi-branch tasks — N (repo, branch) pairs per task

Extends multi-repo into multi-branch so a single task can hold multiple
worktrees on the same repo (one per branch). Sibling worktrees layout at
<task>/<repo>/ + <task>/<repo>-<slug>/, dedicated PRWatch per branch,
live agentctl tracker rescan + frontend store updates so the UI splits
PRs / files / commits per branch without a session restart. MCP
add_branch_to_task_kandev tool is shielded to the worktree executor.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(web): unify multi-branch repo label as <repo> · <branch> across panels

PR Changes and Commits panels rendered repo labels differently for multi-branch
tasks — Commits showed "kandev · feature/x" while PR Changes leaked the raw
"kandev-feature-x" agentctl tracker tag. Reformat tracker-tagged repo names via
formatRepoLabel using the longest known-repo prefix so kandev-cli-* doesn't
collapse to kandev · cli-*.

* fix(tasks): address multi-branch PR review — data races, slug collisions, MCP error codes

Backend
- agentctl process.Manager: add repoTrackersMu RWMutex guarding repoTrackers
  + workspaceTracker. Reads snapshot under RLock; RescanRepositories builds
  trackers first, takes Lock for swap/append. Validates newWorkDir via
  os.Stat before publishing cfg.WorkDir.
- worktree.Manager: reject invalid explicit BranchSlug up-front; cache key
  uses sanitized wt.BranchSlug so read/write paths are symmetric.
- worktree.store: narrow isDuplicateColumnError to "duplicate column name".
- task service AddBranchToTask: slug-collision guard rejects feat/a vs feat-a.
- orchestrator resolvePushRepo: two-pass exact-name match before subdir
  prefix fallback so "backend" doesn't swallow pushes for "backend-admin".
- executor buildRepoSpecs: duplicate repos always get a non-empty BranchSlug
  (falls back to branch-N when both branch fields sanitize to empty).
- MCP handler: classifyAddBranchError maps service errors to NOT_FOUND /
  CONFLICT / VALIDATION_ERROR; add ErrorCodeConflict constant.
- MCP server: extract registerAddBranchToTaskTool, register in task mode
  only (external mode contract: config tools + create_task_kandev).
- workspace_rescan handler: reject malformed JSON with 400 instead of
  silently rescanning the current root.
- branch_materializer: split MaterializeBranch into prepare + finalize
  helpers to satisfy the 80-line cap.

Frontend
- existingPrUrl returns undefined when a task has >1 PR so per-repo View PR
  buttons take over (no silent linking to a sibling).
- formatRepoLabel checks exact known-repo match before prefix splitting so
  "kandev-cli" isn't mislabeled as "kandev · cli".
- Repo chip key now includes base_branch so siblings differing only on
  base render as distinct chips.

Docs
- multi-branch spec uniqueness wording aligned with the canonical 4-column
  key (task_id, repository_id, base_branch, checkout_branch).
- worktree.CreateRequest.BranchSlug doc describes the sibling layout
  (<repo>-<slug>) instead of the obsolete nested form.

* fix(tasks): scope slug-collision check to repo only + use errors.Is for ErrTaskNotFound

- service_branches: drop base_branch from the slug-collision check. Worktree
  path is (repo, slug); two rows on the same repo cut from different bases
  still collide on disk.
- service_branches: prepareBranchAdd wraps taskrepo.ErrTaskNotFound via
  fmt.Errorf with %w so classifiers don't have to substring-match the
  formatted task UUID.
- mcp handlers: classifyAddBranchError uses errors.Is(err, ErrTaskNotFound).

* fix(agentctl): sanitize rescan work_dir before os.Stat to clear CodeQL path-injection

CodeQL flagged the rescan endpoint as a path-injection sink (the
HTTP-supplied work_dir flowed straight into os.Stat and downstream
scanRepositorySubdirs). Add validateRescanPath which:
- requires the input to be a clean absolute path with no ".." segments
- rejects anything that isn't equal to or an ancestor of the current
  cfg.WorkDir — the only legitimate use case is the branch materializer
  promoting <task>/<repo>/ to <task>/

Defense in depth: agentctl binds to localhost, but any colocated process
could otherwise direct it at /etc or the user's home by POSTing
/api/v1/workspace/rescan.

* fix(agentctl): tighten validateRescanPath, close TOCTOU on existingTrackers, add tests

- Drop the strings.Contains(clean, "..") guard. filepath.Clean already
  resolves traversal; the substring check rejects legitimate names like
  /home/u/..hidden without adding defense (the ancestor check via
  filepath.Rel is the real boundary).
- Drop the rel == "." branch in the ancestor check — early return for
  clean == currentClean already covers identity.
- Read existingTrackers inside the write-lock section (not under RLock
  then re-Lock) so two concurrent rescans can't both see 0 trackers and
  double-bootstrap the multi-repo set.
- Add table-driven TestValidateRescanPath covering rejection paths
  (relative, descendant, sibling, traversal-cleaned, empty input),
  legitimate ancestor promotion, exact match, no-anchor first-launch,
  and a /home/u/..hidden dotted-segment case.

* fix(agentctl): break taint flow into os.Stat — derive rescan path from cfg, not HTTP body

CodeQL's go/path-injection trace still flagged manager_rescan.go after the
prior sanitizer because the user-supplied newWorkDir flowed (after
validation) into os.Stat and then into cfg.WorkDir → tracker paths →
workspace_monitor os.Stat sinks.

Replace validateRescanPath with resolveRescanPath: it returns a path
DERIVED from currentWorkDir (no-op = currentClean, promotion =
filepath.Dir(currentClean)) and uses newPath only for comparison, never
as a source for the returned string. os.Stat now receives a path whose
provenance is trusted manager config.

Functionally the legitimate caller (branch materializer promoting
<task>/<repo>/ to <task>/) is unchanged.

* test(agentctl): make TestResolveRescanPath windows-portable

The Linux Backend Tests job passed but the Backend (windows) job rejected
every "absolute" case because filepath.IsAbs("/task/repo") is false on
Windows (no volume letter).

Build inputs via filepath.Join against absRoot() (filepath.Abs of "/" /
"\\") so the same table covers POSIX (`/...`) and Windows (`C:\...`)
without per-OS branches. Keep the traversal-after-Clean case POSIX-only
since filepath.Clean is the shared primitive and that input form needs a
volume to be considered absolute on Windows anyway.

* fix(agentctl): serialize RescanRepositories + re-check trackers under write lock

Two concurrent RescanRepositories calls could both observe an empty
tracker set (between the snapshot read and the bootstrap branch) and both
enter transitionToMultiRepoMode, leaking a duplicate bare-root tracker
and duplicate per-repo trackers. Same race in appendNewRepoTrackers would
double-append the same new child across both callers.

Fix:
- Add rescanMu sync.Mutex on Manager. Acquired for the full body of
  RescanRepositories so the snapshot → branch → mutate sequence is
  serialized. repoTrackersMu still guards individual field reads from
  unrelated goroutines (subscribe/unsubscribe/poll-mode).
- appendNewRepoTrackers re-checks membership inside the write-lock
  before appending and drops already-present trackers (Stop + detach
  outside the lock). Defense in depth.
- Test: add `dotted segment is legal as promotion` case so the
  ..hidden-segment promotion path stays exercised.

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 19:36:20 +01:00
Carlos Florêncio 3c325e9b54 fix(orchestrator): reconcile task state when user cancels turn (#1209) 2026-06-01 10:35:33 +01:00
Carlos Florêncio c6b3b77c3e fix(mcp): disambiguate message_task task-not-found vs no-session error (#1186) 2026-05-31 18:42:47 +01:00
Carlos Florêncio dea04b2bed fix(backend): close clarification overlay after agent MCP timeout and dedup retried questions (#1185) 2026-05-31 12:36:13 +01:00
Carlos Florêncio dcde3da74c feat: surface an inline notice when an agent turn produces no output (#1179) 2026-05-31 11:14:23 +01:00
Nicolas Lenepveu 4b05e9755d feat: throttle issue-watcher task fan-out (per-watcher cap) (#1113) 2026-05-30 23:03:27 +01:00
Carlos Florêncio 01d34faa09 fix: backfill task_sessions cost columns for legacy DBs (#1145) 2026-05-29 11:06:33 +01:00
João Salavisa a627957c5b refactor(task/repo): split sqlite/base.go and add session batch loader (#1058)
* refactor(task/repo): split sqlite/base.go (1486 lines) into schema/migrations/queries

base.go grew to 1.85x the 800-line lint threshold by mixing the Repository
struct, schema initialization, ALTER TABLE migrations, recreate-and-copy
table rebuilds, and historical backfills/heals. Split along those seams:

- base.go (58 lines) keeps only the Repository struct, NewWithDB,
  newRepository, Close, DB.
- base_schema.go owns the initSchema orchestrator, every init*Schema
  function, ensure*Indexes, ensureRunnerProjectionTables, and the
  plan-revision backfill that runs as part of initPlansSchema.
- base_migrations.go owns migrate*, recreateTable[Named], runMigrations,
  the backfillRow type, and every backfill*/heal* function. Verbatim
  function moves with no semantic changes.
- base_queries.go owns sqlLimitClause (still referenced by plan.go,
  document.go, message.go).

To keep golangci-lint (new: true) green after the move:
- initSchema's 17 if-return chain becomes a for-each-step loop so
  cyclomatic complexity drops from 19 to within the 15 limit.
- initInfraSchema and initSessionWorktreeSchema hoist their DDL strings
  into package-level consts (infraSchemaDDL, sessionWorktreeSchemaDDL)
  so each function body fits the 80-line funlen cap.

Step ordering in initSchema is preserved exactly (backfillTaskEnvironments
-> backfillTaskEnvironmentRepos -> healTaskEnvironmentWorkspacePaths ->
healDuplicateTaskEnvironments -> ensureTaskEnvironmentTaskUniqueIndex ->
healSessionTaskEnvironmentIDs) so existing prod DBs continue to migrate
correctly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(task/repo): add BatchGetSessionsByTaskIDs to eliminate task-list N+1

Adds a single repository method that returns every task_session for a list
of task IDs in one chunked IN (...) query, grouped by task_id. The
workspace task-list endpoint previously needed three batched calls
(GetPrimarySessionIDsByTaskIDs + GetSessionCountsByTaskIDs +
GetPrimarySessionInfoByTaskIDs); with this loader the handler can derive
primary session ID, session count, and per-task session info from a
single round trip.

- buildInPlaceholders and chunkIDs land in base_queries.go.
  sqliteMaxHostParams=500 keeps each chunk well below SQLite's
  compile-time SQLITE_MAX_VARIABLE_NUMBER (999 on older builds, 32766
  on newer).
- session.go gains BatchGetSessionsByTaskIDs, which reuses
  taskSessionSelectCols / scanTaskSessions / loadWorktreesBatch so the
  returned sessions are indistinguishable from ListTaskSessions output
  (worktrees populated, JSON snapshots decoded). Empty input
  short-circuits with a non-nil empty map.
- SessionRepository interface and Service.BatchGetSessionsForTasks
  expose the new method to the handler layer. The three existing
  *ByTaskIDs methods are left in place because service_events.go still
  uses them for single-task event publishing.
- mockRepository implementations updated in two test files so the
  packages still satisfy the interface.

TestBatchGetSessionsByTaskIDs covers the empty-input short-circuit, the
multi-task (3 x 2) case (primary derivation + no cross-task bleed), and
the asked-for-but-missing case (absent from result map).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* perf(task/handlers): use BatchGetSessionsForTasks in task-list endpoint

buildTaskDTOsWithSessionInfo previously issued three batched queries per
request (GetPrimarySessionIDsForTasks + GetSessionCountsForTasks +
GetPrimarySessionInfoForTasks). Each was already chunked by task_id,
but together they were three round trips for data that can be derived
from one. Collapse to a single BatchGetSessionsForTasks call and derive
primary session ID, count, and per-task primary info in memory.

The JSON response shape is unchanged: dto.FromTaskWithSessionInfo
still receives the same pointer-typed arguments, primarySessionID stays
nil when no primary exists, and sessionCount stays nil for tasks with
zero sessions (matches the existing pointer-when-present contract that
the frontend store hydration depends on).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(task/handlers): restore JOIN-derived executor type/name in task-list response

buildTaskDTOsWithSessionInfo regressed when it stopped calling
GetPrimarySessionInfoForTasks: extractSessionInfo reads
ExecutorSnapshot["executor_type"] and ExecutorSnapshot["executor_name"],
but those keys exist only on the synthetic snapshot built by the
GetPrimarySessionInfoByTaskIDs LEFT JOIN to executors. The persisted
ExecutorSnapshot JSON uses different keys (e.g. "type" — see
share/builder.go:96), so BatchGetSessionsForTasks alone always yielded
empty executor_type / executor_name fields in the task-list response,
breaking the frontend task card's executor badge.

Fix (caught in code review):
- Handler now calls both BatchGetSessionsForTasks (for primary session
  ID and session count) and GetPrimarySessionInfoForTasks (for the
  JOIN-derived executor type/name and other session info). Two queries
  instead of the original three; correctness preserved.
- New TestGetPrimarySessionInfoByTaskIDs_PopulatesExecutorJoinFields
  seeds an executor + primary session, then asserts the JOIN populates
  ExecutorSnapshot["executor_type"] / ["executor_name"] on the returned
  session. Locks in the contract the handler depends on so a future
  refactor that drops the JOIN fails loudly instead of silently
  emitting empty fields.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(task/repo): chunkIDs returns empty slice for empty input

chunkIDs previously returned [][]string{nil} for an empty/nil input
(a single chunk whose value is nil). The only current caller
(BatchGetSessionsByTaskIDs) short-circuits empty input before reaching
chunkIDs, so the bug stayed dormant — but any new caller that forgot
the short-circuit would feed a nil chunk to buildInPlaceholders (which
returns "" for nil), produce a `... WHERE task_id IN () ORDER BY ...`
query, and hit a SQL syntax error at runtime.

Make the helper safe by construction: an empty input returns nil
(empty outer slice), so `range chunkIDs(empty, n)` iterates zero
times. Add base_queries_test.go covering:

- Empty/nil input → no chunks (locks the safety contract).
- Single-pass input fits in one chunk.
- Over-threshold input splits into correctly-sized, in-order chunks.
- buildInPlaceholders normal + empty-input cases.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(task/repo): propagate non-ErrNoRows errors in backfillSingleTask executor lookup

`backfillSingleTask` looked up `executors.type` for the legacy executor
referenced by a session and defaulted to `"local_pc"` on any error.
That silently masked driver failures, schema mismatches, and corruption
— every backfilled task_environment would get the wrong executor_type
without surfacing the underlying problem.

Narrow the default to the legitimate case: only `sql.ErrNoRows` (the
executor row was actually deleted, e.g. legacy session whose executor
is gone) falls back to `"local_pc"`. Any other scan error now aborts
the migration with a wrapped error so operators see the real cause.

Adds TestBackfillSingleTask_DefaultsExecutorTypeOnMissingRow which
seeds a session referencing a non-existent executor id and asserts the
backfill still completes with `executor_type = local_pc` — locks in
the legitimate-fallback path.

Addresses PR #1058 review feedback from cubic & claude.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(task/repo): chunk ListWorktreesBySessionIDs to honor SQLite var limit

ListWorktreesBySessionIDs built its IN-clause from every input session ID
in one shot. BatchGetSessionsByTaskIDs chunks task IDs at 500 to stay
below SQLITE_MAX_VARIABLE_NUMBER (999 on older builds), but its
loadWorktreesBatch step then handed all sessions in that 500-task chunk
to ListWorktreesBySessionIDs un-chunked — so at modest workloads (500
tasks × ≥2 sessions each = 1000 placeholders) the query fails with
SQLITE_RANGE and bubbles up as a 404 on the task-list endpoint.

ListWorktreesBySessionIDs now applies the same chunkIDs / 500 cap that
BatchGet uses. Body extracted to appendWorktreesForSessionChunk so the
public method stays under the 80-line funlen cap.

New TestListWorktreesBySessionIDs_ChunksOverPlaceholderLimit feeds the
method 1001 non-existent session IDs and asserts it returns cleanly —
without chunking, this fails with SQLITE_RANGE on a default-built SQLite.

Also splits TestBatchGetSessionsByTaskIDs (was 98 lines) into focused
subtests + a shared seedBatchSessions helper, satisfying the test-size
convention. The three sub-scenarios (empty input, multi-task grouping,
missing-task absence) now fail in isolation.

Addresses PR #1058 review feedback from greptile (P1) and claude
(suggestion).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(task/repo): port repositories.copy_files migration to split files

Rebased onto main; main added copy_files to runMigrations() and
initTaskSchema() in base.go while our branch had split those into
base_migrations.go and base_schema.go. Cherry-pick both additions
into the correct split files so the schema is consistent.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 23:57:15 +01:00
Carlos Florêncio 905b9f9727 fix: improve UX for subtasks with repo setup scripts (#1105) 2026-05-28 16:07:51 +01:00
João Salavisa dcfec0d23c refactor(backend): classify errors via sentinels instead of substring matches (#1057)
* refactor(backend): classify errors via sentinels instead of substring matches

Introduce package-local sentinel errors in the github, task repo, agent
lifecycle, and office/skills packages. Producers wrap with fmt.Errorf("%w: ...")
so callers can classify via errors.Is/errors.As instead of brittle
strings.Contains(err.Error(), ...) checks that break on library upgrades.

Sentinels added (or relocated to errors.go):
- github: ErrInvalidToken (new); ErrInvalidPRURL, ErrTaskNotFound, ErrSelfApprove
  moved from service.go.
- task/repository/sqlite: ErrTaskNotFound, ErrOfficeSessionRaceConflict.
- lifecycle: ErrAgentAlreadyRunning, ErrAgentReported.
- office/skills: ErrSkillNotFound, ErrSkillFileNotFound.

Consumers updated to errors.Is/errors.As across orchestrator, task/handlers,
github, and office/skills. AssociateExistingPRByURL switches %v -> %w so the
inner parsePRURL error stays in the chain. process_handlers now uses
errors.Is(err, syscall.ECONNREFUSED) and lifecycle.ErrNoExecutionForSession
plus a small isTimeoutError helper backed by errors.As.

A new taskDeleterAdapter in cmd/kandev/turn_adapters.go translates
taskrepo.ErrTaskNotFound to github.ErrTaskNotFound at the wiring boundary,
avoiding a layering violation between the github and task packages.

The one remaining substring check, in CreateTaskSession, is encapsulated
inside the repo and converts the SQLite UNIQUE-violation message into the
typed ErrOfficeSessionRaceConflict sentinel so callers never see the driver
string.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(backend): add TestErrorsAreClassifiable for refactored packages

Pin the new sentinel-based contract for each refactored package. Each test
exercises the producer paths and asserts errors.Is reaches the sentinel
through the wrap chain, and (where applicable) confirms untyped lookalike
strings no longer classify.

- github: isTaskNotFound, AssociateExistingPRByURL/ErrInvalidPRURL,
  ConfigureToken/ErrInvalidToken (using a noop secret manager).
- task/repository/sqlite: GetTask, DeleteTask, UpdateTaskState wrap
  ErrTaskNotFound.
- task/handlers: isNotFound + isAgentReportedError follow the typed chain.
- orchestrator: each isXError classifies exact + wrapped sentinels, rejects
  untyped lookalikes.
- office/skills: readUserHomeSkillInventoryFile wraps ErrSkillFileNotFound;
  ErrSkillNotFound survives fmt.Errorf("%w", ...).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(github): drop network-hitting subtest from errors_test

The "ConfigureToken wraps ErrInvalidToken when validation fails" subtest
constructed a real PATClient and called GetAuthenticatedUser, which makes
a live HTTP request to api.github.com. That's flaky in CI / offline dev
and violates the unit-test boundary (caught in review).

Replace with a sentinel-survives-wrap-pattern check that locks in the
exact %w/%w shape used by ConfigureToken, plus an assertion that the
inner cause stays reachable through errors.Is. The full ConfigureToken
flow stays covered by service_token_test.go and integration tests.

Remove the now-unused noopSecretManager stub type.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* fix(task): restore "timed out" UX for plain-fmt.Errorf timeout producers

The original createPromptErrorMessage rendered the friendly "Request timed
out…" hint via strings.Contains(err.Error(), "timeout"). The refactor
replaced it with errors.Is(context.DeadlineExceeded) + a net.Error
Timeout() helper — strictly typed. But several upstream producers along
the prompt path (service.WaitForSessionReady, agent-stream connect waits
in lifecycle/session.go and manager_interaction.go, agentctl health waits
in agentctl/client.go and launcher.go, the orchestrator's own
waitForSessionReady) return plain fmt.Errorf("timeout …") with no typed
Timeout() method, so the typed check missed them and the user saw the
generic "Failed to send message to agent" instead.

Add a substring fallback inside isTimeoutError as a UX safety net while
keeping the primary typed paths intact. Lock the contract with table
tests covering all three classes (net.Error timeout, plain string,
context.DeadlineExceeded) so any future trim of the fallback breaks
loudly. Migrating each upstream producer to a typed sentinel is tracked
as out-of-scope follow-up.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* test(backend): tighten classification tests to exercise typed-sentinel contracts

Addresses bot-review feedback on PR #1057:

- task/handlers/errors_test.go: cover all four not-found sentinels
  (taskrepo.ErrTaskNotFound, service.ErrDocumentNotFound,
  service.ErrTaskPlanNotFound, service.ErrRevisionNotFound) in
  TestErrorsAreClassifiable via a table-driven loop, instead of only
  one. Drop the spurious Deadline() *time.Time method on timeoutNetErr
  — it isn't part of net.Error and the existing `var _ net.Error =
  timeoutNetErr{}` line already pins the interface.

- orchestrator/scheduler/scheduler_test.go: testTaskRepository was
  still returning the local scheduler.ErrTaskNotFound, which doesn't
  satisfy the production processTasks check (now errors.Is on
  taskrepo.ErrTaskNotFound). Mirror the real sqlite repo's wrap so the
  mock and prod paths agree. The local scheduler.ErrTaskNotFound is
  truly dead after that, so drop it from scheduler.go.

- office/skills: the wrap-pattern subtest in errors_test.go was only
  testing fmt.Errorf, not production code. Move the contract assertion
  to service_test.go (external skills_test package) where it drives
  the real GetSkillFromConfig lookup path against an empty in-memory
  repository.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* refactor(backend): drop redundant context.DeadlineExceeded check and fix doc grammar

Addresses bot-review feedback on PR #1057:

- task/handlers/message_handlers.go::createPromptErrorMessage: the
  explicit errors.Is(promptErr, context.DeadlineExceeded) is redundant
  with isTimeoutError, which already matches via errors.As against
  Timeout() — context.DeadlineExceeded implements Timeout()==true.
  Inline comment documents the consolidation.

- github/errors.go: fix grammar in the ErrTaskNotFound doc comment
  ("is the sentinel cleanup paths check for to distinguish" →
  "is the sentinel that cleanup paths check to distinguish").

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>
2026-05-26 13:07:46 +01:00
José Almeida 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 044b76bb taught to
  skip session-scoped keys when reusing a sibling session's metadata.
  A direct test now asserts the SSH PID/port/session-dir/forward-port/url
  keys are filtered out while host/port/user/fingerprint/task-dir keys
  still propagate, and that an explicit request value isn't overwritten.

* fix(ssh): rename agentctl_action=uploaded to needs_upload (test endpoint)

The /api/v1/ssh/test endpoint only probes whether the agentctl sha256
sidecar already lives on the remote — it never uploads. Reporting
agentctl_action="uploaded" in the not-cached branch said the opposite
of what the step Output line said ("will upload on first launch") and
mis-signaled real-upload status to any external tooling. Rename to
"needs_upload" so the contract matches the behavior.

* fix(ssh): add ssh_host_alias to trustedExecutorConfigKeys

ssh_host_alias steers ResolveSSHTarget through ~/.ssh/config and ends up
controlling the final Host/Port/User/IdentityFile. Leaving the key out
of trustedExecutorConfigKeys let request-supplied metadata override the
executor's configured alias and pivot the launch to a different host —
the same class of attack the existing entries (ssh_host, ssh_port, …)
guard against. Promote the literal to a constant and add it to the
trusted set.

* fix(ssh): emit 1/1 completion progress when the preparer step succeeds

The final reportProgress was passing (completed=0, total=1) right after
completeStepSuccess marked the step done, so UI subscribers saw the
preparer end at 0% completion. Report 1/1 to match the actual state.

* fix(web): preserve POST/Content-Type + read latest executors at write time

- testSSHConnection: caller's options.init was spread AFTER the required
  method/headers/body, so a caller passing init.headers (or method/body)
  would silently clobber Content-Type and break server-side parsing.
  Spread options first; let the required fields win, and merge headers
  on top of caller-supplied ones.
- useSaveExecutor: the fallback path in the catch arm operated on the
  executors slice captured at hook-creation time, so a WS update that
  landed mid-flight could be overwritten. Read the current snapshot via
  the store API at write time instead, matching what the create flow
  already does.

* fix(ssh): drop "in v1" from user-facing copy

These strings render in the New SSH Executor header, the Connection card
description, and the ProxyJump field hint. "in v1" reads like internal
planning leaking into the UI; the limitations themselves still hold so
just describe them directly.

* fix(ssh): auto-create a default profile on new SSH executor

The /settings/executors index renders entries by flattening to executor
profiles; an SSH executor created with no profiles was effectively
invisible there even though the executor itself existed. The non-SSH
create flow does this implicitly because it goes through the
create-profile form; the SSH-specific flow skipped it.

POST a default profile under the freshly-created executor (same name as
the executor, no overrides) right after createExecutor, then push the
combined record to the store so the index sees it on first paint.

* feat(ssh): pre-flight the agent binary on the remote before launch

Without this, a remote that lacks the agent's required binary (e.g.
\`npx\` for any node-based ACP agent) surfaced the failure deep inside
agentctl as an opaque \`exec: "npx": executable file not found in \$PATH\`
— delivered AFTER a successful "Starting agent controller" step, which
reads as "everything's fine right up until it isn't".

The new step runs right after agentctl upload: derive the binary from
\`agent.BuildCommand(Runtime=ssh).Args()[0]\`, \`command -v\` it on the
remote, and on miss return a structured error that names the agent,
points at the missing binary, and includes the install hint sourced
from \`agent.InstallScript()\`. The message lands on the prepare-step
output (so the UI shows it inline) and on the session's error_message
(so the orchestrator surfaces it on the task).

Coverage: unit test for the error formatter (pure function, no SSH
needed) + e2e regression that mv's mock-agent aside on the sshd
container and asserts the surfaced error mentions agent name, binary,
\$PATH, and the install hint.

* feat(ssh): surface host/workdir/agentctl in the task executor popover

Parity with the Docker and Sprites paths: when the user hovers the
Executor Settings button on a task, an SSH-backed environment now shows
the connection target (\`user@host[:port]\`), the remote task dir, a
compact \`pid → remote :port → local :port\` summary of the agentctl
process, the trimmed host-key fingerprint with copy, and a paste-ready
\`ssh ...\` command that mirrors how kandev itself connects.

Sourced via a parallel \`ssh\` block on
GET /api/v1/tasks/:id/environment/live (no schema migration needed) —
the service joins the task's latest ExecutorRunning row's metadata at
fetch time, the same metadata SSH executor writes in CreateInstance.
The probe is metadata-only on purpose: the popover polls every 3-7s
and a per-poll SSH dial would be both slow and a lot of TCP traffic;
live agentctl/connection health stays under the per-executor
GetRemoteStatus path.

shouldShowExecutorEnvironmentControls now allowlists \`ssh\` so the
button actually renders for SSH tasks (Docker/Sprites/Remote-Docker
were already in the allowlist; SSH was the only runtime that mounted
a TaskEnvironment without showing the popover).

Coverage: unit tests pinning buildSSHLiveStatus's projection of
ExecutorRunning.Metadata into the popover-shaped struct (strings,
string-encoded ints, native ints, empty, malformed) + e2e regression
that launches an SSH task, opens the popover, and asserts each row.

* feat(ssh): probe enabled agents on the remote from settings

Adds a "Remote agent readiness" panel on /settings/executors/ssh/:id so
users can diagnose "which agent CLIs are actually installed on this
host" before launching a task. The same diagnosis that previously
required a launch attempt + reading the pre-flight error now sits one
button-click away on the executor's settings page.

Backend:
- New endpoint \`POST /api/v1/ssh/executors/:id/probe-agents\` dials SSH
  to the configured host and walks \`registry.ListEnabled()\`. For each
  agent it derives the binary from \`BuildCommand(Runtime=ssh).Args()[0]\`
  and runs \`command -v <binary>\` via the new exported
  \`lifecycle.ProbeRemoteBinary\` helper. Returns rows with availability,
  the resolved path on hit, and the agent's \`InstallScript()\` as a
  copy-pasteable hint on miss.
- \`ssh.RegisterRoutes\` now accepts the task service (\`GetExecutor\`) and
  the agent registry; \`cmd/kandev/helpers.go\` wires both in.

Frontend:
- New \`SSHAgentReadinessCard\` mounted above the sessions card. Manual
  refresh only (each probe dials a fresh SSH connection — too expensive
  to poll). Each row gets a status badge + a copy button for the install
  hint when the agent is missing.

Coverage:
- E2E suite: API contract (mock-agent baked into /usr/local/bin/ → row
  reports installed, with /usr/local/bin/mock-agent as resolved_at;
  move it aside → row flips to missing with the install hint flowing
  through end-to-end) + UI contract (click "Probe agents", assert the
  table renders the row).

* feat(ssh): upload remote-credentials over SFTP at launch time

The SSH executor used to accept the \`remote_credentials\` profile field for
parity with Sprites/Docker but only logged a "not implemented" warning at
launch — anyone configuring Claude/Codex/Gemini auth against an SSH host had
no way to actually get the credentials onto the remote.

Ports the Sprites credential pipeline to SSH:
- \`uploadCredentials\` orchestrates the same four phases Sprites does:
  resolve auth-secrets into env vars, run env-credential setup scripts on
  the remote, detect a local \`gh auth token\` if \`gh_cli_token\` was
  selected, and SFTP the selected file-type credentials into the remote
  user's \$HOME.
- \`sshFileUploader\` satisfies \`lifecycle.FileUploader\` via SFTP with an
  \`mkdir -p\`-equivalent walk for parent dirs and \`chmod\` for mode bits.
- \`runAuthSetupScripts\` shells the script through \`runSSHCommand\` with the
  agent's env vars prepended inline (so the script can read e.g.
  \`CLAUDE_CODE_OAUTH_TOKEN\` to materialize \`~/.claude/.credentials.json\`).
- \`resolveRemoteAuthHomeDir\` probes \`printf %s "\$HOME"\` rather than
  reusing the task workdir — credentials need to land under the user's
  true home (\`~/.claude/...\`) even when \`workdir_root\` overrides things.

Wired into \`maybeUploadCredentials\` so a credential failure is logged as
a warning but doesn't abort the launch (consistent with Sprites).

Frontend: \`useRemoteExecutorFlags\` on the profile-edit page now includes
\`ssh\` in the \`isRemote\` set so \`RemoteCredentialsCard\` renders for SSH
profiles. Previously SSH profiles silently dropped any \`remote_credentials\`
selection because the card never rendered.

End-to-end credential upload still requires a non-mock agent enabled in
e2e mode (mock-agent's \`RemoteAuth\` is intentionally empty). Coverage:
unit tests pin the SFTP \`parentDir\` walk and the shell-quoted env-prefix
builder. Existing Sprites tests already exercise the shared
\`UploadCredentialFiles\` helper.

* fix(ssh): wrap remote commands in a login shell so nvm/asdf PATH applies

sshd's exec channel runs commands via a non-interactive non-login shell.
That doesn't source ~/.profile / ~/.bash_profile / ~/.bashrc, so any
binary added to PATH by nvm/asdf/brew/etc. is invisible to:

- the launch-time pre-flight probe (`command -v <agent-binary>`)
- the readiness panel's per-agent probe
- and the nohup'd agentctl process itself

End result: a user with `npx` installed through nvm sees 'X requires
npx on the remote' and can't launch any node-based ACP agent.

Fix: introduce `WrapLoginShell(shell, cmd)` that wraps cmd in
`${shell} -lc '<cmd>'` (with POSIX-safe single-quote escaping) and
default shell to bash when empty. Thread the configured shell through
ProbeRemoteBinary, startRemoteAgentctl, preflightAgentBinary,
runOneAuthSetupScript, and resolveRemoteAuthHomeDir. The shell is
read from per-profile metadata via the new MetadataKeySSHShell
('ssh_shell') key — flows in via the existing executor-config merge.
The probe handler also accepts an optional {shell} body so the UI can
re-probe under whichever shell the user picked.

Default keeps existing behavior for users who don't set a shell (we
just become a login bash instead of bare sh -c). Unit tests pin the
quote-escaping contract.

* feat(ssh): move readiness card to profile page + add shell picker and install button

Four UX changes that together make the SSH-profile setup flow feel right:

1. Save redirect after creating a new SSH executor now lands on the
   profile-edit page (`/settings/executors/{profileId}`) instead of
   the executor page. Profiles are where users actually configure how
   the host gets used.

2. `SSHAgentReadinessCard` relocates from the executor page to the
   profile-edit page (top, conditional on `executor.type === 'ssh'`).
   The host-level executor page keeps Connection + Sessions.

3. Card gains a shell selector that auto-detects available shells on
   the remote and persists into `profile.config.shell`. Changing the
   shell clears the cached probe so the user re-probes under the new
   shell — PATH depends on which init files get sourced.

4. Each missing-agent row gets an Install button that runs the agent's
   InstallScript via /install-agent (login-shell wrapped). Spinner
   replaces the button while it runs; on completion the card auto
   re-probes. No confirmation dialog — the button is the consent.

E2E: probe-shells reports bash+sh on the alpine image (zsh absent),
install endpoint runs mock-agent's deterministic InstallScript,
unknown agent_id is a 404, and the profile-page UI round-trip works
end-to-end.

* refactor(ssh): drop install-agent endpoint and Install button

Install scripts assume environments we don't control on the remote
(package managers, sudo, network access); the failure modes weren't
recoverable from the UI. Pulling the one-click install — copy-and-paste
hint stays.

Backend: delete POST /api/v1/ssh/executors/:id/install-agent and
everything that fed it (httpInstallAgent / installAgent /
runInstallScript / runSSHCommandShim / InstallAgentRequest|Response /
findAgentByID / joinOutputs / capBytes). No other caller of those
helpers.

Frontend: drop the Install button + spinner + collapsible output panel
from ReadinessRow. The 'Install hint' column reverts to copy-only —
same hint string sourced from agent.InstallScript(), users run it on
the remote themselves. Remove installSSHAgent / SSHInstallAgentRequest /
SSHInstallAgentResponse from the api client + types + e2e helpers.

Survives: probe + readiness panel, shell selector + /probe-shells
endpoint, login-shell wrap on every SSH command (the actual fix for
nvm/asdf users).

* fix(ssh): persist shell choice under the same key the launch path reads

Profile config saved the shell selection as 'shell' but the SSH
executor at launch time reads MetadataKeySSHShell which is 'ssh_shell'.
buildLaunchMetadata copies profile.config keys verbatim into
req.Metadata, so the mismatch meant sshShellFromMetadata always
returned empty and WrapLoginShell silently fell back to bash —
defeating the whole point of letting users pick zsh/sh/etc.

The probe path bypasses metadata (the UI passes shell directly in
the request body) which is why the readiness panel reported codex
as installed under the user's zsh while the launch then failed with
'codex requires npx on the remote' under bash.

Align both sides on 'ssh_shell'.

* fix(ssh): propagate profile ssh_shell into launch metadata

The settings UI saved the shell selection on profile.Config["ssh_shell"],
but applyProfile in executor_state.go only forwards an allow-listed set of
profile.Config keys (ssh_workdir_root, remote_credentials, etc.) into
launch metadata — ssh_shell wasn't on the list. The launch path's
sshShellFromMetadata therefore always returned empty and WrapLoginShell
silently fell back to bash, so a user who installed npx via zsh+nvm hit
'npx not found in $PATH' from the preflight while the readiness probe
(which takes shell straight from the request body) still showed the
agent as available.

Mirror the existing ssh_workdir_root propagation for ssh_shell and pin
both directions (present + absent) with a unit test on resolveExecutorConfig.

* fix(orchestrator): make profile.Config authoritative for ssh_workdir_root + ssh_shell

A task that supplied ssh_workdir_root or ssh_shell in its metadata won
when the profile had no value set — `applyProfile` only overwrote
metadata when profile.Config had a non-empty value, so the task-supplied
value squeezed through the empty-profile case. Both keys steer remote
behavior (workdir target, login shell that runs every command), so a
task author should never be able to redirect them.

Two things, bundled because they touch the same allow-list:

1. Replace the long if-chain in applyProfile with a small declarative
   set of tables (passthrough / rename / authoritative). Behavior for
   the existing keys (remote_credentials, git_user_*, image_tag, …) is
   unchanged.
2. Add a new authoritative tier — ssh_workdir_root + ssh_shell — whose
   profile value ALWAYS wins, including when empty. The reader-side
   helpers (SSHExecutor.workdirRoot, sshShellFromMetadata) already
   handle the empty fall-through to a default.

Pin both directions with subtests: authoritative keys clobber any
task value (empty profile included); ordinary passthrough keys keep
their existing "only set if non-empty" semantics so task-supplied
git_user_name still survives.

* refactor(ssh): pass remote workspace explicitly to createRemoteAgentInstance

startAndForwardAgentctl was briefly mutating the caller's *ExecutorCreateRequest
(overwriting req.WorkspacePath with the remote task dir, then restoring it
after the create-instance POST). That's exactly the boring-and-direct sin —
a callee reading from a struct the caller is mutating around the call.
Pass the remote workspace path as an argument so the dance disappears and
req stays read-only inside this leg.

* fix(ssh): surface SHA read errors instead of swallowing them as "not cached"

SSHCheckAgentctlCached treated any error from reading the remote
sha256 sidecar as "binary not on remote" by returning (false, nil).
A transient SSH transport failure therefore masqueraded as
"needs_upload" in the test endpoint's UI badge — the user retries
the test, gets the same misleading hint, and doesn't see that the
connection itself is flaky.

Append `|| true` to the remote cat so a missing file is a successful
empty-stdout read (the genuine "not cached" path), and surface a real
error to the caller for everything else.

* fix(ssh): handle int/int64/float64 metadata in session listing

intFromMetadata only accepted string values, which is what the SSH
executor writes via strconv.Itoa — but ExecutorRunning.Metadata
round-trips through JSON, and there's no guarantee a numeric value
isn't unmarshalled back as float64 somewhere along the way. Anything
that wasn't a string silently became 0 in the sessions endpoint,
making the SSHSessionsCard's "Remote agentctl port" / "Local fwd
port" columns render as 0 for any row that took a non-string path.

Match the smarter shape already used in service.mdInt
(internal/task/service/service_task_environments.go) so both readers
agree on the projection.

* refactor(e2e): drop duplicate SSH HTTP shapes module

api-client-ssh.ts was a literal copy of apps/web/lib/types/http-ssh.ts
with `Body` suffixed onto every type — 75 lines of pass-through that
buy nothing. The e2e ApiClient now imports the canonical shapes from
lib/types/http-ssh directly, drops the re-export pile at the bottom
of api-client.ts, and the duplicate module is deleted.

No spec referenced the *Body names; the grep is clean.

* fix(ssh): make Content-Type win over caller-supplied headers in ssh-api

In `{ "Content-Type": "application/json", ...headers }` the spread runs
last, so a caller passing `init.headers = { "Content-Type": "x" }` would
override the JSON content type and break server-side parsing — the exact
opposite of what the file's comment claimed. The outer `init` spread is
ordered correctly already; this fixes the inner `headers` literal in
the same direction (spread first, JSON literal last) across all three
SSH client functions. Per CodeRabbit thread.

* fix(ssh): distinguish 404 from server errors when resolving SSH executor

resolveSSHTarget collapsed every GetExecutor error into 404 — a transient
storage outage, timeout, or canceled context made it look like the
executor was deleted. Introduce models.ErrExecutorNotFound, wrap it in
the SQLite repo's sql.ErrNoRows case, and use errors.Is in the SSH
handler so only "row doesn't exist" maps to 404; everything else stays a
500 with the underlying error attached. Per CodeRabbit thread.

* fix(ssh): surface latestRunningForTask repo errors instead of swallowing them

latestRunningForTask returned (nil) on any error from ListTaskSessions or
GetExecutorRunningBySessionID. Transient storage failures rendered as
"no SSH live status" on every popover poll, indistinguishable from the
genuine "task has no running session yet" case. Return the error to the
caller; GetSSHLiveStatus now propagates it, and the handler already logs
+ omits the ssh block on a non-nil error so polling stays graceful.

A session without an ExecutorRunning row is the one expected miss — match
on models.ErrExecutorRunningNotFound and skip quietly. Per CodeRabbit
thread.

* fix(ssh): reset readiness card state when executorId changes

The mount effect only reset the sequence token, so switching executors
left the previous one's rows / error / hasProbed visible until the user
clicked Refresh — reads as "still loading" while actually showing stale
data. Clear setRows / setError / setHasProbed / setLoading in the same
effect so a fresh executor starts blank. Per CodeRabbit thread.

* fix(ssh): copy the full fingerprint, not the truncated display value

The popover renders fingerprints as a hash suffix (e.g. ...abcdef012345)
because the full SHA256 is verbose, but the copy button reused the
displayed string — users got an unusable truncated fingerprint instead
of the real one. Add an optional copyValue on FieldRow that overrides
the clipboard payload, and wire the fingerprint row to it. Per
CodeRabbit thread.

* fix(ssh): keep port forwarder alive on transient Accept errors

SSHPortForwarder.serve returned permanently on any non-orderly-close
Accept error — including transient ones like EMFILE (process FD table
exhausted) or ECONNABORTED. A single hiccup orphaned the forwarder, the
remote agentctl kept running, and the user's session went dark until the
backend was restarted.

Match net.ErrClosed explicitly for the orderly-close path; for anything
else, log a warning and continue the loop so the forwarder survives
recoverable per-accept faults. Per claude[bot] review thread.

* fix(ssh): guard ResumeRemoteInstance against duplicate attach

CreateInstance had an early-out for the case where a previous resume
already populated r.sessions[InstanceID], but ResumeRemoteInstance
itself didn't — a retry would dial a second SSH connection, start a
second forwarder, and overwrite the map entry. The original client +
forwarder leaked until the SSH idle timeout reaped them, and the agent
stream briefly bounced between the two.

Mirror the guard so the two entry points to r.sessions[InstanceID]
agree. Per claude[bot] review thread.

* fix(ssh): pipe auth-setup env vars via stdin to keep secrets out of cmdline

runOneAuthSetupScript inlined env vars as `KEY=value KEY=value <shell>
-lc '...'`. That string is what becomes argv of the outer bash invoked
by sshd, so on the remote host any process the same UID can read sees
the secrets in `ps aux` / `/proc/PID/cmdline` for the brief window the
setup script runs. Single-tenant deployments are unaffected in practice
but the leak is real on shared hosts.

Add a runSSHCommandStdin variant that feeds session.Stdin; replace the
inline env prefix with a stdin payload of `KEY='quoted'` lines sourced
by the wrapped shell under `set -a; . /dev/stdin; set +a`. Env never
appears on the remote shell's argv; it's only visible in /proc/PID/environ
of the setup process itself, which is the minimum surface for "pass an
env var to a remote process".

Replace buildSSHEnvPrefix with buildSSHEnvInitScript; update the test.
Per claude[bot] review thread (greptile P1 finding).
2026-05-25 10:04:40 +01:00
Carlos Florêncio adc13c6b5a feat: copy gitignored files from repo into new worktrees (#946) (#950) 2026-05-24 20:12:26 +01:00
Carlos Florêncio db3b6dfb15 feat(tasks): don't cascade archive/delete to subtasks by default (#1020) 2026-05-22 22:55:04 +01:00
Carlos Florêncio 2ca48fc7df feat(repositories): guard delete behind active-session check + UI fixes (#968) 2026-05-21 09:24:34 +01:00
irium 9fd789ba04 fix(executor): persist worktree subdir as workspace_path (#958) 2026-05-19 20:49:00 +01:00
Carlos Florêncio 59ea5b7106 fix: respect user-chosen agent on workflow step transitions (#941) 2026-05-18 23:18:57 +01:00
Carlos Florêncio f6bd83da33 refactor: tighten type system across backend + frontend (#920) 2026-05-17 20:16:15 +01:00
Carlos Florêncio 0b2085688b feat(office): autonomous agent management layer (#914) 2026-05-16 20:07:57 +01:00
Carlos Florêncio d48ee80472 fix(e2e): release agent ports during E2E reset to prevent shard exhaustion (#888) 2026-05-12 22:41:21 +01:00
João Salavisa 45a46f9899 feat: support tasks without a repository (#850)
* feat: support tasks without a repository

Lets users create a task with no repository attached — useful for research,
brainstorming, design discussions, or pointing the agent at an existing
non-git folder. The create-task dialog gains a 3-mode segmented control
(Repo / URL / None); "None" mode replaces the chip row with a folder
picker that browses the host filesystem via a new GET /api/v1/fs/list-dir
endpoint and either commits an absolute path or falls back to a kandev-
managed scratch workspace at <homeDir>/tasks/<workspaceID>/<taskID>/.

The worktree executor is hidden from the picker in no-repo mode (it
inherently needs a repo); the lifecycle manager also short-circuits
worktree mode server-side as a belt-and-suspenders guard. The Changes
panel auto-closes for repo-less tasks since it has nothing to show, and
the cumulative-diff handler returns null instead of erroring when no
base commit exists.

Spec + plan added at docs/specs/tasks-without-repositories/.

* fix(backend): anchor folder picker to discovery roots (CodeQL path traversal)

CodeQL flagged the new GET /api/v1/fs/list-dir handler for piping a
user-controlled path straight into os.Stat / os.ReadDir. Restrict the
listing to paths inside the configured discovery roots (defaults to
$HOME, same allowlist as repository discovery), reject anything outside
with ErrPathNotAllowed → HTTP 403, and clip the breadcrumb's "up"
button at the root so the picker can't be used to walk above the
allowed prefix.

Tests cover: rejection of paths outside roots, traversal escapes (../),
default-to-first-root, and parent exposure for nested paths.

* fix(backend): use os.Root for folder picker (recognized CodeQL sanitizer)

Previous attempt anchored to discovery roots via isPathAllowed, but
CodeQL's data flow doesn't model that helper as a sanitizer and kept
flagging os.Stat / os.ReadDir. Switch to os.Root (Go 1.24+): open the
matching discovery root once and run Stat/Open/ReadDir against the
relative path within it. The OS itself enforces containment — symlinks
and traversal cannot escape — and CodeQL recognizes os.Root as safe.

* fix: address PR review feedback

- Changes panel auto-close now distinguishes "task loading" from
  "task has no repos" (returns boolean | null), so the panel isn't
  permanently removed during the first-render window before task
  data lands in the store.
- Reset noRepository / workspacePath in resetDiscoveryState so
  reopening the dialog after a None-mode task doesn't carry the
  flag forward.
- Folder-picker breadcrumb: dropped dead `i > 0 && i > 1` condition
  (always equivalent to `i > 1`); chevrons now render between every
  segment as expected.
- list-dir HTTP handler returns a generic error message instead of
  forwarding raw OS errors that leak host paths.
- Document the dataDir → kandevHome inversion convention next to
  the scratch-path computation so future readers don't mistake the
  filepath.Dir call for a fragile derivation.

* fix: address CodeRabbit review nitpicks

- Trim whitespace on workspace_path before persisting to task metadata
  so a value of "   " doesn't get stored as a non-empty path.
- Extract the workspace_path metadata key into models.MetaKeyWorkspacePath
  alongside the existing MetaKeyAgentProfileID / MetaKeyExecutorProfileID
  so the set/read sites can't drift apart.
- Folder picker: reset cached listing when the popover closes, so
  reopening reloads from `value` instead of showing the last browsed
  directory the user navigated away from without committing.
- Submit payload: send `workspace_path: undefined` instead of an empty
  string when the trimmed value is empty, matching the noRepository=false
  case and keeping the API contract clear.

* fix: scratch path lands in kandev home, not its parent

Claude caught a real bug — `m.dataDir` is set from `cfg.ResolvedHomeDir()`
in cmd/kandev/agents.go, not `ResolvedDataDir()` (the field name is
misleading and the comment was wrong). My previous use of
`filepath.Dir(m.dataDir)` therefore walked one level above the kandev
home, so scratch dirs landed at e.g. `~/tasks/<ws>/<task>` instead of
`~/.kandev/tasks/<ws>/<task>` — and at the project root in dev mode
where `KANDEV_HOME_DIR=<project>/.kandev-dev`, which is what the user
saw on their machine.

Use `m.dataDir` directly (it's already the kandev home), document the
field's misnamed-but-correct semantics, and tighten the test fixtures.

Also:
- folder-picker trigger gets `cursor-pointer` (CodeRabbit nitpick).

* fix: pass workspacePath through handleCreateWithoutAgent too

The "create without agent" path called buildCreateTaskPayload without
the new workspacePath field, so picking a folder + clicking "Create
without starting" silently dropped the path. Now matches the
performCreate (with-agent) path.

* fix: satisfy DialogFormState in new-subtask form stub

PR #852 (merged after this branch was created) introduced
new-subtask-form-state.ts which stubs out a DialogFormState for the
subtask dialog. Adding noRepository / setNoRepository / workspacePath /
setWorkspacePath to the type made that stub incomplete, breaking the
Next.js production build (caught by CI, missed by tsc --noEmit).

Add the four inert fields to the stub, alongside the existing fresh-
branch inert fields, and pull both groups into named constants to
keep the useMemo body under the per-function line cap.

* fix: keep toggle-github-url testid as alias on URL-mode button

The new source-mode segmented control replaced the previous
"or paste a GitHub URL" text link, which had data-testid="toggle-github-url".
The existing e2e suite (create-task-github-url.spec.ts and the new
subtask.spec.ts from #852) still selects the URL toggle by that testid.
Keep "toggle-github-url" as the testid on the URL-mode button so both
suites pass without a sweep across all e2e files. Click semantics are
unchanged — single click enters URL mode.

* test(e2e): update toggle assertion for new source-mode segmented control

create-task-github-url.spec.ts:892 was checking the old text-link
toggle's two-way behavior with text labels ("or paste a GitHub URL" /
"use a workspace repository"). The new segmented control has explicit
mode buttons; assert aria-checked instead of label text. The other 9
clicks on `toggle-github-url` in this file still work via the legacy
testid alias on the URL-mode button.

* refactor: extract useActiveTaskHasRepos to its own hook file

dockview-desktop-layout.tsx grew to 609 lines with the new hook +
the Changes panel guard, over the 600-line cap documented in
CLAUDE.md (lint passed but the convention is real). Move the hook
to hooks/domains/kanban/use-active-task-has-repos.ts. File drops
to 590 lines.

* chore: gitignore .claude/scheduled_tasks.lock runtime file

* fix(web): lock chip-row min-height so modal doesn't shift on mode toggle

The chip row uses flex-wrap; Repo mode (chip + branch + add button +
segmented control) sometimes occupies a different vertical extent than
None mode (single folder picker + segmented control), causing the
create-task modal to jump a few pixels when toggling between sources.
Set min-h-9 so the row always reserves at least one chip-row of
height regardless of the active mode.

* feat: let folder picker browse anywhere, drop allowed-roots gate

The picker is for choosing a starting folder for a repo-less task —
users legitimately want /tmp, /var/log/foo, or any directory they have
read access to. Coupling it to the repo-discover endpoint's
discoveryRoots ($HOME by default) was too tight: paths like /tmp
returned 403 "path is not within allowed roots".

Decouple ListDirectory from discoveryRoots. The repo-discover endpoint
(DiscoverLocalRepositories, ValidateLocalRepositoryPath,
LocalRepositoryStatus, ListBranches) stays anchored to the allowlist
since picking a repo to import is a workspace-scoped operation; the
folder picker trusts the local-process boundary instead.

CodeQL is kept happy by routing the FS operations through os.Root
opened at "/" — the Go stdlib sanitizer for path injection still
recognises it (containment to / is trivially satisfied for every
absolute path on the host).

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
2026-05-12 01:10:22 +01:00
Carlos Florêncio 9ee42b53e3 fix(orchestrator): abandon orphan turns on session resume (#837) 2026-05-06 20:33:23 +01:00
José Almeida bce9fd266a feat: multi-question support for ask_user_question_kandev (#828)
* feat(backend): multi-question support for ask_user_question_kandev

Reshape the MCP tool from a single prompt+options pair to a questions[]
array (1-4 entries, single-choice each, custom text fallback). The
clarification HTTP handler gates the response until every question has an
answer, fans out one chat message per question (sharing pending_id, with
question_index/question_total in metadata), and returns a JSON map keyed
by question id back to the agent. The orchestrator resume prompt now
formats multi-line Q1/A1, Q2/A2 summaries when the bundle has more than
one question. Mock-agent gets a clarification-multi scenario and
ask_user_question_test.go covers the new schema + map response. No
back-compat shim — local-first tool, single-PR breaking change.

* feat(web): refresh clarification overlay with multi-question + modern UI

Each per-question chat message gets its own card with a "Question N of M"
progress chip, a ✓ Answered badge once committed, and shadcn-aligned
option buttons with kbd hints. A new useClarificationGroup hook coordinates
the per-question commits (decision A) and posts every answer in a single
batch once the user finishes the bundle; X rejects the whole group. The
single-question case keeps the 1-9 / Esc keyboard shortcuts. Playwright
e2e adds 6 multi-question scenarios (rendering, full happy path, partial
state, mixed option/custom, skip-rejects-bundle, sibling-state) on top of
the existing single-question regression suite.

* fix(web): clarification carousel UX with stepper navigation + race fix

Replaces the vertically stacked card layout with a horizontal stepper:
numbered circle dots up top show step state (active/answered/pending),
the single active question card sits in the middle, and Back / Next /
Submit buttons sit at the bottom. Picking an option auto-advances to the
next step (single-question case auto-submits, no explicit Submit button).
The stepper steps are clickable so users can revise prior answers.
Keyboard shortcuts: 1-9 for option pick, ArrowLeft/Right for navigation,
Esc for skip.

Also fixes a race that surfaced as a 400 BadRequest on the
/api/v1/clarification/:id/respond endpoint when the user clicked an
option before all N messages of a multi-question bundle had streamed in:
findPendingClarificationGroup now gates on metadata.question_total and
returns an empty array until the full bundle has arrived.

Playwright suite expanded from 6 to 12 multi-question scenarios:
stepper rendering, auto-advance, Back, stepper jump, Submit gating,
happy path, mixed custom-text, revise-via-jump, skip-from-mid-step,
number-key shortcuts, Esc skip, first-step Back disabled.

* fix(clarification): address CodeRabbit/Greptile/Claude review comments

Backend:
- Single source of truth for question validation: rename/export
  NormalizeAndValidateQuestions and call it from both the HTTP and the
  WebSocket-side MCP handlers, so duplicate question ids and bad option
  counts can't slip through either path.
- httpRespond now validates that every answer references a real question
  id from the bundle and that no id is answered twice (regression tests
  added).
- questionsFromMessages reorders by metadata.question_index so the
  reconstructed bundle preserves the original question order.
- adapters.CreateClarificationRequestMessages rolls back already-created
  messages when a later write in the bundle fails.
- extractQuestionAnswers always returns a JSON map (even on rejection,
  with an envelope { rejected, reject_reason }) and simplifyAnswer
  preserves both selected_option and custom_text when both are present.
- MCP tool description: rewrite the response example to match the actual
  output shape; add minItems / maxItems to the questions and options
  arrays in the JSON Schema.
- Test helper: drop the local contains() shim, use strings.Contains.

Frontend:
- useMemo([props]) was a no-op (props is always a fresh ref); split into
  [message, messages] so it actually memoizes.
- Drop the dead "expired" SubmitState branch — the backend never returns
  410 from this endpoint.
- Remove the asymmetric option-list dimming: the agent now receives both
  selected_option and custom_text together, so a half-grey state was
  misleading.
- New tests: pending-clarification.test.ts covers
  findPendingClarificationGroup including the question_total gate, and
  use-clarification-group.test.ts covers the hook's record/submit/skip
  flows and the override-merge path used by the single-question case.

* fix(web): treat clarification 409 as success so duplicate submit doesn't stick the overlay

* fix(clarification): unblock agent on persistence failure + nil-guard rejected branch

- Add Store.CancelRequest(pendingID) and call it from both the WebSocket
  MCP handler and the HTTP create-request handler when message creation
  fails. Without it, the agent's WaitForResponse parked for the full
  2-hour MCP timeout while no clarification cards ever reached the user.
  Both handlers now return an explicit error response on failure.
- applyAnswersToMessages now guards h.repo == nil in the rejected branch
  (parity with sibling expectedQuestionIDs).
- extractQuestionAnswers stubs for unanswered questions in a rejected
  bundle now carry { answered: false, rejected: true } so agents reading
  per-question payloads see the rejection without inspecting the
  envelope.
- Hook test: added 409-as-ok regression coverage for both submit and
  skip paths so the previous fix can't drift.
- Store test: covers CancelRequest unblocking a parked WaitForResponse.

* fix: drop unused e2e helper + use channel sync in CancelRequest test

* refactor(web): drop dead 'message' prop from clarification overlay

Both call sites pass 'messages' (the bundle group), so the legacy
single-message prop was unused. Tightening the prop type also lets the
useMemo depend on a single value.

* fix(web): ignore modifier-keyed events in clarification carousel shortcuts

Bare 1..9 / ArrowLeft / ArrowRight / Esc still drive the carousel, but
Cmd/Ctrl+1..9 (browser tab switch) and Alt+ArrowLeft (browser back) now
pass through as the user expects.
2026-05-05 19:14:57 +01:00
Carlos Florêncio 22c8baf015 fix(backend): correct task_environments migration order for older DBs (#811) 2026-05-04 09:34:17 +01:00
Carlos Florêncio cc934adb85 fix: make executors_running the single source of truth for agent_execution_id (#799) 2026-05-03 22:17:23 +01:00