Commit Graph

288 Commits

Author SHA1 Message Date
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
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
Nicolas Lenepveu 08189ec539 feat(integrations): add Sentry integration with issue watcher (#1133) 2026-06-04 10:18:17 +01:00
João Salavisa be9821fc55 fix(task): surface add_branch_to_task failures instead of silent orphans (#1264)
* fix(worktree): promote FallbackBaseBranch when BaseBranch is empty

CreateRequest.Validate previously hard-rejected an empty BaseBranch even
when the caller supplied a FallbackBaseBranch. The fallback is plumbed
specifically so a missing primary branch can degrade into the repo's
default — this lets that path actually fire.

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

* feat(task): probe provider default branch in add_branch resolve path

Adds an opt-in ProviderDefaultBranchProber seam (impl wired in cmd/kandev)
and threads a ResolveProviderDefaults flag through TaskRepositoryInput so
the add_branch_to_task GitHub-URL path can resolve a real default_branch
before writing the Repository row. Also backfills the existing repo's
default_branch when the probe succeeds and the workspace already had a row
with an empty value. The create_task path keeps its zero-default contract
(executor still backfills after clone).

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

* fix(task): surface add_branch_to_task failures instead of silent orphans

AddBranchToTask used to return a success envelope even when the
post-insert worktree materialize step failed on a live task. The
task_repositories row stayed in place with no on-disk worktree, and
provider-URL flows also left an orphan repositories row behind.

Three coordinated changes:
- Gate empty base_branch after resolution with a typed error so no row
  is written when nothing can be materialized.
- Track whether the repository row was created by this call and roll it
  back on any subsequent failure.
- materializeBranch now returns its error; on a live worktree-executor
  task, AddBranchToTask rolls back the task_repositories row and surfaces
  the failure. Pre-launch tasks keep the legacy best-effort path so the
  next session launch still materializes via the multi-repo prepare flow.

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

* fix(mcp): classify add_branch base_branch resolution failure as validation

AddBranchToTask now returns a "cannot resolve base_branch" error when the
provider repo's default branch could not be probed. Map it to
ErrorCodeValidation so MCP agents see a user-fixable input issue rather
than an opaque internal error.

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

* fix(task): defer task.updated until after worktree materializes

Publishing task.updated before materializeBranch ran meant a failed
materialize on a live task would push the new task_repository row to WS
clients and then silently roll it back, leaving stale UI state until the
next poll. Move the event after materialize success so the bus only sees
durable rows. Test now also asserts no task.updated fires on the rollback
path. Renames stubProber.lastProvid to lastProviderArg for clarity.

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

* fix(task): emit repository.deleted on add_branch orphan rollback

Cleanup callback previously called repoEntities.DeleteRepository directly,
which soft-deletes the row but bypasses Service.DeleteRepository's event
publish. The create path emitted repository.created, so skipping the
delete event left WS subscribers and frontend caches holding a phantom
row after a resolve-then-fail sequence. Route through the service method
so the events stay symmetric. Test now asserts the symmetry.

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

* fix(task): skip provider probe when workspace already has the repo

resolveRepoInput's add_branch probe fired on every add_branch_to_task
call with a github_url, even when the workspace already had the
provider repo registered with a non-empty default_branch. The existing
row's value would win seconds later via the repo.DefaultBranch fallback,
so the remote git ls-remote round-trip was pure waste — and unnecessarily
hit network on every call. Peek at the workspace registry first and
short-circuit when a usable default_branch is already on file.

Also adds a QA-tier test file covering probe-error fallthrough, bad URL
rejection, registered-repo probe skipping, empty-default backfill, and
the repository_id fast path.

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

* fix(task): detach rollback paths from caller ctx, log probe-lookup errors

Two adjacent robustness fixes flagged on PR #1264:

1. resolveRepoInput silently discarded the GetRepositoryByProviderInfo
   error before the provider probe. A transient DB failure was
   indistinguishable from a clean "not found" lookup, so the code would
   pay for a network probe instead of failing fast. Surface the error in
   logs and skip the probe — FindOrCreateRepository below already hits
   the same DB and will return the real cause.

2. The repository-create cleanup callback in resolveBranchAddRepoRef and
   the task_repositories rollback in AddBranchToTask both captured the
   caller ctx for their DELETE queries. Rollback often runs precisely
   because that ctx was cancelled (timeout, client disconnect), and
   reusing it would kill the cleanup and orphan the row we just created.
   Use context.WithoutCancel so the rollback survives ctx death.

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

* fix(task): close orphan-row gaps from caller cancel and concurrent create

Two findings from Claude's review on PR #1264:

1. (blocker) materializeBranch called taskAlreadyLaunched(ctx, ...) after
   MaterializeBranch returned, using the same caller context that often
   failed the materialize in the first place. A cancelled ctx made the
   post-failure DB lookup return (nil, err), taskAlreadyLaunched returned
   false, and the live-task rollback path silently demoted itself to the
   pre-launch best-effort branch — leaving the orphan task_repositories
   row the PR exists to prevent. Snapshot the launch state BEFORE
   MaterializeBranch (launch state can't change during the synchronous
   call) so the rollback decision is anchored to a live-ctx answer.

2. (suggestion) resolveBranchAddRepoRef inferred "this call created the
   repo" by comparing the resolved ID against a workspace ListRepositories
   snapshot taken before FindOrCreateRepository. A concurrent add_branch
   for the same GitHub URL could create the row between snapshot and
   lookup; the second request would treat it as "not in my snapshot, must
   be mine" and register cleanup against the other request's data. On any
   later failure, cleanup would DELETE a repository this call didn't
   create and emit a spurious repository.deleted event.

   Thread an explicit `created bool` out of CreateRepository through
   FindOrCreateRepository → resolveRepoInput → ResolveRepositoryRef so
   the cleanup callback fires only when this call physically inserted
   the row. The flag is set at the CreateRepository / FindOrCreateRepository
   sites themselves and cannot be spoofed by concurrent activity.

resolveRepoInput's branches were extracted into resolveRepoInputGitHub /
resolveRepoInputLocal helpers to keep the parent function inside the
cyclomatic-complexity and cognitive-complexity budgets after the signature
change.

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

* chore: retrigger cubic CI

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-03 15:43:41 +01:00
Nicolas Lenepveu 72e3a49718 fix(watcher): self-heal orphaned watchers when agent profile is soft-deleted (#1094) 2026-06-02 23:17:56 +01:00
João Salavisa dfaab11959 feat: full GitLab integration — parity with GitHub (#1120)
* feat(backend): full GitLab parity — store, service, poller, WS, presets

Add the watcher/poll/write-action surface on top of the existing GitLab
client + service skeleton:

* 5 new SQLite tables (mr_watches, review_watches, review_mr_tasks,
  issue_watches, issue_watch_tasks, action_presets) + CRUD
* Client + Service: MergeMR, GetProjectMergeMethods, GetProtectedBranch,
  ListUserProjects, SearchProjects, SetMRLabels, SetMRAssignees
* Service: review/issue/MR watch CRUD + Check/Trigger, GetStats,
  CleanupAllReview/IssueTasks (cleanup-policy aware), TriggerMRSync,
  action-presets CRUD with defaults
* Background poller (MR / review / issue loops) wired from main.go
* ~35 GitLab WS action constants + handlers.go registrations
* Mock controller for E2E seeding (MRs, issues, pipelines, discussions,
  approvals, branches)
* eventBus + taskDeleter + taskSessionChecker plumbed through Service
* Event types: GitLabMRFeedback / NewReviewMR / NewIssue / TaskMRUpdated

* feat(web): GitLab frontend parity — types, API, store slice, domain hooks

Extend the frontend GitLab integration on top of the new backend surface:

* HTTP API client expansion (gitlab-api.ts): full CRUD for review/issue/MR
  watches, MR write actions (merge/approve/labels/assignees), MR
  files/commits/feedback, action presets, projects autocomplete, stats
* Store slice expansion: review/issue/MR watches lists, action presets
  by workspace, stats + status with loading flags. Properties prefixed
  with gitlab to avoid collision with GitHub slice
* Domain hooks: use-gitlab-status, use-gitlab-stats,
  use-gitlab-review-watches, use-gitlab-issue-watches,
  use-gitlab-action-presets — mirror github hook shapes
* Type model: ReviewWatch, IssueWatch, MRWatch, ActionPresets, Stats,
  ProjectMergeMethods, MRApproval, Pipeline, MRFeedback, etc.
* Backend controller HTTP routes for watches/presets/write-actions/
  projects/stats so the frontend HTTP API client has endpoints to hit

All passes: backend (build + vet + lint + tests), web (typecheck + lint +
2400 tests).

* fix(gitlab): wire orchestrator event handlers, fix label-filter drop, add tests

Address Claude review blockers on the parity PR:

1. Wire orchestrator subscriptions for GitLab review/issue watch events.
   Add event_handlers_gitlab.go with handleGitLabNewReviewMR /
   handleGitLabNewIssue, dedup reservation handshake, and task-creator
   interfaces (mirrors event_handlers_github.go). main.go now calls
   orchestratorSvc.SetGitLabService so the dedup APIs are usable
2. Fix silent label-filter drop: when a custom_query is set on an issue
   watch, labels were being appended to the unused filter arg. Fold
   labels into customQuery (or default filter) so they actually reach
   GitLab; extracted appendLabelsToQuery helper with explicit handling
   of pre-existing labels= clauses
3. Switch event payloads to pointer types (mirrors GitHub) so
   handlers can type-assert pointer events
4. Backend tests: store_watches_test.go (CRUD + reserve/assign for all
   4 new tables + presets), service_watches_test.go (label-filter
   helper, cleanup policy, project normalisation), action_presets_test.go
5. Frontend slice tests: review/issue watch CRUD round-trips, action
   presets + stats reducer coverage

All green: backend (build+vet+lint+tests), web (typecheck+lint+2403 tests).

* fix(gitlab): address remaining blockers — cleanup wiring, safe defaults, nil guards

Address the second round of Claude review blockers:

1. main.go: wire SetTaskDeleter + SetTaskSessionChecker on services.GitLab
   (mirrors GitHub). Without these, manual cleanup sweeps would always
   error with "task deleter not configured"
2. service_cleanup.go: default cleanup policy on transient DB error is now
   CleanupPolicyNever (preserve tasks) instead of CleanupPolicyAuto
   (silently delete). Genuine "watch was deleted" path also falls under
   the safe-side default; user can manually delete the orphan tasks
3. service_watches.go: add nil guards on requireStore() so the 6 MR-watch
   list/get/delete methods return an errStoreUnavailable error instead
   of panicking when the SQLite store failed to construct at boot
4. use-gitlab-status.ts + use-gitlab-stats.ts: add per-mount attemptedRef
   so an unreachable GitLab doesn't trigger an infinite re-fetch loop —
   the previous useEffect re-ran every render because the failure path
   left status null, satisfying the !status guard

* fix(gitlab): propagate session-check errors, clamp poll interval on update

Address remaining Claude blockers:

* service_cleanup.go: HasUserAuthoredMessage transient errors are now
  preserved (return false → skip delete) instead of silently ignored;
  the alternative was occasionally deleting tasks a user had touched
* service_watches.go: extract clampPollInterval helper applying the same
  bounds (0 → default, <60 → 60) as the create path; both
  applyReviewWatchPatch and applyIssueWatchPatch use it so user-supplied
  zero or tiny values via UpdateXxxWatchRequest no longer hammer GitLab

* fix(gitlab): clear dupl lint, fix preset retry loop + per-workspace watch cache

* service_watches.go: applyReviewWatchPatch / applyIssueWatchPatch get
  nolint:dupl markers — they share shape but per-domain field validation
  lives in the create paths, so deduplicating via generics would obscure
  the contract. Restores backend lint to 0 issues
* use-gitlab-action-presets.ts: per-workspace attemptedRef set so a
  failing preset fetch doesn't retry on every render
* use-gitlab-review-watches.ts + use-gitlab-issue-watches.ts: track
  lastFetchedRef per consumer so a workspace switch triggers a refetch.
  The slice-level loaded flag is shared across instances and can't
  double as a per-workspace cache key

* fix(gitlab): add nil-store guards across review/issue watch + cleanup paths

Round-3 Claude finding: ReviewWatch / IssueWatch / preset / reservation
methods still called s.requireStore().X() directly. If NewStore fails at
boot (table migration error), the service struct's store is nil and every
list/get/update/create/reserve method panics on first request.

Apply the same store-nil → errStoreUnavailable pattern from f95a929 to
every remaining method:
* CreateReviewWatch / CreateIssueWatch
* GetReviewWatch / ListReviewWatches / ListAllReviewWatches /
  UpdateReviewWatch / TriggerReviewWatchAll
* GetIssueWatch / ListIssueWatches / ListAllIssueWatches /
  UpdateIssueWatch / TriggerIssueWatchAll
* ReserveReviewMRTask / AssignReviewMRTaskID / ReleaseReviewMRTask
* ReserveIssueWatchTask / AssignIssueWatchTaskID / ReleaseIssueWatchTask
* lookupReviewPolicy / lookupIssuePolicy (return CleanupPolicyNever)

* fix(gitlab): nil-store guards on CheckXxxWatch + DeleteXxxWatch + cleanup paths

Round-4 Claude finding: 4 remaining panic sites the previous nil-guard
pass missed — these run from the background poller (every 5 min) and the
manual delete/cleanup flows, so a boot-time NewStore failure would
crash the orchestrator process on first tick.

* service_watches.go: add store-nil guards to DeleteReviewWatch /
  DeleteIssueWatch / CheckReviewWatch / CheckIssueWatch
* service_cleanup.go: guard CleanupAllReviewTasks / CleanupAllIssueTasks
  entry points; wrap the trailing DeleteReviewMRTask / DeleteIssueWatchTask
  calls in `if store := s.requireStore(); store != nil` blocks so the
  cleanup succeeds even when the dedup-row delete is unreachable

* refactor(gitlab): split service_watches.go to stay under 800-line revive limit

CI's --new-from-rev lint caught service_watches.go at 808 lines (limit
800). Split into three focused files:

* service_events.go (94 LOC) — publish helpers for MR feedback /
  new review MR / new issue / watch lifecycle events
* service_issue_watches.go (302 LOC) — Issue watch CRUD + Check +
  Trigger + fetch + helpers
* service_reservations.go (59 LOC) — Reserve/Assign/Release dedup
  handles used by the orchestrator event handlers

service_watches.go is now 505 lines and contains MR watch + Review
watch only. No behavioral change.

* fix(gitlab): address inline code-review feedback

Address actionable inline comments from Claude/CodeRabbit/cubic/Greptile:

* appendLabelsToQuery: use url.ParseQuery for exact key match instead
  of strings.Contains; previously false-matched keys like mylabels=
  and silently dropped the watch's labels (test added)
* fetchReviewMRs: drop dead `filter = watch.CustomQuery` assignment —
  SearchMRs's buildMRSearchQuery returns customQuery verbatim and
  ignores filter when customQuery is non-empty
* store.go: add workspace_id indexes on gitlab_review_watches and
  gitlab_issue_watches (the 5-min poller and HTTP list endpoints did
  full table scans)
* mock_client.SearchProjects: switch from == to case-insensitive
  Contains so partial-query autocomplete returns the seeded project,
  matching the doc comment
* Poller: add sync.Mutex to guard `started` field; Start/Stop are
  now safe to call concurrently (go test -race would have flagged
  the previous read/write race)
* Add ErrWatchNotFound sentinel; controller_watches.go maps it to
  HTTP 404 via httpRespondError helper. Update/Trigger handlers
  previously returned 500 for missing watches, hiding the distinction
  from real server faults
* service_events.go: log Publish errors on watch lifecycle events
  instead of swallowing them silently
* handlers.go: reject malformed JSON payloads in list-style WS
  handlers rather than silently falling through to broader
  list/search behavior
* event_handlers_gitlab.go: remove dead gitlabRepoSlug helper +
  blank-identifier suppression

* ci: re-trigger to clear flaky E2E shard 3

* fix(gitlab): address final review round — nil guards, store merge, logs, body validation

- CheckMRWatch, UpdateReviewWatch, UpdateIssueWatch: nil guards for store/req
- handlers: wsNewDiscussionNote rejects empty body
- action_presets: Update reads raw stored row (no default freezing);
  Reset validates workspace_id
- service_search.GetStats: log Warn per sub-call failure before zero
- event_handlers_gitlab: log Release* dedup-row errors with context
- web/store.ts: post-slice merge restores every GitLab sub-state field;
  extract buildStateOverrides helper
- web/default-state.ts: add GitLab fields to defaultState +
  mergeInitialState via mergeGitLabFields helper
- web/gitlab-api.ts: UpdateXxxWatchRequest now Omit workspace_id

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-06-02 22:10:07 +01:00
Carlos Florêncio 2f12c680b8 feat(mcp): include associated PRs in task-listing tools (#1236) 2026-06-02 16:46:50 +01:00
João Salavisa cb7b5429d5 fix(tasks): harden multi-branch add_branch and surface it in UI (#1239)
Three areas were broken in the MCP add_branch_to_task flow:

1. Predictable branch names. autoBranchName counted up from existing
   branch-N rows. Concurrent add_branch calls raced onto the same name
   and the worktree directory leaked ordering. Now uses a random
   branch-<3 random chars> suffix via worktree.SmallSuffix.

2. Sibling materialize did not flow to the UI. The materializer
   published AgentctlReady with the NEW sibling worktree's path, which
   the frontend handler wrote onto the session row — clobbering the
   primary's worktree_id/branch and pointing the file browser at the
   sibling instead of the task root. Added TaskWorkspacePath to the
   payload, detected the sibling case on the frontend (existing
   worktree_id != payload's), kept the primary's id/branch, repointed
   worktree_path to the task root, bumped commits refetch, and dropped
   the pre-multi-repo legacy git-status entry so the Changes panel
   stops surfacing the frozen single-repo snapshot. File browser's
   resetKey now includes the worktree count so the tree reloads when
   the sibling lands.

3. workspace_path promotion + agentctl rescan refused to act on
   historical envs. A few envs had workspace_path pointing at the
   source repo's local_path (orchestrator computeWorkspacePath fallback
   to req.RepositoryPath when resp.WorktreePath is empty) instead of
   the primary worktree. The materializer's promotion gate compared
   against env.WorkspacePath's parent, so those envs were never
   promoted, and agentctl's resolveRescanPath only accepted
   parent-of-current so the rescan to the real task root was rejected.
   Trackers stayed on the wrong directory and the sibling's commits
   never appeared in the Commits panel even after a full reload.
   Anchored promotion on env.TaskDirName (basename cross-check) and
   added a recovery branch to resolveRescanPath that accepts any
   absolute directory which actually contains git-repo subdirs — the
   bearer-token middleware already gates the endpoint and
   scanRepositorySubdirs validates the children, so the relaxed check
   stays safe.

Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-02 00:14:00 +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 5591cdc0bf fix(web): add button to load older chat messages reliably (#1223) 2026-06-01 19:12:49 +01:00
Carlos Florêncio 1eb62f2810 docs(agentctl): clarify default_child_ordering is not enforced outside office (#1214) 2026-06-01 18:07:06 +01:00
João Salavisa 1fdeda904b feat: add voice mode for chat input (#1159)
* feat: add voice mode for chat input

Adds a voice input button to the chat composer with three transcription engines:
in-browser Web Speech API, in-browser Whisper via @huggingface/transformers, and
a server-side OpenAI Whisper fallback gated on KANDEV_VOICE_OPENAI_API_KEY.

User-configurable from a new /settings/voice-mode page:
- Master on/off toggle (defaults to on; opt-out, not opt-in)
- Engine preference (auto / Web Speech / Whisper Web / Whisper Server)
- Language hint (auto-detect or BCP-47 pinned)
- Activation mode (click-to-toggle or hold-to-talk)
- Auto-send after transcription
- Whisper Web model size (tiny / base / small)
- Editable Cmd+Shift+M keyboard shortcut

Voice settings live under user_settings.voice_mode as a nested JSON object —
no schema migration. Setting changes broadcast via the existing
user.settings.updated WS event so any open chat tab updates live.

The mic button is rendered on the right of the composer alongside the send
button (round primary fill; destructive fill with pulse ring while recording).
On browsers without a usable engine (e.g. iOS Chrome over HTTP) the button
stays visible as a muted greyed icon — tapping it surfaces a toast with the
reason (typically a missing secure context).

Whisper Web runs in a Web Worker with fp16 decoder weights (avoids the
MatMulNBits scale-missing crash that q4/q8 hit on WASM). Models are pulled
from the onnx-community/* mirror and cached by transformers.js in the Cache
Storage on first use.

* fix: address voice mode PR review feedback and E2E selector clash

Bot-review fixes from #1159:
- Map http.MaxBytesError from c.FormFile to 413 instead of bare 400.
- Strip BCP-47 region suffix before passing language hint to Whisper Web.
- Recover from worker crash: terminate + null worker + reset ready flag so
  next init/transcribe creates a fresh worker instead of hanging.
- Read live store state via storeApi.getState() in voice-mode settings
  savers to avoid stale-closure clobbering concurrent settings updates.
- Detach Web Speech callbacks before abort() so trailing onend/onerror
  events don't mutate hook state after cancel().
- Spread caller init before method/body in voice-api fetch so a caller-
  supplied init can't override the multipart POST.
- Replace t.Fatalf with t.Errorf+return inside test HTTP handler goroutine
  (FailNow from non-test goroutine is unsafe).
- Add Enabled/AutoSend zeroing assertions in partial-update test so the
  bool-overwrite-on-PATCH behavior is locked in explicitly.

E2E fix:
- toolbar-overflow spec used button.rounded-full which now matches both
  the send and voice buttons (strict-mode violation). Switched to the
  submit-message-button testid for an unambiguous locator.

* fix: scope voice settings rollback + whisper worker error to changed keys

Round 2 of PR review feedback on #1159:
- voice-mode settings rollback now restores only the keys patched by the
  failing request (not the whole snapshot), preserving concurrent edits
  to unrelated voiceMode fields.
- Shortcut rollback diffs previous vs next and reverts only the changed
  keys (restore prior value or delete if absent), preserving other
  concurrent shortcut edits.
- Whisper worker error handler captures the worker reference at attach
  time and only clears refs when this.worker still matches it — a stale
  error from a previously replaced worker no longer nukes the active one.
- Update stale whisper-web-models JSDoc that still pointed at Xenova
  mirrors.

* fix: voice mode progress scale, hold-mode race, race-aware rollback

Round 3 of PR review feedback on #1159:
- Normalise Whisper progress to 0–1 in ensureWhisperClient (transformers.js
  emits 0–100, the button's display + the `ready: 1` convention expect
  0–1 — fixes the 5000% mid-download display).
- Claim driverRef synchronously at the top of finishCapture so concurrent
  pointerup + pointerleave invocations early-return instead of racing and
  clobbering a freshly-started recording's ref.
- Race-aware rollback: voice-mode and shortcut catch branches now skip
  reverting a key when latest[key] !== next[key]. A failed earlier request
  no longer overwrites a later successful save to the same key.
- Update the Worker wire-protocol comment to show onnx-community/whisper-base
  instead of the stale Xenova/* example.

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
2026-06-01 14:32:39 +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 6734b64b54 fix(e2e): ignore stale session state snapshots blocking idle input (#1208) 2026-06-01 10:35:07 +01:00
Carlos Florêncio 151bf54dec feat(health): add OS inotify resource limits health check (#1195) 2026-05-31 17:54:49 +01:00
Carlos Florêncio d1c73d2f2c feat: surface subagent task tool calls as cards in task chat (#1132) 2026-05-31 17:53:21 +01:00
Carlos Florêncio af3b26b6d9 fix: track OS PIDs and clean up shells on task archive/delete (#1191) 2026-05-31 14:38:52 +01:00
Carlos Florêncio 86f756e280 feat(workflow): add set_session_mode action and preserve mode across reset (#1188) 2026-05-31 14:36:12 +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
José Almeida 137336de86 fix: manually drain queued chat messages after cancel (#1166)
* fix: drain queued chat messages after cancel

* fix: address queue drain review feedback

* fix: keep queued messages parked after cancel

* fix: stabilize queue drain errors

* fix: keep queue parked during cancel events

* fix: wait for lifecycle stream goroutines

* fix: address lifecycle stream review feedback
2026-05-31 11:58:01 +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
Carlos Florêncio 1fa0c340d8 refactor: move workspace policy params from MCP to agentctl CLI (#1181) 2026-05-31 11:12:36 +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 ec289c799b feat: retry transient provider 529 errors with visible backoff (#1173) 2026-05-30 19:16:49 +01:00
Carlos Florêncio 4da076c43e feat(backend): per-session ACP debug logs with rotation and retention (#1172) 2026-05-30 19:10:09 +01:00
Carlos Florêncio 43977ca570 fix(web): keep markdown table headers readable on narrow content (#1122) 2026-05-30 13:38:21 +01:00
João Salavisa 991a6315c3 fix(backend): force fresh git status on session subscribe (#1092)
* fix(backend): force fresh git status on session subscribe

Every new WS subscriber to a session now triggers a fresh `git status
--porcelain` in agentctl instead of replaying the cached snapshot.
This makes the cache an optimization for already-subscribed observers
only — a fresh observer revalidates ground truth on every connect, so
a stale cache (e.g. left over when the poll loop missed a HEAD change
in paused mode or dropped a tick) self-heals on the next subscribe.

- `?fresh=true` on `/api/v1/git/status/multi` propagates through to
  `WorkspaceTracker.GetGitStatus(ctx, true)`.
- New `GetGitStatusMultiFresh` client method; `tryGetLiveGitStatus`
  (the session-subscribe hydration path) uses it.
- Regression test reproduces the bug: dirty file → cache primed →
  commit without refresh → cached read still shows the file as
  modified; fresh=true returns the clean truth.

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

* fix: address PR review feedback

- Reframe regression-test precondition comment so it reads as scenario
  setup (Claude review).
- Document GetGitStatusMultiFresh's read-only-vs-cache contract in its
  godoc so a future reader doesn't try to write the result back into
  the tracker's currentStatus (Claude review).
- Trim multi-line block comment in helpers.go to a single line per the
  backend AGENTS.md convention (Claude review).
- Parallelize handleGitStatusMulti's per-repo fan-out via sync.WaitGroup
  to keep the fresh-path wall-clock close to the slowest single repo
  regardless of repo count, so multi-repo workspaces don't blow past
  the 2s subscribe-path timeout (Greptile review). Pre-sized result
  slice means each goroutine writes its own slot — no shared-slice
  contention. collectStatusForRepo now takes context.Context directly
  instead of *gin.Context.
- Add a third assertion in the regression test that locks in the
  documented cache-unchanged contract — after fresh=true, a subsequent
  GetGitStatus(ctx, false) call must still return the stale cached
  value, so any future change that silently writes through fresh would
  fail the test (Greptile review).

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

* fix: address round 2 review feedback

- Trim the parallelization rationale in handleGitStatusMulti to a
  single line per AGENTS.md convention (Claude review).
- Drop unused GetGitStatusMulti client method (cached variant) and its
  internal getGitStatusMulti helper — only GetGitStatusMultiFresh has
  a caller after this PR, and AGENTS.md says to delete unused code
  outright (Claude review).

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 17:44:44 +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
Luan Martins 1a467b969f fix(web): wire permission approval UI for Kandev MCP tools (#1037)
* fix(web): wire permission approval UI for Kandev MCP tools

KandevToolMessage adapter ignored the permissionMessage prop, so MCP tools
that block on a permission request rendered the result row but no Approve/
Deny buttons — users could not unblock the agent. Extract the shared
permission handlers into use-permission-handlers.ts and reuse them from
both ToolCallMessage and KandevToolMessage. KandevRow now also shows an
amber pending clock icon and hides the result-count summary while waiting
for approval so the row reads correctly before the result arrives.

Adds a mock-agent scenarioKandevMCPPermission emitting
mcp__kandev__list_workspaces_kandev plus a Playwright e2e and unit tests
covering the pending UI and the absence of "0 workspaces" while pending.

* fix(web): collapse tool + permission status into resolved Kandev row

After approve/reject the orchestrator stamps the decision onto the
permission_request message immediately, but the tool_call's own
meta.status stays "pending" until the next tool_call update lands.
KandevToolMessage previously passed meta.status straight to the renderer,
so the row kept the amber pending clock and the hidden-summary state for
the brief window between the two events. resolveKandevStatus now prefers
the permission decision once present, mapping approved -> complete and
rejected -> error.

Drops a redundant Playwright `.first()` after a toHaveCount(1) assertion
on the same locator. Adds unit coverage for the resolved-decision state.

* fix(web): keep Kandev tool status independent of permission decision

The previous fix collapsed an approved permission into tool status
"complete" and a rejected permission into "error". That was wrong on two
counts: an approved tool that's still running rendered as already done
(green check + visible result summary built from an empty payload), and
any later tool_call error was silently masked because the status had
already been forced to "complete".

Replace the status override with a separate display overlay. KandevRow
now reads a `KandevPermissionUIContext` ("pending" | "rejected" |
undefined) and uses it only to drive the waiting-on-user affordances —
amber clock, hidden summary — and a rejection mark that wins over the
tool's own success state. The renderer always receives the real
tool_call status, so a tool that's approved-but-pending still reads as
pending and a tool that errors after approval still renders the red X.

Adds explicit regression tests for the two race-window failure modes
plus the new rejected overlay.

* test(web): tolerate duplicate-render backend race in Kandev MCP e2e

CI shard 1 sometimes renders two approve buttons for the same Kandev MCP
permission because the orchestrator can create a second generic tool_call
message for the same tool_call_id alongside the kandev-routed one. Both
buttons resolve the same pending_id, so clicking the first unblocks the
agent either way.

The bug the test guards against is "no approve button at all" — the
strict toHaveCount(1) assertion was tying the test to that separate
backend issue. Relax to at-least-one and click the first.

* fix: persist Deny clicks as rejected, not approved

Backend RespondToPermission previously derived status purely from cancelled:
cancelled=true -> "rejected", otherwise "approved". Deny clicks sent
cancelled=false with a reject_* option_id, so they were recorded as
"approved". Add an explicit rejected flag from the WS request so the
backend can record "rejected" without taking the cancellation path (which
races against permission_cancelled -> "expired").

Also refactor permission-request-message.tsx to consume the shared
usePermissionResponseHandlers hook (the duplicated inline handler had the
same bug) and introduce a typed models.PermissionStatus enum on the
backend so status strings are not scattered literals.

* test(web): scope Kandev MCP permission e2e to the Kandev row

Previously the test clicked the first matching permission-approve button,
which could be a duplicate generic ToolCallMessage fallback rendered for
the same pending_id by a backend race -- a green test no longer proved the
custom Kandev approval UI rendered.

Add a `data-testid="kandev-tool-permission"` wrapper around the Kandev
action row and a `kandevPermissionApproveButtons` page-object locator that
scopes through it, then assert `toHaveCount(1)` so the test only passes
when the Kandev custom row actually mounts.

* chore(orchestrator): log rejected flag in RespondToPermission

Debug log was missing the new rejected param, making it impossible to
distinguish explicit Deny from dialog cancellation when triaging
permission-flow issues.
2026-05-26 00:22:45 +01:00
Carlos Florêncio f1dbad1dbd feat(cli-mode): auto-inject task prompt + dynamic submit sequence + passthrough UX fixes (#923) 2026-05-25 19:14:36 +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 68aefc0e08 feat(web): system settings pages (status, database, backups, logs, updates, licenses, about) (#942) 2026-05-25 09:04:16 +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
José Almeida 3bc092bf20 feat(terminal): first-class user terminals — stable seq, rename, park/resume (#1009)
* feat(terminal): persist user terminals with stable seq + park state

Ordinary user shells now have a DB-backed `user_terminals` row keyed by
the agentctl PTY id, carrying a per-task monotonic seq (gaps preserved on
delete), custom_name override, and open/parked state. The terminal
service owns the create/list/rename/park/resume/destroy surface and
guards reject `bottom-panel` (cmd+J) + `script-*` ids so passthrough
flows are untouched. Closing a tab now parks (PTY keeps running);
destroy kills the PTY and removes the row.

* feat(web): expose terminal seq badge, rename, and parked-terminals menu

Wires the right-panel strip and SSR page state to the new task-keyed
terminals endpoint and discriminated UserShell union, adds the `#N`
badge for ordinary terminals, plumbs context-menu / double-click rename,
and renders a "Parked" submenu so closed-but-running terminals can be
resumed. Closing a managed tab now parks instead of destroying so the
PTY (and its buffer) survives accidental closes.

* test(terminal): e2e coverage for first-class persistent entities

Covers the persistence guarantees the user explicitly called out:
monotonic seqs, refresh-survives-rename-and-park, gap-preserving destroy,
park+resume cycle, and bottom-panel/script guards. Exercises the new
user_shell.{create,list,rename,park,resume,destroy} WS actions through
the shared ApiClient.wsRequest helper.

* fix(terminal): atomic seq allocation + propagate destroy errors

- Repository.Create folds MAX(seq) read + INSERT into a single
  `INSERT … SELECT` statement so concurrent creates can no longer pick
  the same seq. Locked in with TestCreate_ConcurrentSeqAllocation (25
  parallel creates, all unique).
- wsUserShellStop now propagates the service-Destroy error so the
  frontend stops optimistically removing a row the backend failed to
  delete.
- Service.Destroy / CleanupTask log PTY-stop failures at Warn (instead
  of silently swallowing) while keeping the proceed-on-failure semantics
  intentional — the DB row is the source of truth for "user expects
  this terminal gone".
- IsUserShellAlive copies ProcessID under the read lock before calling
  r.Get(), removing the post-unlock pointer-deref race.
- Get-not-found test asserts errors.Is(err, ErrNotFound) so it locks to
  the repository contract.

* fix(web): close-tab waits for backend ack + className undefined guard

- handleCloseTab defers `removeTerminal` into the `.then()` of the
  park/destroy promise. A failed backend call leaves the tab visible
  rather than vanishing it optimistically; the next `user_shell.list`
  poll reconciles state.
- session-tabs guards `tab.className` with `?? ""` so an undefined value
  no longer produces "undefined group relative..." in the rendered
  class string.
- user-shells.test simplification: `createSessionRuntimeSlice` only
  consumes `set`, so the test passes the slice creator straight to
  `immer()` instead of wrapping it in an adapter with unused args.

* fix(terminal): task-scoped mutations + legacy-shell list + UI hardening

Round-2 review feedback (cubic P1/P2, claude, coderabbit):

Backend:
- Rename/Park/Resume/Destroy now require a task_id and verify the
  terminal belongs to that task via a shared requireOwnership helper.
  Cross-task mutations return ErrTaskMismatch instead of silently
  succeeding by raw terminal id.
- wsUserShellList no longer drops managed-shaped agentctl shells when
  the terminal service is disabled or task_id is missing — legacy
  passthrough callers see their existing shells again. `managedCovered`
  flag scopes the orphan filter to the DB-backed path.
- service_test.go uses the shared-cache in-memory SQLite + MaxOpenConns(1)
  setup, matching repository_test.go and removing the flake-one-race-away
  warning.
- New cross-task guard tests: TestRename_RejectsCrossTask,
  TestPark_RejectsCrossTask, TestDestroy_RejectsCrossTask, plus a
  TestRename_EmptyTaskIDSkipsOwnership for internal callers.

Frontend:
- updateUserShell patch type narrowed to Partial<Omit<UserShellInfo,
  "terminalId">> so the row key can't be silently mutated.
- destroyUserShell now throws on a missing WS client instead of silently
  resolving — handleCloseTab's .then() no longer fires prematurely.
- rename/park/resume/destroy API client + useTerminals hook plumb the
  active task id through every mutating call.

E2E:
- New "cross-task mutating ops are rejected" spec creates a terminal
  under task A and verifies task B can't rename/park/destroy it by raw
  id.
- Existing specs updated to send task_id with their mutating calls.

* fix(terminal): require task_id on all mutators + wire taskID into hooks

Round-3 review feedback (cubic P1, claude blocker, coderabbit):

Backend:
- requireOwnership no longer skips the ownership check when taskID is
  empty. The previous carve-out was reachable from the WS layer (where
  task_id is user-controlled), so an attacker who guessed a terminal id
  could omit task_id and mutate any row. CleanupTask walks the repo
  directly and never touched the bypass.
- TestRename_EmptyTaskIDRejected pins the new contract.

Frontend (the real bug claude caught):
- useUserShells now accepts a taskId parameter and forwards it on the
  user_shell.list WS call. Without it the backend's DB-backed ordinary-
  shell path was skipped and the Zustand store wiped the SSR-hydrated
  panel strip on the first WS fetch.
- Response mapping switched to a discriminated union: id (new field)
  falls back to terminal_id (legacy), and kind/seq/state/pty_status/
  display_name/custom_name flow into the store so the parked-terminals
  submenu actually populates.
- useTerminals + useMobileTerminals read the active task id from the
  store and pass it through to useUserShells.
- destroyUserShell now retries with user_shell.stop when the server
  returns an unknown-action error — keeps the rollout-window
  compatibility intact for older backends that only know the legacy
  action name.

* fix(terminal): list parked, rollout-safe kind default, helper extraction

Round-4 review feedback (claude + greptile P1):

- useUserShells now sends include_parked: true so the "Parked terminals"
  submenu populates after a page reload. Previously the WS list returned
  only state=open rows; parked terminals were invisible until the user
  parked something in the same session. The main strip still hides
  parked entries via buildTerminalsFromShells.
- useAddTerminal stops blindly defaulting kind to "ordinary" — that
  misclassified legacy passthrough payloads (terminal_id without seq)
  during a rollout where the frontend is newer than the backend. The
  managed-tab path then called parkUserShell on a server that didn't
  know the action. Now only tags as ordinary when kind is explicit or
  the row carries a seq.
- handleCloseTab extracted into a standalone useCloseTab + helpers
  hoisted into use-terminals-build.ts / use-terminals-types.ts. Brings
  use-terminals.ts back under the 600-line file limit and the
  useTerminalActions wrapper inside the 100-line function limit.

* fix(terminal): scope-keyed shell cache + null-task guard + tighter rollout default

Round-5 review feedback (cubic P1, coderabbit x4):

- useUserShells now keys its fetch cache on env+task (`${env}:${task}`),
  not just env. An earlier load without a task id (e.g. before the
  active task hydrated) no longer pins the store on a legacy-only shell
  list and blocks the task-scoped refetch.
- useMobileTerminals delays auto-create until a real taskID is
  available. Previously a null taskID could bootstrap a legacy
  non-persistent shell, mark the env as auto-created, and starve the
  real task of its first-class terminal. Guard key is now env:task, and
  releaseAutoCreatedEnvironment clears every scope on that env.
- useAddTerminal tightens the rollout check: a row is "ordinary" only
  when `seq !== undefined`. A bare `kind: "ordinary"` without seq from
  an older backend stays legacy so close → destroy on the first click.
- useRemoveTerminal reads the current activeTab from a ref instead of
  closing over the value captured when handleCloseTab first ran. Async
  park/destroy resolves after a user tab-switch no longer clobber the
  newer selection with the stale fallback shift.

Tests:
- New "waits for taskID before auto-creating — null taskID is a no-op"
  case in use-mobile-terminals.test.ts confirms the null-task guard.
- All existing mobile-terminal + slice + E2E tests pass.

* fix(terminal): live activeTab lookup + selectable-tab guard

Round-6 review feedback (cubic P2 x2):

- getActiveTab now reads the active tab from the Zustand store via
  storeApi.getState() instead of a useRef/useEffect pair. Closes the
  post-render race where an async close handler could resolve between
  the new render's commit and the effect-driven ref sync.
- computeTerminalTabValue requires activeTab to be selectable
  (present in `terminals` or === "commands") before honoring it. A
  stale id left over from a parked/destroyed terminal no longer pins
  the panel on a nonexistent tab and blocks the fallback shift to a
  real shell.

* chore(terminal): inline savedTabFromStorage to drop file under line cap

Round-7 review feedback (claude suggestion): the useMemo around the
synchronous getSessionStorage call was zero-value memoisation that
pushed use-terminals.ts three lines over the 600-line max-lines limit.
Inlined; file now at 600 lines.

* chore(terminal): drop savedTabExists local + comment to land under 600 lines

Round-8 follow-up (claude): the previous commit's two-line comment block
held the file at 603 lines (still over the 600-line cap). Inline
savedTabExists at the only call site and remove the redundant comment —
the round-7 PR thread already explains why the useMemo was removed. File
now at 599 lines.

* fix(terminal): plumb task_id through dockview + tolerate legacy destroys

Manual-test feedback: clicking X on a dockview terminal panel surfaced
"destroy terminal: terminal does not belong to the supplied task" because
the dockview path was creating + destroying terminals without sending
task_id at all. The new requireOwnership guard then correctly rejected
the empty task_id against a non-empty term.TaskID.

Frontend:
- addTerminalPanel grows task_id + title parameters; stamps both into
  panel params at creation time so the destroy cleanup can pass them
  back to user_shell.destroy / .stop.
- Every dockview-side createUserShell call site now passes the active
  task_id, so the backend creates a DB-backed ordinary terminal (with
  seq + displayName) instead of a legacy passthrough shell.
- destroyUserShell + stopUserShell already accepted an optional task_id;
  dockview-layout-setup.onDidRemovePanel now reads the stamped task id
  from panel params and forwards it.

Backend:
- wsUserShellStop now requires both terminalSvc and a non-empty
  req.TaskID before routing through the managed service. An empty
  task_id falls through to the legacy passthrough StopUserShell so older
  clients (and dockview panels persisted before task_id was stamped)
  keep working.

Known follow-ups (separate task): the #N badge, rename context menu,
and parked-terminals dropdown still only show on the mobile/tablet
right-panel strip. Desktop dockview shows the seq inside the panel
title text but doesn't expose the badge/rename/parked-list UI yet.

* feat(terminal): dockview tab badges + rename context menu + existing-terminals list

Manual-test feedback round 2:

- Custom dockview tab component (TerminalTab) renders the #N pill
  only when the active env has more than one ordinary terminal —
  mirrors the session-tab badge semantics. Right-click on any ordinary
  terminal tab now exposes Rename / Park / Destroy.
- Dockview + menu picks up a new "Terminals" section listing every
  ordinary terminal for the active task (open and parked). Clicking a
  row focuses the existing dockview panel if one is mounted, otherwise
  re-opens it; parked rows are auto-resumed via user_shell.resume
  before the panel is added. The base "Terminal" action was renamed
  to "New Terminal" so the existing-terminals list is distinguishable
  in the menu.
- Reopen menu uses a stable EMPTY_SHELLS sentinel for its store
  selector to avoid Zustand infinite re-renders when the env has no
  shells.

E2E:
- terminal-new-tab.spec uses getByRole("menuitem", { name: "New
  Terminal", exact: true }) to disambiguate from the new "Terminals"
  submenu label.
- Full terminal suite still passes (26/26).

* fix(terminal): tab title shows "Terminal" with seq as badge, populate store on session page

Re-reading the user feedback: the title text was rendering as
"Terminal 1" (backend's displayName), and the dockview "+" menu's
Terminals section was empty because nothing on desktop was calling
useUserShells to populate the store.

- TerminalTab.pickDisplayName now returns the literal "Terminal" for
  ordinary terminals (custom names still override). The seq lives only
  in the sibling badge — same split as session-tab where the agent
  name is the title and the seq is a pill before it. Reading "Terminal
  1" out of "[1] Terminal" instead of "[1] Terminal 1".
- LeftHeaderActions (dockview "+") now calls useUserShells on mount.
  Without this the desktop layout never triggered the WS list fetch,
  so the Terminals submenu and the badge "count > 1" check both saw
  an empty store and silently disappeared.
- TerminalReopenMenuItems also calls useUserShells defensively (cheap
  thanks to scope-keyed cache) so it works when mounted in isolation.

* feat(terminal): "New X" rows live inside their section labels

User feedback: move "New Agent" under the Agents section header and
"New Terminal" under the Terminals section header so the dockview "+"
menu reads top-to-bottom as Agents → New Agent → existing sessions →
Terminals → New Terminal → existing terminals → other panels.

- SessionReopenMenuItems accepts an optional onNewSession prop and
  renders the "New Agent" row as the first item under the "Agents"
  label. Render gate now keeps the section visible whenever the create
  handler is supplied, even with zero existing sessions.
- TerminalReopenMenuItems gets a matching onNewTerminal prop with
  the same behaviour ("New Terminal" row under the "Terminals" label).
- dockview-add-panel-items drops its standalone "New Agent" and "New
  Terminal" rows since both now live inside their sections.

* feat(terminal): auto-migrate default panel to DB-backed ordinary terminal

The legacy `terminal-default` dockview panel was hard-wired to a
non-managed `shell-default` PTY so it never picked up an ordinary
seq/customName/parked state — the new "Terminal N" badge UX could
only show a badge once the user had explicitly created a second
terminal. With useEnsureDefaultTerminalOrdinary the default panel is
upgraded into a real `shell-<uuid>` row on session-page mount, so
every visible tab is now a first-class ordinary terminal.

Plus a UI-level e2e spec asserting the multi-terminal title + badge
contract, and an expectation bump on the existing rename+park spec
since visiting the session page now mints 1 extra ordinary terminal.

* fix(terminal): park ordinary terminals on tab close so they survive refresh

Dockview's tab X close button routed straight to stopUserShell (an
alias for destroyUserShell), wiping the DB row on close — after a
refresh the user had no way to resume a still-running PTY they had
just tucked away.

The X is now a "hide for now" affordance: ordinary terminals get
parked (PTY survives, row stays in DB, reappears in the "+" →
Terminals menu with a `parked` pill), while scripts / bottom-panel /
legacy passthrough shells stay on the destroy path because they have
no resume story.

Permanent delete still lives on the right-click Destroy menu item
and (in the next commit) on each reopen-menu row.

* fix(terminal): preserve DB-backed terminal panel ids across layout restore

The serializer's normalizePanel rewrote every user-created terminal
panel id to `terminal-saved-N` and dropped environmentId/taskID from
params, so after a reload that panel couldn't be matched back to its
live PTY or its store record — the tab rendered with no badge and a
stale fallback title.

Treat `shell-<uuid>` panel ids the same way we already treat
`terminal-default`: preserve id and params on restore. Legacy
ephemeral `terminal-saved-*` ids still mint a fresh PTY on restore.
Also force tabComponent=terminalTab and title="Terminal" for any
restored terminal panel so layouts persisted before the badge UX
land on the new presentation.

* feat(terminal): destroy button on reopen-menu rows for permanent delete

Destroy used to live only on the tab's right-click menu — useless once
the tab was already closed. Each ordinary terminal row in the dockview
"+" → Terminals submenu now ends with an × button that confirms-then
calls destroyUserShell, killing the PTY and dropping the DB row so the
terminal does not reappear after reload.

Pairs with the previous close-to-park change: closing the tab parks
(soft, resumable), the × button on the menu row destroys (hard,
permanent).

* fix(terminal): reopen-menu focuses existing tab, drops parked pill, says Terminate

Three small fixes to the dockview "+" → Terminals submenu:

1. Clicking a row whose terminal is already mounted now FOCUSES the
   existing panel instead of duplicating it. The default-migrated
   panel keeps its registry id `terminal-default` even after the
   migration to a `shell-<uuid>` PTY, so a direct getPanel(uuid) miss
   would fall through to addTerminalPanel and create a second tab for
   the same PTY. Fall back to scanning `params.terminalId` so the
   lookup catches that case.

2. Drop the "parked" pill from the row — parking is now an internal
   detail (close = park) that the user doesn't need to read off the
   menu. Clicking the row brings the terminal back into view either
   way, so distinguishing it visually is noise.

3. Rename the row × tooltip + confirm copy from "Destroy" / "Permanently
   delete" to "Terminate" to match the new tab-context-menu label.

Spec: add a test that asserts no duplicate tab when reopening an
already-open terminal, and drop the parked-pill assertion from the
existing close+reload test.

* feat(terminal): empty-group watermark uses the rich + menu

When a user splits a group (split-right / split-down) the new half is
empty and dockview renders the watermark. Before this, the watermark
showed a hard-coded Agent/Plan/Terminal/Browser row and clicking
"Terminal" called createUserShell without a taskId — so it spawned a
legacy passthrough shell that never appeared in the user-shell list,
and clicking "Agent" only ever focused the singleton chat panel
(invisible if the task had multiple sessions).

The watermark now renders the same AddPanelMenuItems content the
header "+" uses, so the user gets:
- The Agents list (existing sessions, greyed when already open)
- The Terminals list (existing terminals, greyed when already open)
- The Browser/Plan/VS Code/Changes/Files/PR options

…served behind a single "Add panel" dropdown trigger centered on the
empty group.

* feat(terminal): inline rename on the tab; drop Park, rename Destroy → Terminate

The tab title is now editable in place — picking Rename from the
right-click context menu swaps the title for an input. Enter commits,
Escape / blur-with-no-change cancels, and the new name is pushed
through renameUserShell + the user-shell store so the API and badge
list stay in sync. Replaces the previous window.prompt which felt
out of place in the dockview chrome.

While here:
- Drop the "Park (hide, keep PTY)" context-menu item. Tab close
  already parks ordinary terminals, so the explicit Park action was
  redundant and broke the "one obvious affordance per outcome" rule.
- Rename "Destroy" → "Terminate" in the context menu. Matches the
  reopen-menu × tooltip and reads more like a system action than a
  destructive UI primitive.

Spec adds an inline-rename test asserting the input appears, accepts
typed text, and commits the new title to the tab.

* fix(terminal): right-click Terminate closes the tab; drop displayName DOM warning

Two unrelated fixes both landing in terminal-tab.tsx:

- Terminate used to fire the WS destroy and update the store, but left
  the dockview panel mounted with a dead PTY underneath. Now it
  optimistically drops the shell row from the store, closes the panel
  via api.close(), then sends the destroy — same effect as the X close
  but routed to the hard-destroy path (the onDidRemovePanel cleanup
  sees no shell entry and skips its park branch).

- TerminalTabBody typed `displayName` as a prop but never destructured
  it, so {...props} spread it into DockviewDefaultTab and on into a DOM
  div, triggering "React does not recognize the `displayName` prop on
  a DOM element". Drop it from the spread.

* feat(terminal): polish + menu — badge-style indices, flush-left New rows, terminate closes panel

Polish pass on the dockview "+" → Agents / Terminals submenus:

- Replace the plain `#N` text with the same pill-style badge the tabs
  use (small bg, rounded, 11px). Agents and terminals both pick this
  up — so the menu row reads "[N] label" matching the tab style.
- Drop the empty index-column placeholder span on the "New X" rows so
  the icon + label sit flush-left under the section header instead of
  awkwardly indented.
- Strip the backend "Terminal {seq}" suffix from row labels — the
  badge already carries the number, so showing "Terminal 1" next to
  the "1" pill duplicated it. Custom-renamed terminals still show
  their name.
- The row × button now also closes the dockview panel if the terminal
  is currently open, mirroring the right-click → Terminate flow.

Spec: switch the row-by-seq lookups from `hasText: "#N"` (now stale
since the `#` prefix is gone) to a `has:` filter against the new
`reopen-terminal-seq-N` badge testid, and add a regression test for
right-click → Terminate closing the tab and removing the row in one
gesture.

* fix: address terminal review feedback

* fix: resolve terminal review suggestions

* fix: catch terminal close stop failures

* fix: harden terminal terminate failures

* fix: use pinned pnpm docker arg

* fix: satisfy backend lint after main merge

* fix: stabilize e2e after layout merge
2026-05-24 12:59:30 +01:00
João Salavisa 68aeefa296 feat(gitlab): add GitLab integration with MR / discussions support (#861)
* feat(gitlab): add GitLab integration with MR / discussions support

Adds a GitLab integration parallel to the GitHub one: PAT and glab CLI auth,
self-managed host configuration, MR feedback (approvals + discussions +
pipelines), and discussion reply / resolve so agents can address review
comments. The pr skill detects the host from git remote and creates a real
GitLab merge request via glab or REST.

Closes #820

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

* refactor(gitlab): address review feedback and tighten lint surface

- Add service_test.go covering ConfigureToken / ClearToken / ConfigureHost.
- Drop speculative MRWatch / TaskMR / ReviewWatch / IssueWatch / preset
  scaffolding that had no migrations or handlers behind it.
- Consolidate /approvals into one round-trip via fetchApprovals.
- Use http.DefaultClient in CheckHost (context already carries the deadline).
- Rename misleading id → configured in ConfigureToken.
- Introduce mrStateOpen constant so the lint goconst rule passes.

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

* fix(gitlab): address PR review feedback (CodeQL SSRF, races, atomicity)

- connectivity: parse host with url.Parse, validate http/https scheme,
  build probe URL via &url.URL{} so user-controlled host can't smuggle
  into the request — clears the CodeQL go/request-forgery alert.
- pat_client: guard cached username with sync.RWMutex; IsAuthenticated
  now propagates non-401/403 errors instead of masking transport
  failures; GetMRStatus skips pipelines query when HeadSHA / HeadBranch
  are both empty; parseNextLink drops cross-host links instead of
  returning bare absolute URLs that get() would double-prefix.
- service: findTokenSecret returns (bool, string, error) and all
  callers propagate the secrets.List error so a backend outage isn't
  mistaken for "token absent"; ConfigureHost persists to hostStore
  before mutating in-memory host + client + authMethod under one lock.
- repoclone: SSH clone URL falls back to ssh:// form when the host has
  a port (scp-style git@host:path can't carry one).
- gitlab/client_helpers: namespace fallback uses LastIndex("/") so
  subgroup paths (acme/team/squad/repo) are preserved.
- pr skill / plan doc: drop hardcoded --target-branch main, derive host
  from git remote instead of $CI_SERVER_URL, build curl payload with
  jq -n; fix /pipelines/:id/jobs → :pipeline_id typo.

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

* fix(gitlab): preserve host base path in CheckHost; broaden validation; test

- connectivity: extract validateHost so the parse / scheme / hostname /
  userinfo / opaque-URL checks each have a single home. Preserve the
  configured base path (TrimRight slash) so a host like
  https://gitlab.acme.corp/gitlab probes /gitlab/api/v4/version instead
  of dropping the prefix.
- connectivity_test: cover validateHost (default, https, http, trailing
  slash, subpath, missing scheme, ftp, empty host, userinfo) and
  CheckHost end-to-end (version endpoint, base-path preservation,
  scheme rejection, unreachable host).
- pr skill: drop the broken `glab repo view --json defaultBranch`
  branch — glab doesn't accept --json, so the lookup silently fell
  back to "main" every time. Fetch default_branch via the GitLab
  REST API directly using the PAT we already have.

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

* fix(gitlab): override filter defaults, refresh auth uncached, mock pipelines

- appendFilter: Del before re-Add so user-supplied filter keys actually
  override caller defaults (e.g. state=closed swaps the seeded state=opened
  instead of appending a second value GitLab silently ignored).
- IsAuthenticated: probe /user directly instead of routing through the
  username cache; a token revoked upstream now surfaces as not-connected
  immediately. Cache is opportunistically refreshed on success.
- MockClient.GetMRFeedback: include pipeline state in HasIssues, matching
  the real PATClient behavior so test flows can't miss a broken MR.
- pr skill: document `glab mr note create --reply <id>` for replies (bare
  `glab mr note` opens a new thread); fix host-extraction block to handle
  ssh://host:2222/team/repo.git without leaking the port into PROJECT.

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

* fix(gitlab): guard mock pipelines on empty MR head; preserve HTTPS port in pr skill

- MockClient.GetMRFeedback now mirrors PATClient: skip the pipelines
  fetch when HeadSHA and HeadBranch are both empty. The mock'\''s
  ListPipelines ignores its branch argument and returns every pipeline
  seeded under the project, so without this guard a brand-new MR would
  inherit a sibling MR'\''s failing pipeline and flip HasIssues to true.
  Tests cover both the empty-head and head-present cases.
- pr skill: derive HOST scheme-aware so https://host:8443 keeps the port
  (HTTPS API endpoint), ssh://host:2222 strips it (different transport),
  and scp-style git@host stays portless. Avoids silently re-routing
  self-managed API calls to the default 443.

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

* fix(gitlab): export SeedPipelines; mock GetMRStatus parity; drain bodies; test cache hit

- MockClient: add exported SeedPipelines(project, iid, []) alongside the
  other Seed* methods; mirror PATClient.GetMRStatus by populating
  PipelineState / jobs counts via summarizePipelines, guarded on a
  non-empty head ref. Brings mock parity with the real client so any
  future test seeding pipelines sees the same MRStatus shape.
- PATClient HTTP plumbing: drain resp.Body via io.Copy(io.Discard, ...)
  when result == nil in both getWithTotal and doWrite, so HTTP/1.1 can
  reuse the connection instead of opening a new TCP socket per call.
- TestPATClient_GetAuthenticatedUser: assert calls == 1 after the second
  GetAuthenticatedUser hit so the cache contract is actually verified
  (a non-caching implementation would have passed the prior version).

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

* fix(gitlab): surface connection errors, align MR pipeline guards, fix mock seed key

- service.GetStatus now captures the IsAuthenticated error into a new
  Status.ConnectionError field (empty on healthy / 401-403, populated on
  network / 5xx / parse failures). Frontend GitLabStatus + settings page
  render a distinct "Unreachable" badge + alert so users don't delete a
  still-valid token during a transient GitLab outage.
- PATClient.GetMRFeedback now guards on HeadSHA || HeadBranch, matching
  GetMRStatus and MockClient.GetMRFeedback (which had been updated in an
  earlier round). The three call sites were inconsistent — a mock test
  with HeadBranch-but-no-SHA would pass while the real client silently
  dropped pipelines.
- MockClient.pipelines is now keyed by project alone (was
  map[mockMRKey][]Pipeline). SeedPipelines takes (project, []) so
  successive seeds for the same project deterministically overwrite,
  instead of leaving multiple entries that ListPipelines would resolve
  in Go's unspecified map-iteration order.
- Tests: TestService_GetStatus_{PopulatesConnectionErrorOn5xx,
  ClearsConnectionErrorOn401} cover the new error surface;
  TestMockClient_SeedPipelines_OverwritesByProject locks the key change.

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

* fix(gitlab): tighten validateHost; init Status scopes; close token-host race

- validateHost: drop the parsed.Opaque != "" branch — Go only sets
  Opaque for opaque URIs, which have an empty Host the prior check
  already rejects. Narrow the userinfo error to mention the actual
  user:password@host shape so the message matches what users see.
- service.GetStatus: initialize RequiredScopes: []string{} on the
  nil-client defensive branch so the JSON payload keeps the
  always-an-array contract declared on the TypeScript side. The branch
  is effectively unreachable (every Provide path returns at least a
  NoopClient) but the empty-slice fallback costs nothing.
- service.ConfigureToken: build the installed client inside the write
  lock using the up-to-date s.host instead of the pre-probe snapshot.
  A concurrent ConfigureHost between RUnlock and Lock used to leave
  s.client pointing at the prior host while s.host advertised the new
  one — every subsequent API call would silently target the wrong
  instance until the next reconfigure.

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

* fix(gitlab): drop MR-commit stats fields; rename AuthorName; log post-lock host

- MRCommitInfo: drop Additions / Deletions / FilesChanged. GitLab's
  /merge_requests/:iid/commits endpoint doesn't return a stats object,
  so those fields were always 0 — better to omit them than feed callers
  silently-wrong numbers. Rename AuthorUsername to AuthorName since the
  API returns a display name, not a login handle. Doc-comment records
  the per-commit endpoint to use if a future revision wants real stats.
- ListMRCommits: drop the Stats anonymous struct from the JSON decode
  target now that the destination fields are gone.
- service.ConfigureToken: capture installedHost inside the write lock
  and log that value. The previous log used the pre-validation host
  snapshot, so a concurrent ConfigureHost between snapshot and lock
  would leave the log entry referring to a stale value while the
  client itself was correctly built against the new host.

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

* fix(gitlab): encode subgroup paths; sentinel-route errors; mock approval parity

- pat_client.SearchGroupProjects now encodes the group path through a new
  groupRef helper that %2F-substitutes slashes (mirrors projectRef). Subgroup
  paths like "acme/team" used to land at /groups/acme/team/projects, which
  GitLab routed to the parent group only and returned 404 for. Subgroups now
  work end-to-end.
- service.ErrInvalidToken sentinel + controller errors.Is routing: token
  validation failures now wrap a package-level sentinel and the HTTP layer
  switches on it via errors.Is, replacing the fragile strings.Contains
  prefix check that would silently fall through to 500 if the message ever
  changed. Coverage: TestService_ConfigureToken_WrapsErrInvalidToken.
- MockClient gains seedable approvals: SeedApprovals(project, iid, list,
  required) backs a dedicated approvals + requiredApprovals map.
  ListMRApprovals, GetMRFeedback, and GetMRStatus all consume them so
  ApprovalState / ApprovalCount / RequiredApprovals match what the real
  PATClient.GetMRStatus computes. Coverage:
  TestMockClient_GetMRStatus_UsesSeededApprovals.
- ClearToken: same host-snapshot race ConfigureToken was fixed for last
  round — NewClient now runs under the write lock against s.host rather
  than a pre-acquired snapshot.
- CheckHost: drain resp.Body before close so http.DefaultClient's keep-alive
  pool can reuse the connection for the next probe.

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

* fix(gitlab): full-path projectRef round-trip; unlock during glab probe

- splitFullReference now returns (namespace, fullPath) where fullPath is
  the namespace-qualified project path ("group/sub/project") instead of
  the last segment alone. Callers can pass MR.ProjectPath straight to
  projectRef for round-trip API calls (GetMR, GetMRFeedback, etc.) —
  matches GitLab's own path_with_namespace convention. Existing test
  cases updated.
- ClearToken: build the fallback client OUTSIDE the write lock so
  NewClient's glab subprocess (bounded by glabAuthTimeout = 10s)
  doesn't freeze every concurrent GetStatus / GetMRFeedback /
  Client() RLock reader for the duration. Brief WLock now only wraps
  the field swap. Tradeoff with the concurrent-ConfigureHost race is
  documented in-code: narrow stale-host window vs. 10s of blocked
  readers, and the next reconfigure resolves it.
- New regression test TestPATClient_SearchGroupProjects_EncodesSubgroupSlash
  asserts the request URL is /groups/acme%2Fteam/projects so a future
  refactor that swapped groupRef back to a bare url.PathEscape would
  fail visibly instead of silently 404ing subgroup lookups.

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

* fix(gitlab): equality-guard ClearToken's client swap against ConfigureHost race

ClearToken builds the fallback client outside the write lock to avoid
blocking RLock readers for up to 10s while glab auth-probes. The
swap-only critical section now compares s.host against the pre-probe
snapshot before assigning — if a concurrent ConfigureHost moved the
host underneath us, we leave its (newer, correctly-targeted) client
alone instead of overwriting with our stale-host build.

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

* fix(web): add GitLab to the settings integrations sidebar

The /settings/integrations/gitlab page existed and the index card listed
GitLab alongside the other integrations, but the settings sidebar nav
(IntegrationsNavGroup) only listed GitHub / Jira / Linear / Slack. The
page was unreachable through the standard sidebar.

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

* feat(gitlab): task-MR topbar surface + DB-backed task↔MR store

Phase 1 of the GitLab UI build-out: brings the MR side of the task topbar
to parity with the existing GitHub PR button.

Backend
- gitlab_task_mrs table + Store wiring (UpsertTaskMR,
  ListTaskMRsByTask, ListTaskMRsByWorkspaceID, DeleteTaskMR), uniqued
  on (task_id, repository_id, project_path, mr_iid) for multi-repo.
- service.SyncTaskMR folds a live GetMRStatus probe into an upsert;
  ListTaskMRsByTask / ListTaskMRsByWorkspace expose the rows.
- Three HTTP endpoints: GET /workspaces/:id/task-mrs, GET
  /tasks/:taskID/mrs, POST /tasks/:taskID/mrs/sync.
- initGitLabService now takes a *db.Pool and constructs gitlab.Store.
  Failures non-fatal: integration keeps working without the table.

Frontend
- TaskMR type + listWorkspaceTaskMRs / listTaskMRs / syncTaskMR API.
- lib/state/slices/gitlab slice (taskMRs.byTaskId) wired through
  default-state, store.ts AppState, and slices/index exports.
- hooks/domains/gitlab/use-task-mr.ts: useWorkspaceMRs (fetch-on-
  workspace-switch) and useTaskMRs (per-task selector).
- components/gitlab/mr-topbar-button.tsx — single/multi MR variants,
  status icons mirroring pr-task-icon priority order.
- task-top-bar.tsx renders PRTopbarButton and MRTopbarButton side-by-
  side; each returns null when empty, so the union handles GitHub-only,
  GitLab-only, and multi-repo tasks without an explicit switch.
- kanban-board.tsx hydrates useWorkspaceMRs alongside useWorkspacePRs.

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

* feat(gitlab): add /gitlab page for browsing user MRs and issues

Adds the GitLab counterpart to /github so users can browse their own
merge requests and issues from inside kandev, parallel to the existing
GitHub "My GitHub" page.

Backend
- /api/v1/gitlab/user/mrs and /user/issues — paginated wrappers around
  the existing SearchMRsPaged / ListIssuesPaged client methods.
  paginationFromQuery clamps page/per_page the same way as the client.

Frontend
- /gitlab SSR page + client with MR/Issue tabs, scope filters
  (assigned / created / review-requested), and a free-text search box
  that falls back to raw GitLab query-string for power users.
- searchUserMRs / searchUserIssues API helpers + MR / Issue / *SearchPage
  types in lib/types/gitlab.
- useGitLabAvailable hook (probes /status + refreshes on window focus)
  feeds the integrations menu so the GitLab link only shows when a
  token is configured or auth is green.

Tests
- task-top-bar.test mocks the new MRTopbarButton so renders no longer
  pick up useAppStore without a StateProvider.
- integrations-menu.test wires the new useGitLabAvailable mock and
  gitlabReady prop into mockAvailability / getAvailableIntegrationLinks
  fixtures.

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

* fix(gitlab): stable empty array in useTaskMRs to avoid infinite re-render

useTaskMRs returned `state.taskMRs.byTaskId[taskId] ?? []` — that fresh
literal made the zustand selector produce a new reference on every
render when the task had no MRs, triggering an infinite re-render loop
in MRTopbarButton that crashed the task page with a Next.js client-side
exception.

The page never mounted session-chat, so Docker E2E tests that drive
`/t/:id` (slow-bootstrap progress, external-stop resume, environment
reset) all timed out waiting for the chat panel.

Mirror the GitHub side's `EMPTY_PRS` pattern: hoist a module-level
`EMPTY_MRS = []` and return it for the empty case.

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

* fix(gitlab): address PR review — cache header, in-flight cancel, tests

- Top-level `cache: "no-store"` in 5 sites (use-task-mr.ts, gitlab-api.ts,
  gitlab-page-client.tsx). The earlier `init: { cache: "no-store" }`
  placement was silently overwritten by fetchJson with undefined, so probes
  hit the browser cache instead.
- useWorkspaceMRs now invalidates in-flight responses and calls
  resetTaskMRs when the workspace becomes null, so the previous workspace's
  MRs don't linger after sign-out / workspace switch.
- Drop unpopulated UnresolvedThreads/DiscussionCount from TaskMR (model,
  schema, TS type). SyncTaskMR's source MRStatus never populated them and
  no surface reads them; the dropped columns are skipped via an explicit
  projection in store.go so existing dev DBs keep binding.
- Extract MRList/IssueList inline fetches in /gitlab into useGitLabUserMRs
  / useGitLabUserIssues — matches CLAUDE.md "never fetch in components".
- Tests for store (UpsertTaskMR, composite key, workspace-JOIN isolation,
  ordering, Delete), service (SyncTaskMR mock round-trip + field mapping +
  store-unset error, ListTaskMRsByWorkspace JOIN isolation, ListTaskMRsByTask),
  pat_client (ListIssuesPaged X-Total), gitlab-api (5 new fns), gitlab-slice
  (setTaskMR composite key), and the use-task-mr / use-user-search hooks.
- pr SKILL.md: skip note for steps 5–6 on GitLab remotes.

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

* fix(gitlab): only wrap 401/403 probe errors as ErrInvalidToken

Network failures, 5xx, and DNS errors during `ConfigureToken` previously
surfaced to the user as HTTP 400 "invalid token: …" — during a GitLab
outage a user with a valid token might delete it assuming it was rejected.
Now only `*APIError` with 401/403 is mapped to `ErrInvalidToken`; other
errors propagate unwrapped so the controller routes them to "GitLab
unreachable" instead.

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-23 23:17:16 +01:00
João Salavisa 1b58a64fd8 feat(automations): /settings/automations with run-mode + per-automation config (#1016)
* feat: add automation system with triggers, backend-driven metadata, and PR association

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

* feat(automations): execution_mode + flat /settings/automations

Layered on top of #406's automation subsystem:

- Add execution_mode to Automation (task | run, default task). Run-mode
  firings create an ephemeral kanban-hidden task tagged with
  origin=automation_run. The session pipeline still runs against it, but
  users surface them through the AutomationRun history instead of the
  kanban. Pragmatic shim while AutomationRun-as-true-session-owner is
  deferred to a follow-up.

- Thread IsEphemeral + Origin through ReviewTaskRequest +
  reviewTaskCreatorAdapter so the orchestrator can create the hidden
  task in one call.

- Run-mode always auto-starts (the user never sees the task to drag).

- Flat /settings/automations route replaces the per-workspace sidebar
  entry. Picks the only workspace, shows a picker for many, or shows
  a CTA for zero workspaces.

- Editor exposes execution mode + table shows it as a badge column.

Spec at docs/specs/office/automations-settings.md documents the design,
deferred items, and scenarios.

* feat(automations): per-automation repository picker

Adds a repository_id field on Automation so users can pin scheduled and
webhook automations to a specific workspace repository instead of always
falling through to "first repo". github_pr triggers ignore the field and
keep using the PR's own repo — the editor disables the picker with a
hint when a PR trigger is selected.

Backend:
- repository_id column folded into the canonical CREATE TABLE (no
  migration needed — PR #406 introduces the table).
- RepositoryID threaded through model, DTOs, store INSERT/UPDATE, and
  service.CreateAutomation.
- resolveAutomationRepository prefers the explicit ID, falls back to
  workspace-first on empty.

Frontend:
- repository_id on the Automation TS type + Create/Update payloads.
- Repository SelectField in ConfigSection backed by useRepositories;
  "Auto" sentinel represents the empty/fallback choice.
- Disabled with help-text for github_pr triggers.

* feat(automations): repo picker shows discovered repos too

Matches the task-creation dialog's UX: the editor's repository picker
now lists registered workspace repos AND filesystem-discovered repos
under the workspace's roots. Picking a discovered repo registers it
with the workspace at save time (via createRepositoryAction), captures
the new id, and writes it onto the automation. After the first save
the picker selection is promoted from "discovered" to "registered" so
re-saving doesn't re-register.

Form state holds a RepositorySelection union (auto | registered |
discovered) rather than a plain id string so the picker can carry the
metadata needed to register.

github_pr triggers still disable the picker — PR events always override
any configured repo with the PR's own.

* fix(automations): add idempotent ALTERs for in-branch columns

The canonical CREATE TABLE covers fresh installs, but `CREATE TABLE
IF NOT EXISTS` is a no-op for DBs already initialised from an earlier
commit on this branch (PR #406's original schema). Without these
ALTERs, dev DBs created before execution_mode + repository_id existed
on the row hit "no such column" errors.

Three idempotent ALTERs: task_title_template, execution_mode,
repository_id. SQLite returns a duplicate-column error when the column
already exists; we swallow it.

* fix(automations): use generateUUID in non-secure contexts

crypto.randomUUID is only defined in secure contexts (HTTPS or
localhost). PR #406's editor used it directly, which throws "crypto.
randomUUID is not a function" when the app is reached via a plain
hostname like nova:58204. Switch to lib/utils.generateUUID, which
falls back to a Math.random-based implementation when crypto isn't
available.

* fix(automations): polish run-mode lifecycle, validation, and editor UX

Run-mode automations were partly broken end-to-end:
- workflow_id was sent for ephemeral tasks (rejected by task service)
  and was required by the editor + automation service, so "Create
  Automation" was always disabled and silently failed
- a permission_request hanging the agent left the run pinned at
  task_created forever; nothing reaped the ephemeral worktree
- trigger config inputs (cron, branches, authors) did not re-sync
  with the loaded automation on page reload
- runs row never linked back to the underlying task for task-mode
  automations, and the table header highlighted on hover

Backend
- skip workflow fields in CreateReviewTask / StartTask for run-mode
- relax automation.Service.CreateAutomation: workflow only required
  for task-mode
- auto-reject permission prompts on ephemeral automation tasks and
  mark the AutomationRun failed with the requested action
- on agent completion / failure for ephemeral automation tasks,
  flip the run to succeeded/failed and remove the worktree + branch
  immediately (new RunStatusSucceeded + MarkRunSucceededByTaskID)
- wire worktree.Manager into the orchestrator service

Frontend
- show Execution Mode first; hide Workflow / Workflow Step in run-mode
- canSave no longer requires workflow in run-mode
- surface save errors via toast instead of silent failure
- sync trigger-config local input state with loaded prop value
- runs table: clickable row → /tasks/<id> for task-mode runs,
  non-clickable in run-mode; suppress header hover

* fix(automations): address PR review feedback

Backend
- automation/scheduler.go: parseCronFields returns an error for
  unsupported expressions instead of silently defaulting to hourly
- automation/webhook.go: constant-time secret compare, header-only
  (no more ?secret= query param), reject with 409 when no enabled
  webhook trigger exists
- automation/service.go: enforce max_concurrent_runs in FireTrigger
  via maybeSkipForConcurrencyCap (records a skipped run when capped)
- cmd/kandev/e2e_reset.go: surface automation delete errors as 500
  instead of swallowing them
- orchestrator/event_handlers_git.go: auto-reject permission with
  cancelled=true so the message status is "rejected" and the session
  isn't flipped back to running
- orchestrator/event_handlers_agent.go: finalizeAutomationRunIfEphemeral
  now runs at the top of handleAgentFailed, before the resume/recoverable
  early returns, so session-backed run-mode failures get cleaned up

Frontend
- state/slices/automations/automations-slice.ts: declare _get/_api so
  the call site's arity matches and CodeQL stops flagging ignored
  trailing arguments
- automations/schedule-selector.tsx: clearing the input now propagates
  ""; cron is trimmed before validation+save
- automations/triggers-section.tsx: switching to webhook mode deletes
  the existing scheduled/condition triggers so they don't keep firing
  hidden in the background
- automations/trigger-configs/github-ci-config.tsx: useEffect re-sync
  for checkNames, matching the other trigger configs
- hooks/domains/settings/use-automations.ts: per-workspace fetch via
  loadedWorkspaceRef + render-safe loadedWorkspaceId state;
  inFlightWorkspaceRef discards stale responses after workspace switch

---------

Co-authored-by: Carlos Florêncio <carlosmflorencio@gmail.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Kandev Agent <agent@kandev.dev>
2026-05-22 22:01:59 +01:00
Carlos Florêncio a2b4fc0c6d fix(web): keep sidebar 3-dot menu from overlapping the subtask toggle (#1012) 2026-05-22 15:36:46 +01:00
Carlos Florêncio c9b6fe4b8a fix(worktree): support fork PR worktree creation via pull refspec (#990) 2026-05-22 12:15:39 +01:00
João Salavisa f39e7b06ca fix(agent): honor session passthrough snapshot when starting agent process (#1003)
* fix(agent): honor session passthrough snapshot when starting agent process

Tasks created in agent (ACP) mode got stuck in "starting" after toggling
the profile's cli_passthrough flag and restarting the server.
StartAgentProcess re-resolved the live profile each launch, so the new
flag rerouted existing ACP sessions into the passthrough path (and vice
versa), where the workspace plumbing didn't match.

Snapshot session.IsPassthrough onto LaunchAgentRequest /
lifecycle.LaunchRequest / AgentExecution and let StartAgentProcess use
that as the source of truth. Sessionless launches (legacy
controller.LaunchAgent) still fall back to live profile resolution.

Fixes #981.

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

* fix(agent): make passthrough snapshot the sole routing signal in StartAgentProcess

Bot review feedback follow-up (#1003):

- Route on the snapshot alone (new routePassthrough helper). A transient
  ResolveProfile error no longer silently routes a passthrough session
  to ACP — the fresh path surfaces an explicit error, the resume path
  surfaces the existing resolvePassthroughAgent error.
- Add TestStartAgentProcess_PassthroughResume_SurvivesProfileResolveError
  and TestStartAgentProcess_FreshPassthrough_SurfacesProfileResolveError
  to lock the no-silent-ACP-fallback invariant.
- Add TestLaunchPreparedSession_PropagatesIsPassthrough mirroring the
  resume-path propagation test so both build paths are independently
  covered.
- Strip issue-number references from source/test comments per project
  convention.

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

* fix(agent): avoid double ResolveProfile in sessionless passthrough path

routePassthrough now returns (bool, *AgentProfileInfo). When the
sessionless fallback path already resolved the profile, it threads it
back so StartAgentProcess can reuse it for command building instead of
calling ResolveProfile a second time. Snapshot-driven routing still
returns nil so the snapshot path resolves fresh — the snapshot
deliberately outlives any particular live profile state.

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

* fix(agent): extract startPassthroughExecution to satisfy nestif

CI's golangci-lint flagged the nested-if complexity of the passthrough
dispatch block in StartAgentProcess after the previous round of fixes
pushed it past nestif's threshold of 5. Move the resume-vs-fresh
branching plus the lazy profile resolution into a startPassthroughExecution
helper so StartAgentProcess becomes a single short routing decision.

No behavior change.

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

* docs: drop caller reference from startPassthroughExecution comment

Per project convention, source comments shouldn't name the calling
function — the reason it's its own method is captured by its contract
(profileInfo nullability + routePassthrough handoff), not by the
caller's name.

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-21 23:39:23 +01:00
João Salavisa 0f1081d423 feat(share): add public share links via GitHub Gists (#995)
* feat(share): add public share links via GitHub Gists

Adds a Share button to the chat panel header that publishes the
current session as a secret GitHub Gist on the user's account. The
share link routes through gistpreview.github.io to render share.html
as a styled kandev-themed page; snapshot.json + README.md ride along
for machine-readable + fallback views.

Snapshots run a redaction pass (absolute paths, common credential
shapes, .env contents, shell env vars) and strip <kandev-system>
prompt blocks via the shared sysprompt helper. Message filtering
whitelists user prose + tool_* types so internal noise never leaks
into shares.

Spec at docs/specs/public-share-links/spec.md.

* fix(share): coerce pendingClarification to bool for ClarificationSection

A late refactor extracted ClarificationSection with `pendingClarification: boolean`
on its props, but the call site passes the actual Message|null value. CI's tsc
build catches the mismatch; runtime was unaffected because the section only uses
the value in a truthiness check. Coerce with Boolean() at the call site to keep
the prop's intent narrow.

* fix(share): address PR #995 review feedback

Bugs (flagged by Greptile, Cubic, Claude):
 - abs-path redaction split on `root + "/"` so sibling dirs like
   /home/user/proj2 don't get corrupted when workspaceRoot is
   /home/user/proj; regression test added.
 - GHClient.DeleteGist now wraps "HTTP 404" stderr as *GitHubAPIError so
   share.IsAlreadyGone matches consistently with the PAT client; this
   restores the "gist already revoked upstream" soft-success path for
   CLI-authenticated users.
 - SnapshotSizeBytes is now measured from the same MarshalIndent payload
   the gist backend uploads, so the size shown in the UI matches the
   actual gist bytes (was ~20-40% underreported).

Polish (flagged by CodeRabbit, Cubic):
 - Tool name + tool output now HTML-escaped; tool call args and output
   render via <pre><code> instead of triple-backtick fences so payloads
   containing ``` can't break out of the code block.
 - share-list.tsx: clear `busy` via finally and break error layout out
   of the justify-between row.
 - use-shares.ts: convert UseSharesResult to interface (repo TS rules);
   reset error+isLoading when ids are missing.
 - use-shares.test.tsx: wait on the actual async outcome (shares
   populated, listShares called) instead of isLoading flipping false.
 - provider.go: replace `repo.Close` cleanup with an explicit noop
   closure so the comment ("Cleanup is a no-op") and the impl match.
 - spec.md: aligned the shareability rule across What/Failure/Scenarios/
   Out-of-scope — any non-prehistory state can be shared; added language
   tags to fenced code blocks for markdownlint.

Skipped intentionally (reply on each thread):
 - HTML-escape every metadata badge: the values are controlled enum
   strings from kandev's own schema, not user input. Defense-in-depth
   noise. Tool name (which can come from agent runtime) IS escaped.
 - useShares request-token guard against stale responses: the dialog
   owns the hook lifecycle and opens against a single (taskId,
   sessionId) — the race the comment describes can't occur here.

* fix(share): redact paths for every worktree, not just the first

A single TaskSession can carry multiple worktrees when the task spans
multiple repos. The redactor previously only saw the first worktree
path, so absolute paths from secondary worktrees leaked into public
shares instead of being rewritten to repo-relative form.

NewRedactor is now variadic and applies the abs-path rule against every
root it receives, sorted longest-first so a root that is a prefix of
another (e.g. /workspace vs /workspace/repo1) gets matched by the more
specific one first. workspaceRootFor in builder.go becomes
workspaceRootsFor and emits every WorktreePath plus the WorkspacePath
fallback.

Flagged by Cubic on PR #995.

* fix(share): address PR #995 review round 3

Bugs (flagged by Cubic + Claude on gh_client.go:714):
 - DeleteGist now calls the existing isNotFoundErr helper instead of an
   inline strings.Contains(err, "HTTP 404"). The helper also covers
   "404 Not Found" and "status: 404", which some gh versions emit, so
   the share service's "gist already revoked upstream" soft-success
   path now fires consistently across CLI auth users.

Bugs (flagged by Claude on builder.go:220):
 - toolCallBlock used to read metadata["tool_name"] and metadata["args"]
   but production never writes those keys; the typed payload lives under
   metadata["normalized"] (a *streams.NormalizedPayload) and the tool
   kind is on Message.Type. Switched to deriving ToolName from
   strings.TrimPrefix(m.Type, "tool_") (gives "read", "execute", "edit",
   …) and reading args from metadata["normalized"], with backward-compat
   fallback to the older "tool_name"/"args" keys for the debug-fixture
   replay path. New TestBuildSnapshot_ToolCall_ProductionMetadataShape
   exercises the real session shape.

Bugs (flagged by CodeRabbit on redaction.go:110):
 - Bare workspace-root occurrences (pwd output, end-of-sentence, quoted
   paths) used to leak because the matcher only handled root + "/".
   Switched to a precompiled regex per root —
   regexp.QuoteMeta(root) + `(?:/|\b)` — so both `root/...` and standalone
   `root` get stripped, while sibling dirs like /workspace2 still don't
   match. Test TestRedactor_AbsPath_StandaloneRootStripped covers the
   bare-root variants.

Doc (flagged by Claude on spec.md):
 - Two malformed fence closers (`go and `text on closing fences) corrected
   to plain ``` so the file is markdownlint-clean.

* fix(share): narrow abs-path boundary to avoid sibling-prefix corruption

Round 4 review (Claude + CodeRabbit) caught a regression in the regex
introduced by 436cb66: `(?:/|\b)` used `\b` (word boundary) to catch
bare-root cases, but `\b` fires on any word↔non-word transition. So a
sibling path like `/workspace.bak`, `/workspace-old`, or
`/workspace-other` would false-match the `/workspace` root (since
e→. and e→- are word boundaries), stripping it and leaving a
corrupted `.bak` / `-old/file.ts` in the share.

Replaced the matcher with `(/|\s|$)`: path separator, whitespace, or
end-of-string only — never punctuation or quote chars. The boundary
is captured and re-emitted via ReplaceAllStringFunc (or dropped, in
the `/` case) so prose loses only the root, not the surrounding space.

Added TestRedactor_AbsPath_SiblingsWithPunctuationNotCorrupted covering
`.bak`, `-old`, `_alt` siblings; the digit-sibling test still passes.
The quoted-bare-root case (`"/workspace"`) no longer matches and was
dropped from the standalone-root test — contrived enough that a small
leak there is preferable to corrupting realistic sibling paths.

* refactor(share): move share button into chat status bar

Replaces the dedicated PanelHeaderBarSplit above the message list with
a slot in ChatStatusBar (the existing row holding TodoIndicator and
PRStatusChip just above the chat input). The button anchors right via
ml-auto; when the "proceed to next step" affordance is present it
takes the right anchor instead and the share button stays inline.

Also tightens the iconOnly variant in ShareButton to a ghost style
that matches the muted-foreground chips in the status bar, and exports
shareableSessionStateClient from share-button.tsx so the gating logic
lives next to the entry point it controls.

Net -23 lines in task-chat-panel.tsx: the extra header bar and the
local gate helper are gone.

* docs(share): broaden tooltip + add share/ to CLAUDE.md tree

The share tooltip read "Share this completed task with a public link",
but shareableSessionStateClient allows RUNNING / IDLE / WAITING_FOR_INPUT
/ FAILED / CANCELLED too. A user hovering mid-run saw misleading copy;
reworded to "Share this task conversation as a public link" which
matches the actual gate.

Also added the missing internal/task/share/ entry to the task/ subtree
in CLAUDE.md so the new package shows up in the repo map — flagged
across multiple rounds.

* docs: add internal/task/share/ to repo map

Carryover from the share PR — AGENTS.md (which CLAUDE.md symlinks to)
was missing the share/ subpackage under task/. Adding it now so the
repo map matches what's checked in.

---------

Co-authored-by: Kandev Agent <agent@kandev.dev>
2026-05-21 23:35:41 +01:00
José Almeida 1f264adbc4 fix(github): drain piled-up PR/issue review tasks via cleanup policy + manual sweep (#929) 2026-05-21 08:46:50 +01:00
Carlos Florêncio a74409af3c fix(web): cap clarification overlay height and add mock agent ask commands (#956) 2026-05-19 22:27:24 +01:00
Carlos Florêncio 2bfdd518b6 fix(web): improve inline code chip visibility in markdown (#948) 2026-05-19 17:56:35 +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 e346572371 chore(deps): migrate ACP SDK from agentcooper fork to upstream v0.13.0 (#921) 2026-05-17 19:51:24 +01:00
Carlos Florêncio 0b2085688b feat(office): autonomous agent management layer (#914) 2026-05-16 20:07:57 +01:00
Carlos Florêncio a5108de926 fix(windows): make conpty Close idempotent and kill orphan agentctl (#900) 2026-05-15 16:04:35 +01:00
José Almeida f3397b4ad7 feat: docker and sprites executor improvements (#738)
* feat(orchestrator): generalize task environment reuse across executor types

Worktree, container, and sandbox IDs now flow uniformly from a persisted
TaskEnvironment back into LaunchAgentRequest through a single helper. This
gives Sprites session-to-session reuse the same first-class treatment that
Docker already gained from the GitHub-token-injection PR, and lets a fallback
launch refresh the environment row instead of leaving stale IDs in the DB.

- applyExistingEnvironment propagates WorktreeID, ContainerID (via metadata),
  and SandboxID (as sprite_name metadata) onto the launch request
- persistTaskEnvironment refreshes those IDs when the executor ends up
  creating new resources (e.g. the previous container was pruned)
- extractSandboxID lifts the sprite identifier out of executor response
  metadata so it can be persisted on the TaskEnvironment row

* feat(backend): add task-level reset environment action

Users sometimes need a clean slate — broken packages, stale caches, a
container that drifted into a bad state. Reset Environment is the explicit
escape hatch: tear down the recorded container, sandbox, and/or worktree
for a task and delete the TaskEnvironment row so the next session starts
fresh.

- service.ResetTaskEnvironment(taskID, ResetOptions) is the entry point.
  ResetOptions.PushBranch optionally git-pushes the workspace branch
  before teardown so committed work isn't dropped on the floor; a failed
  push aborts the reset and leaves the environment intact.
- A new EnvironmentDestroyer interface in the task service hides the
  per-resource teardown behind a single seam, implemented in cmd/kandev
  by delegating to lifecycle.Manager (containers, sprites) and
  worktree.Manager (worktrees, branch preserved).
- lifecycle.Manager.DestroyContainer / DestroySandbox dispatch to the
  Docker and Sprites executor backends without leaking model types into
  the lifecycle package.
- Reset is blocked while any session on the task still has an
  ExecutorRunning row (returns ErrSessionRunning → HTTP 409). Per-resource
  teardown is best-effort but the DB row is only removed once everything
  is confirmed gone or already missing, so a partial failure is retryable
  by clicking Reset again.
- Endpoint: POST /api/v1/tasks/:id/environment/reset.

* feat(web): add executor settings popover with reset environment action

A new button on the task top bar opens a popover showing the active
TaskEnvironment — executor type, status, container/sprite/worktree
identifiers, and a copy-ready 'docker exec' command for local Docker.
The popover hosts the destructive Reset Environment action so users have
one obvious place to inspect and manage the environment without
cluttering the New Session dialog.

The reset confirmation dialog requires explicit acknowledgement that
uncommitted changes will be lost. When the environment has a host-side
worktree it offers an opt-in 'push branch before resetting' checkbox so
committed work can be pushed first; the backend aborts the reset on
push failure.

- task-environment-api.ts: fetchTaskEnvironment + resetTaskEnvironment
- executor-settings-button.tsx: popover trigger + identity panel
- task-reset-env-confirm-dialog.tsx: destructive confirm with push opt-in
- task-top-bar.tsx: wire the new button into the right-side controls
- e2e: replace fresh-env-toggle test with reset-from-popover flow

* fix(docker): expand {workspace} placeholder in container WorkingDir

The agent runtime config carries `WorkingDir: "{workspace}"` for most ACP
agents. Mount sources were already expanded via expandMountSource, but
WorkingDir was passed through verbatim, so Docker rejected new containers
with:

  Error response from daemon: the working directory '{workspace}' is
  invalid, it needs to be an absolute path

Run the same expansion on WorkingDir, defaulting to /workspace when no
host WorkspacePath is set (the clone-inside-container case introduced by
PR #654).

* fix(docker-profile): persist Dockerfile/image_tag and apply tag override at runtime

Two bugs:

1. The Dockerfile editor and Image Tag input on the executor profile page
   stored their values in component-local useState inside DockerSections,
   not in the form state. Saving the profile never wrote them back, so
   "Use defaults" / typing in the editor / changing the tag never
   persisted — and the Build button was the only thing that consumed
   them. The same gap existed in the new-executor flow's
   buildProfileConfig.

   Lift `dockerfile` and `image_tag` into useProfileFormState (edit) and
   useCreateProfileFormState (new), and emit them in buildSaveConfig /
   buildProfileConfig so they round-trip through profile.config.

2. Even when an image_tag is set on the profile, the container builder
   ignored it. imageName was always derived from the agent runtime's
   hardcoded `kandev/multi-agent`, so a user who built a custom image
   like `kandev/agent:latest` would still see Docker fail with
   "No such image: kandev/multi-agent:latest".

   Propagate `profile.config.image_tag` → metadata
   (`image_tag_override`) → ExecutorCreateRequest → ContainerConfig,
   and let it override `rt.Image` in buildContainerConfig when set.

Also align the frontend default image tag (kandev/multi-agent:latest)
with the backend's hardcoded runtime default so users who don't override
end up with a working build.

* test(docker): cover container config build + Docker profile persistence

Adds two regression tests for the recent Docker fixes:

- container_config_test.go (Go unit): asserts buildContainerConfig
  expands `{workspace}` in WorkingDir (defaulting to /workspace when no
  host WorkspacePath is set) and that ContainerConfig.ImageTagOverride
  takes precedence over the agent runtime's hardcoded `rt.Image`. No
  Docker daemon required.

- docker-profile-persistence.spec.ts (Playwright E2E): creates a
  local_docker executor + profile, opens the profile edit page, clicks
  "Use defaults" to populate the Image Tag input + Dockerfile editor,
  hits Save, then refetches the profile via API and reloads the page to
  verify both fields round-trip through `profile.config`. Catches the
  prior regression where DockerSections owned the form state locally
  and never wrote it back. Runs against the existing E2E backend that
  has KANDEV_DOCKER_ENABLED=false — we only test the persistence path,
  not container creation.

Adds an api-client helper (`getExecutorProfile`) for the round-trip
assertion.

* feat(docker): self-contained Dockerfile + mount agentctl + capabilities adapter

Make the Docker executor work with a Dockerfile users can actually build,
without depending on a maintainer-shipped image:

- The container now launches a small bootstrap (`sh -c "$KANDEV_PREPARE_SCRIPT
  ; exec /usr/local/bin/agentctl"`) regardless of the image's ENTRYPOINT.
  The agent CLI is no longer wired through container Cmd; agentctl receives
  it later via its CreateInstance HTTP API.
- Host agentctl-linux binary is mounted into the container at
  /usr/local/bin/agentctl via the existing AgentctlResolver. User-built
  images don't have to bake it in.
- Default Dockerfile in the executor profile UI replaced with a working
  node:22-slim + git + curl image — runs npx-based ACP agents (Claude,
  Codex, OpenCode, Auggie) out of the box.
- lifecycleAdapter now exposes RequiresCloneURL / ShouldApplyPreferredShell
  so the orchestrator can correctly identify containerized executors and
  resolve the clone URL. Without this, applyRepositoryConfig short-circuits
  on `e.capabilities == nil`, the cloneURL is never resolved, and the
  prepare script fails with `repository '{{repository.clone_url}}' does
  not exist`.
- applyRepositoryConfig also surfaces the resolved clone URL to script
  metadata so the prepare script can interpolate {{repository.clone_url}}.
- docker.ContainerConfig gains an Entrypoint field, plumbed to the docker
  client.

Verified end-to-end on a local stack with the new minimal image: the
container starts, bootstrap clones the GitHub repo, agentctl launches and
hands off to claude-acp inside the container.

* fix(docker): patient health check + resilient bootstrap

Two follow-ups to the Docker bootstrap work:

- The 30 retries × 500ms = 15s budget for agentctl health was too tight.
  In the previous flow agentctl was a separate exec from the prepare
  script, so it came up immediately. Now it runs after the prepare
  script (which does git clone, optional installs), so first launch can
  easily exceed 15s and produce a misleading "agentctl not healthy after
  30 retries" error. Bumped to 240 retries (120s) and surfaced the last
  underlying error in the message so failures point to the real cause.

- Prepare scripts commonly start with `set -e`/`set -eu`; running them
  via `eval` propagated the option to the outer bootstrap shell, so any
  failure killed the bootstrap before `exec /usr/local/bin/agentctl`.
  Run prepare in a subshell so its `set -e` is contained, and start
  agentctl regardless. If prepare fails, agentctl still comes up — the
  host can connect, surface the failure, and let the user debug from
  the Executor Settings popover.

* fix(executor-profile): persist remote auth secrets/credentials for Docker profiles

The Remote Credentials card (per-agent secret picker, plus the GitHub
credential checkbox) is rendered for any remote executor — Sprites,
local_docker, or remote_docker — but the save path in both the create
and edit pages only emitted `remote_auth_secrets` and `remote_credentials`
when `isSprites` was true. Selecting a secret for the Claude / Codex /
Auggie ANTHROPIC_API_KEY-style entry on a Docker profile updated form
state, the dropdown showed it as picked, but Save Changes silently
dropped the value before posting to the API. On reload the agent showed
as "Not configured", and any session start failed with
`{"code":-32000,"message":"Authentication required"}`.

Switch the guards to `isRemote` so the same data persists for all remote
profile types. The backend orchestrator already propagates
`profile.config.remote_auth_secrets` and `remote_credentials` through to
metadata for any containerized executor and resolves them via
`applyContainerCredentials` → `resolveAuthSecrets`, so no backend change
is needed once the values actually land in the saved profile.

* fix(executor-profile): live Configured badge + session/load resilience + live container status

Three independent fixes that all surface together while iterating on the
Docker executor flow:

- Remote Credentials card: the per-agent "Configured" / "Not Configured"
  badge was driven by useState that only seeded once on mount. Picking a
  secret in the dropdown updated the parent's `agentEnvVars` map but the
  local `choice` never moved to `"env"`, so the badge stayed grey until
  the page was reloaded. Derive `choice` straight from props every render
  so the badge tracks live state.

- ACP session/load: when the agent process restarted between turns
  (common with claude-agent-acp because its session map is in-memory),
  the host's session/load call failed with JSON-RPC -32002 "Resource not
  found" and the entire launch errored out. Treat that as the same kind
  of "session is gone" signal as -32601 / capability=false and fall back
  to session/new. Worst case the agent loses prior context; best case
  the user just keeps typing.

- Executor Settings popover: dropped Execution ID and Environment ID
  rows (debug-only noise users don't need), kept Container ID + the
  copyable `docker exec -it … bash` line, added a live status badge
  with tones for running / exited / paused / dead / error / missing,
  and a 3-second polling interval so the badge follows the real
  container state. New `GET /api/v1/tasks/:id/environment/live`
  endpoint plumbs Docker `inspect` data through the lifecycle manager
  → task service → handler. ContainerLiveStatus mirror type at the
  service layer keeps lifecycle types out of the public surface.

* feat(web): gate agent profiles by executor profile credentials

Filter the agent profile dropdown in both the Create Task dialog and the
New Session dialog to only show agents that are actually configured for
the selected executor profile.

For local/worktree executors, no gating (agents need no extra creds).
For Docker/Sprites profiles, an agent is "configured" iff one of its
remote-auth methods has a value in `remote_credentials` or a non-null
secret in `remote_auth_secrets`. Agents without an entry in the remote
auth catalog (Copilot, Amp, OpenCode, TUI, mock) cannot carry creds on
remote executors and are blocked.

Spec IDs match `AgentProfileOption.agent_name` (the registry-type string
like "claude-acp"); `agent_id` is a DB UUID and unrelated to the catalog.

The Create Task footer surfaces a "No agent profile is configured for X"
tooltip and disables submit when the selected executor profile has no
compatible agents. The New Session dialog auto-swaps to the first
compatible agent and shows the same banner inline.

`useRemoteAuthSpecs` returns a `loaded` flag so callers don't filter
during the initial fetch (would otherwise hide everything).

* feat(docker): make mock-agent runnable inside Docker for E2E

Lay the backend groundwork so the existing mock-agent can run inside
local_docker containers — needed for real-Docker E2E tests that don't
require Anthropic/OpenAI API keys.

Container builder bind-mounts a host mock-agent binary at
/usr/local/bin/mock-agent when KANDEV_MOCK_AGENT_LINUX_BINARY is set
(or build/mock-agent-linux-amd64 sits next to the kandev binary), the
same pattern already used for agentctl. MockAgent.BuildCommand always
resolves via PATH so the same command works on host (E2E PATH includes
bin/) and in containers (mounted at /usr/local/bin/mock-agent).

Mock now declares non-nil empty RemoteAuth so it counts as a
remote-capable agent. The remoteauth catalog includes specs even with
zero methods, and the frontend gating helper treats an empty-method
spec as "no credentials needed" — letting mock-agent pass the
configure-on-executor check that hides uncatalogued agents on remote
executors.

repositoryCloneURL falls back to the local origin remote URL when the
repo has no provider info, so local-only repos with a real remote
(including file:// remotes used in E2E) work on remote executors that
clone inside the container.

Makefile: build-mock-agent-linux target mirrors build-agentctl-linux.

* test(docker-e2e): add real-Docker Playwright project + first spec

A new Playwright project — \`docker\` — runs container-bound E2E specs
against a backend with KANDEV_E2E_DOCKER=1. The fixture probes for a
Docker daemon and skips the worker entirely when none is reachable, so
contributors without Docker can still run the rest of the suite.

The fixture builds a tiny kandev-agent:e2e image once per machine
(node:22-slim + git/curl), pre-seeds a local_docker executor profile,
and seeds a local repo with a file:// origin so the executor's
clone-inside-container path resolves without network access. A small
WS-based session.launch helper is added to the api-client for tests
that need to launch additional sessions on an existing task.

First spec — docker-launch.spec.ts — covers the smoke path: a session
launches in a real container, the task environment records its
container_id, and \`docker inspect\` confirms the container exists.
Two follow-up specs (multi-session env reuse + recovery from external
container removal) are wired up but marked test.fixme with comments
pinpointing the backend gaps that block them: persistTaskEnvironment
isn't called for workflow-prepared sessions (so task_environment_id
stays empty on those rows), and DockerExecutor.reconnectToContainer
loses the agentctl handshake auth token across launches (so reconnect
401s and falls back to a fresh container every time).

Run locally with: \`pnpm --filter @kandev/web exec playwright test
--config e2e/playwright.config.ts --project=docker\`.

* ci(e2e): add dedicated Docker E2E job

The new \`docker\` Playwright project needs a Docker daemon and the
linux/amd64 agentctl + mock-agent binaries bind-mounted into containers.
Pin the existing sharded job to chromium + mobile-chrome so the docker
project no longer leaks into every shard (where it would fail seeding
without KANDEV_DOCKER_ENABLED), and add an \`e2e-docker\` job that:

- Verifies the Docker daemon is reachable on the runner.
- Downloads the same backend artifact (now including the linux helper
  binaries, built via \`make build-backend-linux-helpers\`).
- Runs \`playwright test --project=docker\` with KANDEV_E2E_DOCKER=1.
- Cleans up any kandev-managed containers at teardown so leaks don't
  poison the next run.

* ci(e2e): include docker job in merged HTML report

Upload the docker job's blob report under the \`blob-report-*\` glob the
merge job already consumes, and add e2e-docker to e2e-report's needs so
the final HTML report covers both the sharded chromium runs and the
docker run.

* ci(e2e): add workflow_dispatch for ad-hoc runs and draft-PR verification

* fix(build): always produce linux/amd64 agentctl as part of \`make build\`

Remote executors (local Docker, remote Docker, Sprites) bind-mount the
linux/amd64 agentctl binary into the container. On non-Linux hosts (e.g.
macOS dev installs) running plain \`make build\` produced only the host
binary, so the first Docker session would fail with:

  failed to build container config: agentctl linux binary not found

The top-level \`make dev\` already special-cased non-Linux hosts to chain
\`build-agentctl-linux\`, but \`make build\` and \`make start\` skipped it.
Add it as a dependency of the standard backend build so every install
ships a usable linux variant. Cross-compiling agentctl is fast (~1-2s)
and the binary is ~20 MB, so the cost is negligible.

Users who hit the error can run \`make build-backend\` (or \`make build\`
in apps/backend) to regenerate; setting KANDEV_AGENTCTL_LINUX_BINARY
remains a manual override.

* fix(release): ship linux/amd64 agentctl in every release artifact

Remote executors (local Docker, remote Docker, Sprites) bind-mount the
linux/amd64 agentctl into the container. The release pipeline only
shipped a host-arch agentctl, so users who installed via the bundled
binaries (npm/Homebrew/etc.) hit the same "agentctl linux binary not
found" error the first time they tried a Docker executor — even on
Linux, since the resolver expects the -linux-amd64 suffix.

Changes:

- .github/workflows/release.yml: cross-compile agentctl-linux-amd64 in
  the build-bundles matrix step (CGO off so it works from every host).
  Bundle ships kandev + host agentctl + agentctl-linux-amd64 in
  dist/kandev/bin/. The AgentctlResolver finds it next to the kandev
  binary automatically.
- Dockerfile: same idea for the container image — produce both variants
  in the Go builder stage and copy the linux-amd64 helper into
  /app/apps/backend/bin/ so the resolver picks it up inside the image.

A previous commit added the same dependency to \`make build\` for local
dev installs; this commit closes the gap for end users.

* fix: address PR review feedback (CI, CodeRabbit, Greptile, Claude)

Backend:
- service_task_environments: fail closed when checkAnySessionRunning
  errors so a transient DB hiccup can't bypass the running-session
  guard and tear down a live container/sandbox; teardown is now truly
  best-effort with errors.Join across container/sandbox/worktree, so a
  failed Docker call doesn't permanently orphan the worktree on retry.
  Adds tests for the running-check error path and the multi-resource
  best-effort path.
- executor_execute: propagate GetTaskEnvironmentByTaskID errors instead
  of silently launching a fresh environment that orphans existing
  containers/worktrees; gate environment reuse on matching ExecutorType
  so a profile switch doesn't pass stale PreviousExecutionID/container
  IDs to the wrong backend.
- task_environment_handlers: reject malformed JSON on POST
  /tasks/:id/environment/reset (still allow empty body) so a destructive
  endpoint doesn't silently proceed with a bad payload.
- worktree (PushEnvironmentBranch): bound git push with a 30s context
  timeout and disable interactive prompts via GIT_TERMINAL_PROMPT=0
  so a stalled remote can't hang the request thread.
- manager_environment: explicitly call ensureClient() before
  ContainerMgr() in DestroyContainer/GetContainerLiveStatus so a missing
  Docker daemon surfaces as "docker unavailable" rather than the
  generic "container manager not initialized".
- container: WorkingDir always uses the in-container /workspace path
  rather than the host workspace path (Docker would happily start in an
  unrelated directory in host bind-mount mode); bootstrap now logs the
  prepare-script exit code so failures show up in container logs.
- docker/client: CreateContainerInteractive honors ContainerConfig.Entrypoint
  so the same config struct produces consistent behavior on both creation
  paths.

Frontend:
- executor-settings-button: extract useTaskEnvironment hook (component
  shrinks below the 100-line limit); only treat 404 as empty env (other
  errors no longer wipe a valid environment); track "loaded once" via a
  ref so loadEnv doesn't depend on env state — that dep was creating a
  new function identity on every successful poll, cancelling the
  3-second interval and turning the popover into a tight loop hammering
  /environment/live; copy-shell command uses sh instead of bash so it
  works on user-built minimal images.
- task-reset-env-confirm-dialog: AlertDialogAction onClick calls
  e.preventDefault() so Radix's auto-close doesn't dismiss the dialog
  before the async reset completes (loading spinner now visible; close
  is parent-controlled via onOpenChange).
- remote-credentials-card: track env method in selectedIds the same way
  files/gh_cli_token are tracked, and let initialChoice match either
  selectedIds OR envSecretId. First-time env-credential setup for an
  agent that exposes both files+env was broken: clicking the env radio
  never updated state, choice re-derived to "none", and the secret
  picker disappeared before the user could pick anything.
- e2e/executor-reuse: select the acknowledge checkbox by label, not by
  positional last(), so adding another checkbox to the dialog can't
  silently target the wrong control.

Format pass on prettier-flagged files; gofmt fixed two whitespace
nits in lifecycle files.

* test(executor): cover applyExistingEnvironment executor-type mismatch guard

* fix(docker): connect agentctl through published ports

* fix(web): keep remote auth selection stable

* fix(docker): reuse task containers across sessions

* fix(docker): resume task environment after refresh

* fix(docker): track executor containers for cleanup

* fix(docker): explain missing agent credentials

* fix(docker): surface container launch progress

* fix(docker): stabilize container reuse and feedback

* fix(docker): show task titles in container lists

* fix(docker): use container icon for docker tasks

* fix(orchestrator): align environment reuse after rebase

* fix(sprites): reconnect sessions on existing sandboxes

* fix(docker): use container icon for local docker executor too

Local docker tasks were rendering the Docker brand "chip" mark while
remote docker uses the container glyph. Map both variants to
IconContainer so the executor surfaces look consistent.

* feat(sprites): provision a fresh sandbox when the persisted one is gone

Resuming a Sprites task whose sandbox was destroyed upstream returned
"sprite not found" and dead-ended the user. Detect the upstream 404 via
a typed ErrSpriteNotFound sentinel, mark the reconnect step skipped with
a user-facing warning explaining what happened, swap the prepare plan
in place to the fresh-provision shape, and continue the launch under a
new sandbox name on the same branch.

The progress plan is now mutable so the reporter can stream the fresh
steps live after the reconnect-then-fresh transition. Only the explicit
not-found case triggers fallback; other reconnect failures still
surface as before, and the fallback fires at most once per launch.

* feat(web): friendlier header for sandbox-recovered prepare runs

When the only warning on a completed prepare is the missing-sandbox
fallback notice, render "Environment prepared on a fresh sandbox" in
the header instead of the generic "Environment prepared with warnings".
The fallback row still carries its own amber warning callout so the
user understands why a fresh sandbox was provisioned.

* feat(web): unify executor surfaces in the right-side popover

The desktop task top bar carried a small cloud icon (RemoteCloudTooltip)
in the breadcrumb cluster while the right-side Executor popover showed
the same kind of info — two surfaces for one concept. Remove the cloud
icon from the desktop top bar; kanban cards, task list rows, and the
mobile top bar keep theirs since they have no popover.

The popover button now picks its icon by executor type (cloud for
sprites, container for docker, server for local PC) using a shared
getExecutorStatusIcon helper. While the prepare progress is preparing
the icon swaps to a spinner so the user can see at a glance that work
is in flight.

The popover content gains a Preparing-environment section that streams
"Step N of M: <step>", an amber inline note when the missing-sandbox
fallback is in progress, an Environment-ready row with duration on
success, and a failed-step summary on failure. Polling promotes to the
active 3s cadence while preparing so the icon flips to ready without
the user opening the popover.

* fix(docker): swap container glyph for a plain box icon

IconContainer's vertical ribbing reads as a microchip on small sizes.
IconBox is an unambiguous outlined cube. Use it for both docker
variants so the executor surfaces look like a box, not a chip.

* fix(orchestrator): preserve base branch on resume for clone-based executors

Sprites and Docker resumes were sending the launch request with an empty
BaseBranch, so the in-sandbox prepare script's `git clone --branch <X>`
shifted positional args and failed with "repository '/workspace' does
not exist". applyResumeRepoConfig only set BaseBranch inside the
worktree-specific block; widen that to all executors that require a
clone URL.

Local executors are intentionally left untouched: LocalPreparer reads
BaseBranch and forces a `git fetch && git checkout` against the user's
own workspace, which would clobber the "use current state" path.

* feat(sprites,docker): generate a kandev feature branch for clone-based executors

Sprites tasks committed straight onto the chosen base branch because
SpritesPreparer didn't populate WorktreeBranch and the orchestrator's
"forward env.WorktreeBranch on reuse" check only fired for Docker.

Mirror the Docker preparer's TaskBranchName generation in Sprites,
extract a shared `nonWorktreeTaskBranch` helper that uses a
deterministic suffix derived from the task ID (so legacy env rows that
predate this change resolve to the same branch on every resume), and
let the orchestrator forward env.WorktreeBranch for any clone-based
executor — Docker or Sprites.

* fix(sprites,docker): prefer existing remote feature branch on resume

When a sandbox is destroyed and we provision a fresh one for the same
task, the agent may have previously committed and pushed onto the
feature branch. The previous prepare scripts only ran `git checkout -b
{{worktree.branch}}` which would overwrite that progress with a fresh
branch off the chosen base.

Try `git fetch origin <feature>` first; on success, `git checkout -B`
against the remote tip so the agent picks up where it left off. Falls
back to a fresh local branch when origin has nothing.

Also add the same checkout block to the Sprites prepare script — it was
missing entirely, so every Sprites session was committing onto main.

* test(sprites): lock in informative branch name in fallback warning

Now that BaseBranch is preserved on resume for clone-based executors,
the missing-sandbox fallback warning detail names the actual branch
("Cloning … on the same branch main") instead of the "(unknown)"
placeholder. Add a regression test so a future change to the resume
metadata propagation can't silently regress that copy.

* fix(sprites): preserve sandbox on plain agent stop so resume is fast

StopInstance was calling sprite.Destroy() unconditionally, so every
session stop tore down the cloud sandbox. Resume then always fell into
the missing-sandbox fallback, re-cloned the repo, re-ran the prepare
script, and lost the agent's working tree. That's a 30s+ round-trip
for what should be "stop the process, then re-attach later".

Limit Destroy to terminal lifecycle reasons (task/session
deleted/archived), the same set runTerminalCleanupScript already uses.
Plain stops just close the proxy and leave the sandbox alive.

Also update the existing Docker e2e regex: the feature-branch suffix is
now a deterministic 6-hex-char prefix of the task UUID rather than a
random 3-char tail, so resume on legacy env rows lands on the same
branch.

* test(docker): cover stop + resume → same container, branch preserved

Adds a Docker e2e test that exercises the user-visible stop-then-resume
flow: launch session, stop it via WS session.stop, launch a fresh
session on the same task, and assert the same task environment +
container ID are reused and the kandev feature branch is preserved.

Also nudge the executor popover padding: when the prepare-status
section is rendered above the Sprites/Docker info block, the cumulative
top+bottom paddings looked too generous around "Sprites sandbox" /
"Local Docker". Tighten both sections vertically without affecting the
horizontal rhythm or the bottom action row.

* feat(web): show "Resuming session" feedback in popover during reconnect

The bottom Resume button on the chat panel was leaving users staring at
nothing for the 5-30s reconnect window: prepare-progress events are
intentionally suppressed on resume (the workspace is already there), so
the popover sat in the idle phase even while session.state was STARTING.

Synthesize a "resuming" phase from session state when no prepare events
are flowing, rendered as a spinner row in the popover with copy
"Resuming session — Reconnecting to the existing environment…". The
button icon also flips to the spinner so the toolbar advertises the
in-flight work without the user having to open the popover.

Extract the prepare-status reducer + section component into their own
file so the main popover module stays under the 600-line ESLint cap.

* fix(codex): align with the standard SessionDirTemplate pattern

Codex was the only agent that bind-mounted its host home into the
container via an explicit `Mounts: [{home}/.codex → /root/.codex]`
entry, while every other ACP agent uses `SessionConfig.SessionDirTemplate`
+ `SessionDirTarget`. Move codex onto the same surface so the
host-state isolation work in the next commit covers it uniformly.

This change alone does not stop the host-state leak — the bind mount
still resolves to the host's ~/.codex for now — but it removes the
last agent-specific branch in the runtime config so the docker
container manager can apply one rule for all agents.

* fix(docker): isolate per-container agent state from the host home

Every ACP agent's session dir was bind-mounted from ~/.<agent> on the
host into /root/.<agent> in the container. That exposed host state DBs,
session caches, and lock files to the container — codex's rollout DB
caches absolute /Users/... paths, which don't resolve inside Linux and
the agent silently loses prior thread context on resume.

Replace the host bind mount with a kandev-managed per-container session
dir under <kandev-home>/agent-sessions/<instance_id>/. Before the
container starts, selectively copy the auth-relevant files (the same
files Sprites already copies via RemoteAuth.SourceFiles) into that
dir; everything else — state DBs, sessions/, lock files — stays on the
host and the agent rebuilds them inside the container with
container-local Linux paths.

The change is agent-agnostic: any agent that has SessionDirTemplate
+ SessionDirTarget gets the new behavior automatically. Cleanup runs
on destructive stops only (task/session deleted/archived), mirroring
the Sprites preserve-on-stop rule so plain stops let resume re-attach
to the same agent state.

* fix(executor): make the kandev feature-branch checkout an invariant postlude

Profiles created in the UI snapshot the *then-current* DefaultPrepareScript
into their stored prepare_script field. Updates to the default — like
shipping the worktree-branch checkout block — never reach those older
profiles, so users keep landing the agent on the chosen base branch
(main) even though the env row carries a feature branch name.

Append a kandev-managed postlude to whatever prepare_script ran (custom
or default), wrapped in a subshell + `|| true` so it's idempotent and
never blocks agentctl from starting. The postlude prefers the remote tip
when origin already has the branch (resume after a destroyed sandbox),
falls back to checking out the existing local branch, and finally
creates one off the current HEAD.

Sprites needed the WorktreeProvider in resolvePrepareScript so the
postlude's `{{worktree.branch}}` actually expands; Docker already had
it.

The Docker e2e gains a regression case that explicitly stores an older
default-style script (clone, no checkout) and asserts the agent still
ends up on the feature branch — locking the contract in regardless of
which default the user happens to have stored.

* fix(web): tighten executor popover padding under the env header

The Sprites/Docker info block under the Environment-ready row had too
much vertical breathing room — the header-to-fields gap and the
inter-row spacing piled up and the popover felt airy. Trim the section
to match the snug rhythm of the rest of the popover (px-3 py-2,
1.5-unit gap before the field list, 1-unit gap between rows). Also
unify the Environment-ready row from p-3 to px-3 py-2 so the dividers
between sections sit at the same vertical cadence.

* fix(web): tighten executor popover bottom padding too

The previous tighten only fixed the top half — pb under the field rows
plus the Reset-row's full p-2 still left a generous gap below the last
"Sprite ..." line. Same fix applies whether the popover is showing
Docker or Sprites.

EnvironmentInfo: pt-2 pb-1.5 (was py-2) so the bottom field hugs the
divider, and the Reset-row goes from p-2 to px-2 py-1.5 so the button
sits closer to the text above.

* fix(web): drop the redundant Environment-ready row + tighten popover top

The READY badge next to the executor name already conveys ready-state;
the dedicated "Environment ready · 12s" row above the executor info
just added vertical noise. Skip rendering for the ready phase (other
phases that surface non-redundant info — preparing, resuming, failed —
still render). Drop top padding on the executor info section now that
nothing sits above it on the happy path.

* fix(web): move executor info to the left top bar, hover-to-open, no border

The executor section is informational, not a button — readiness state +
sandbox/container/branch fields. Move it to the left top bar (next to
the integrations menu) where it sits alongside the other contextual
info, drop the outline-button styling, and switch from click-to-open to
hover-to-open via HoverCard with 150ms open / 250ms close so the cursor
can travel to the destructive Reset button without flickering.

The trigger is a borderless icon-with-status-dot span; tabIndex keeps
it keyboard-accessible. Reset still works — it opens the existing
confirmation dialog independently of the hover card.

Tests: pointerEnter instead of click for the hover-to-open path.

* refactor(cleanup): drop dead code, dedupe predicates, unify token injection

Tier 1 of the post-review cleanup. Each item is independently safe; bundling
because they're all small, mechanical, and lint-passing.

- fix(clone): unify injectTokenIntoURL across Docker and Sprites — Sprites
  used to ignore GH_TOKEN entirely (a user with only GH_TOKEN set got 401
  on Sprites clone but worked on Docker). Promote one
  `injectGitHubTokenIntoCloneURL` helper, accept both env vars
  (GITHUB_TOKEN wins, matching Docker), and use the documented
  `https://x-access-token:T@github.com/` form on both surfaces.
- fix(executor-popover): swap the <span tabIndex role="button"> trigger
  for <Button variant="ghost"> so Enter/Space activation, focus rings on
  Safari, and disabled semantics come for free.
- test(docker): rewrite TestDockerStopInstance_PreservesSessionDirOnPlainStop
  to drive the real DockerExecutor.StopInstance path with ContainerID=""
  (skips the docker daemon, exercises the kandev-side cleanup branch).
  Previous version only iterated shouldRunExecutorCleanup strings — would
  pass even if StopInstance always cleaned up. Now table-driven over
  preserve+destructive reasons with on-disk asserts.
- refactor(orchestrator): drop dead `_ = arg` parameters from persist*
  helpers (persistResumeState, persistModelSwitchState, persistLaunchState).
  Brings signatures from 9→6 args, under the funlen lint cap.
- refactor(orchestrator): collapse byte-identical isContainerizedExecutor
  ≡ isCloneBasedExecutorType predicate into one.
- refactor(lifecycle): drop dead getMetadataBool, FileUploader.RunCommand,
  expandMountSource {home} arm (no production agent ships {home} in
  Mounts; codex_acp_test asserts it), dockerTaskBranch one-line wrapper.

* refactor(scripts): postlude owns worktree-branch checkout

Strip the inline `git checkout -B` block from the Docker and Sprites
default prepare scripts. The kandev-managed postlude
(KandevBranchCheckoutPostlude) is appended unconditionally after every
user prepare script, so keeping the same logic inline made the default
and the postlude two sources of truth that could drift.

* refactor(lifecycle): drop dead instance_port persistence entry

`instance_port` is only used as a zap log field — it's never written to
the executor metadata map, so carrying it across resume in
`persistentMetadataKeys` was a no-op.

* refactor(lifecycle): drop legacy {home} session-dir fallback

ExpandSessionDir kept a `{home}/<dotdir>` fallback for the case where
kandevHomeDir was unresolved. In production, ResolvedHomeDir() always
returns a non-empty path, so the fallback only ever fired in tests
that hadn't been updated to pass an instance ID. Returning the empty
string in that case makes container.go skip the bind mount — the
correct behaviour, since without a kandev home there's no session dir
to mount.

* refactor(orchestrator): collapse env-reuse helpers into reuseExistingEnvironment

The two env-reuse helpers (`applyExistingEnvironment` and
`applyExistingEnvironmentRuntimeMetadata`) were always called together
and shared the same intent: carry the previous TaskEnvironment forward
into the launch request. Merge them into a single
`reuseExistingEnvironment(ctx, req, env)` so the executor-type gate
runs once for both env-level fields and runtime metadata, and callers
have a single entry point.

Document the layered metadata rule (running wins for container_id;
env wins for sprite_name and other stable IDs) inline so the next
reader doesn't have to reverse-engineer it from two functions.

* refactor(web): lift prepare-summary reducer + isFallbackNoticeStep into lib/prepare

Move the popover's prepare-summary reducer (and the
`isFallbackNoticeStep` helper duplicated word-for-word in
`prepare-progress.tsx`) into `apps/web/lib/prepare/summarize.ts`. The
hook moves to `hooks/domains/session/use-prepare-summary.ts` to match
the project's domain-organized hook layout.

The popover's PrepareSummary type and the chat panel's EffectiveStatus
serve different UIs, so the panel keeps its own `deriveStatus` — only
the literal duplicate (`isFallbackNoticeStep`) is shared.

* refactor(web): split executor-settings-button by concern

Slice the 471-line button file into three modules with single
concerns: the popover shell stays in `executor-settings-button.tsx`
(168 lines, just trigger + popover layout + icon/status dot),
`executor-environment-info.tsx` owns the env detail rows + status
badge, and `hooks/domains/session/use-task-environment.ts` owns the
fetch/poll/reset lifecycle alongside other session hooks.

* refactor(e2e): extract docker + session helpers from docker-launch.spec

Pull the inline docker CLI helpers (`dockerInspectExists`, `dockerStop`,
`dockerCurrentBranch`, etc.) and the generic session-state pollers
(`waitForLatestSessionDone`, `waitForSessionDone`,
`waitForSessionEnvironment`) into `e2e/helpers/docker.ts` and
`e2e/helpers/session.ts`. The spec drops from 763 to 647 lines and the
helpers can be reused by future docker / session tests.

* test(scripts): execute postlude under bash -e against a real git repo

Pair the static-content assertion with a behaviour test that runs the
postlude as a real shell script. Three scenarios exercise the three
branches inside the snippet: remote tip exists (track origin), no
remote (fresh local branch), and already on the target branch
(idempotency). This catches regressions where the snippet still parses
as bash but no longer does the right thing — wrong refspec, missing
-B, swallowed exit code, etc.

* test(sprites): integration check for stop preserve + resume reconnect path

Each piece of the resume-fast path is unit-tested separately
(StopInstance keeps the token cache, spritesShouldReconnect reads
sprite_name, the reconnect plan has 3 steps). This new test wires them
together end to end so a future change that accidentally strips
sprite_name on stop or forgets to look at metadata on launch fails
loudly here, not at runtime.

* test(sprites): assert plan ordering instead of absolute indexes

Replace `plan.index(spriteStepWaitHealthy) == 1` with an
`assertPlanOrder` helper that checks "X runs before Y". The contract
the test guards is the order of create-sprite → wait-healthy →
agent-instance, not their absolute slot numbers — adding a new step
shouldn't break the test if the order is preserved.

* test(web): hoist test constants above vi.mock factories

Move SESSION_ID and friends above the vi.mock factories so the mock
closures don't depend on vitest hoisting magic capturing TDZ-undefined
locals. The previous layout worked because factories aren't invoked
until the module under test is imported (after const initialization),
but a refactor that imports earlier would break the closure silently.

* fix(seeder): contain localFileUploader writes inside the session root

CodeQL flagged go/path-injection on the localFileUploader.WriteFile
sink: instanceID flows into the write path, and a malformed value
would let writes escape the kandev session root and clobber arbitrary
host files. Bind the uploader to its session root and reject any path
that filepath.Rel resolves to a "../" escape. Production callers
always pass an internally generated instanceID; the check is defense
in depth against a future caller plumbing in user input.

* refactor(docker): split reconnectToContainer + cover reconnect helpers with unit tests

Address recurring review feedback on the docker reconnect path:

- reconnectToContainer was 98 lines (over the 80-line repo limit). Split
  into ensureContainerRunning (inspect + start + re-inspect) and
  bringupAgentctl (health-check, find-or-create instance, transparent
  re-handshake). The orchestration body now reads top-down at 49 lines.
- resolveDockerEndpoint and waitForAgentctlHealth grew real branches in
  this PR (host-port lookup with container-IP fallback; 240×500ms
  retry loop) but had no direct tests. Introduce narrow hostPortLookup
  / healthChecker interfaces, satisfied by *docker.Client and
  *agentctl.ControlClient, so both functions are now driven by stubs
  that exercise happy-path, fallback, retry-until-healthy, exhaustion,
  and context-cancellation branches.
- Add direct unit tests for the docker package's pure helpers
  (normalizeDockerHostIP, buildDockerPortBindings) and for
  dockerAgentctlPortBindings — a regression in the published port
  range or host IP would silently break container reconnect.

* test(handlers): cover ensureShellExecution stale-execution recovery

The recovery branch (Failed execution → CleanupStaleExecutionBySessionID
→ relaunch) is the load-bearing piece of shell session resilience and
had no direct test. Extract the body into a free function that takes a
narrow shellLifecycle interface so the four branches are testable
without standing up a real lifecycle.Manager:
  - happy path (running execution, no cleanup)
  - stale recovery (Failed → cleanup → fresh)
  - initial GetOrEnsureExecution failure
  - cleanup failure must NOT trigger a re-launch loop
  - relaunch failure surfaces a clear "recover execution" error

* fix(backend): clear lint blockers from CI lint job

CI's only-new-issues lint surfaced three real items left over from
recent additions:

- container.go bootstrap snippet had two `fi` shell tokens flagged by
  dupword. Tagged with the same nolint directive used by the
  KandevBranchCheckoutPostlude (two distinct shell blocks closing).
- DockerExecutor.CreateInstance was 89 lines (over funlen 80). Split
  the body into helpers: reportCreateInstanceProgress (deferred
  progress reporter), tryReconnect (gates the resume path),
  seedSessionDir (per-container auth seed), buildContainerLaunchConfig,
  and buildCreatedInstance. CreateInstance is now a 30-line top-down
  orchestrator.
- executor_execute.go was 887 effective lines (over revive 800). Move
  reuseExistingEnvironment + its helpers (latestExecutorRunningForEnv,
  executorRunningMatchesEnvironment, applyExecutorRunningMetadata,
  ensureLaunchMetadata) into a sibling executor_environment_reuse.go.

* fix(web): only block submit during real env prepare, not every STARTING

The "agent still being set up" submit gate was triggering on every
session that briefly transitions through STARTING — including local
quick-chat, which has no prepare phase and has its agent live within
~100ms. The eager-init quick-chat e2e tests (chat/quick-chat.spec.ts
and chat/message-queue.spec.ts) wait for the editor to become
editable, then press Cmd+Enter. The race made wrappedSubmit a no-op
exactly when the test was firing — message dropped, agent never saw
it, downstream assertions failed.

Split the merged isStarting flag in useSessionState: keep the broad
flag (still used for the workspace-prep loading UI), and expose
isPreparingEnvironment as a separate signal that's only true while
prepareProgress.status === "preparing". Thread it through chat-input-
area → chat-input-container → useChatInputContainer and gate the
submit-disabled tooltip on it instead of the broad isStarting.

Net: Docker / Sprites bootstrap still surfaces the "agent still being
set up" message and disables submit; quick-chat / local sessions stop
silently dropping in-flight messages.

* fix(web): keep editor disabled during STARTING so quick-chat sends don't race

My previous fix made submit no longer block on isStarting, with the
goal of keeping local quick-chat sessions from silently dropping
messages. The trace from CI showed it traded one failure for another:
the editor became contenteditable while the session was still
STARTING, the e2e suite pressed Cmd+Enter immediately, and the
backend rejected with "Failed to send message to agent" because the
agent process wasn't ready yet.

Restore the original semantics — STARTING blocks both editor and
submit — but keep `isPreparingEnvironment` as the narrower trigger for
the "agent still being set up" tooltip. Net behaviour:
  - STARTING transition: editor disabled, no tooltip (signal is the
    disabled state itself)
  - container/sandbox prepare: editor disabled + tooltip explaining why
  - quick-chat sessions: editor stays disabled until RUNNING, no race

* docs(web): correct isPreparingEnvironment JSDoc after STARTING re-block

The 9389ac9 fix restored isStarting as the editor disabled gate.
Update the JSDoc on isPreparingEnvironment to match: it now drives
the "agent still being set up" submit-disabled tooltip only — the
disabled state itself is gated on the broader isStarting so e2e
Cmd+Enter doesn't race the not-yet-ready agent.

* fix(lifecycle): make agentctl health retry delay cancelable

Replace `time.Sleep(retryDelay)` in `waitForHealth` (container.go) and
`waitForAgentctlHealthWith` (executor_docker.go) with a cancelable
`select { <-ctx.Done() | <-time.After(retryDelay) }`. Also skip the
final retry's sleep — the loop only re-enters when `i+1 < maxRetries`,
so the extra delay was just added latency on aborted launches.

Cancellation now propagates within ≤500 ms instead of waiting for the
next probe iteration to observe `ctx.Err()`.

* fix(scripts): non-destructive postlude branch checkout

The previous postlude tried `git fetch + git checkout -B feature
origin/feature` first, which is force-reset semantics: any unpushed
local commits on the resumed workspace would be orphaned without
warning.

Reorder to prefer the existing local branch (preserving in-flight
work after a sandbox/container resume), fall through to a fresh
tracking branch off origin, and only as a last resort create the
branch off HEAD:

  1. local feature branch exists  → checkout (preserves)
  2. else fetch + checkout -b      → tracks origin, no force-reset
  3. else                          → create new local branch

Behaviour tests still pass: fresh sandbox with remote tip → falls
through to step 2 (clean track). The static-content test now also
verifies the destructive `-B "feature" "origin/feature"` form is
absent.

* fix(lifecycle): reject non-regular files for KANDEV_MOCK_AGENT_LINUX_BINARY

`os.Stat` succeeds on a directory, so the previous resolver happily
accepted a directory path and forwarded it to the Docker bind-mount +
exec path, where it surfaced as a confusing "permission denied" /
"exec format error" deep in container bootstrap.

Validate `info.Mode().IsRegular()` up front and return a clear error
naming the env var.

* docs(test): update stale comment after retry-delay became cancelable

aaec9e87 swapped the retry sleep for a select on ctx.Done. The test
comment in TestWaitForAgentctlHealth_HonoursContextCancellation still
described the old "subsequent time.Sleep is short" reasoning, which is
no longer how cancellation propagates — the select exits immediately
via ctx.Done() within the same iteration.

* fix(web): extract buildDialog{FormBody,Footer}Props to satisfy lint cap

After the main merge, prettier expanded my hand-compacted destructuring
in TaskCreateDialog onto multiple lines, putting the function at 115
lines (over the 100 cap). Extract the two prop-assembly blocks into
buildDialogFormBodyProps + buildDialogFooterProps helper functions so
the orchestrator becomes a thin JSX wrapper. No behaviour change — the
helpers project the same shape, 1:1.

Also fixes the upstream CI failure: prettier --check was rejecting the
hand-compacted destructuring lines.

* test(executor): cover repositoryCloneURL LocalPath fallback

The function gained a second code path to support Docker E2E tests with
file:// remotes — when ProviderOwner/ProviderName are both empty and
LocalPath is set, it shells out to git remote get-url origin. The
original table-driven test had eight cases, none of which exercised
the new branch.

Add a dedicated TestRepositoryCloneURL_LocalPathFallback with four
sub-cases:
  - local-only repo with a valid origin remote → returns the URL
  - local-only repo without an origin remote → returns empty
  - local-only non-git directory → returns empty
  - no provider and no local path → returns empty (regression for the
    early-return branch the old table already covered)

* refactor(web): move task-create-dialog prop builders to sibling file

3a0c45a0 introduced buildDialogFormBodyProps + buildDialogFooterProps
to slim TaskCreateDialog under the function-line cap, but adding them
in-file pushed task-create-dialog.tsx to 612 raw lines. Local eslint
still passes (skipBlankLines + skipComments brings the effective count
under 600), but Claude's review flagged the raw-line growth as a
blocker — and the prop builders are pure projections from setup state
to plain JS objects, no JSX, so they belong outside the component
module anyway.

Move computeHasAllBranches and both builders to a new
task-create-dialog-prop-builders.ts. Export TaskCreateDialogProps and
useTaskCreateDialogSetup from dialog.tsx so the builders can derive
their parameter types via ReturnType<typeof useTaskCreateDialogSetup>.

task-create-dialog.tsx: 612 → 522 raw lines.

* chore(web): apply prettier to task-create-dialog-prop-builders.ts

The new file from 08aa04c5 wasn't run through prettier before push,
so CI's --check rejected the long import line. Mechanical reformat
(import collapsed to one line, no semantic change).

* fix(web): import-type the prop-builders circular dep + add computeHasAllBranches test

Two findings from Claude on 08aa04c5 + fa5ba9d6:

  - The mutual import between task-create-dialog-prop-builders.ts and
    task-create-dialog.tsx is purely type-level (TaskCreateDialogProps
    is an interface; useTaskCreateDialogSetup is consumed only through
    `ReturnType<typeof ...>`). Switch to `import type` so bundlers and
    analysis tools don't flag this as a runtime cycle.

  - computeHasAllBranches was a private function in
    task-create-dialog.tsx and didn't need its own test. Moving it to
    a sibling .ts utility makes it a public helper, and the SKILL.md
    convention is that public utilities carry their own tests. Add a
    table-driven unit test covering the three branches: noRepository
    short-circuit, useGitHubUrl branch validation, and the
    every-repo-has-a-branch case.

* fix(web): pass executorType to kanban card RemoteCloudTooltip

The kanban-card call site was missing the optional executorType prop,
so getExecutorStatusIcon(undefined, ...) fell through to the default
arm and rendered IconServer for every remote-executor task —
including Docker, which should be IconBox. Other call sites
(task-item.tsx, session-mobile-top-bar.tsx) already pass it.

This was a pre-existing bug from c64e8356 that became visible because
this PR makes Docker tasks first-class on the home view.

* fix(web): give executor popover env header breathing room above the title

The EnvironmentInfo block was `px-3 pb-1.5` (no top padding), so the
"Local Docker" heading hugged the popover top edge when no prepare-
status row was rendered. Add `pt-2.5` to match the visual rhythm of
the prepare-status row that sometimes sits above it.

* fix(sprites): DestroySandbox passes terminal StopReason so cleanup actually runs

shouldRunExecutorCleanup gates Sprites teardown on a small set of
terminal reasons (task/session archived/deleted). DestroySandbox was
calling StopInstance with an empty StopReason, which never matched
that set — so the sandbox was never actually destroyed, only the
local execution state was cleaned up. The sprite kept running
upstream until it idled out.

Pass StopReasonTaskDeleted explicitly. While here, lift the four
terminal-reason literals into named constants so callers don't have
to keep them in sync with shouldRunExecutorCleanup's switch.

* fix(web): block submit when selected agent is incompatible with executor

useExecutorProfileCompat returned noCompatibleAgent=true only when
NO agent profile was compatible with the chosen executor. The user
could still pick an agent that happened to be incompatible with the
currently-selected executor (e.g. by switching executor after the
agent was chosen) and the submit gate didn't fire.

Two fixes:
  - Hook now takes the selected agent profile ID and returns
    noCompatibleAgent=true when the *selected* agent isn't in the
    compatible set (covers both the old "no agents at all" case and
    the new "wrong agent picked" case).
  - Footer's session-mode disable was gating only on missing agent;
    add noCompatibleAgent so the disable matches the reason text
    sessionDefaultReason already surfaces.

* fix(task-env): sanitize generic 500 response on reset failure

The reset-environment endpoint was returning the raw err.Error() in the
500 response body, leaking internal details (paths, wrapped chains,
infra error strings). Keep the structured log at error level for
operators and return a generic "failed to reset task environment"
to clients.

* fix(worktree): push to the branch's configured upstream remote, not hard-coded origin

PushEnvironmentBranch was running `git push origin <branch>`, which
breaks for repos whose primary remote isn't called "origin" (fork
workflows that use "upstream", "github", etc.). Read the branch's
configured upstream remote via `git config branch.<branch>.remote`
and fall back to "origin" only when no upstream is set.

* refactor(web): type workflows/snapshots in DialogFormBodyProps properly

DialogFormBodyProps had `workflows: unknown[]` and `snapshots: unknown`
which dropped type safety and forced unsafe casts at the call site.
Replace with the actual store-derived types (WorkflowsState["items"]
and KanbanMultiState["snapshots"]) that the call sites already
produce.

* fix(turns): align info.TaskEnvironmentID with the resolved env after fallback

GetWorkspaceInfoForSession initializes info.TaskEnvironmentID from
session.TaskEnvironmentID, then resolves the env — first by that ID,
falling back to GetTaskEnvironmentByTaskID when the session's ID
points to a stale/missing row. When the fallback kicks in, the
resolved env has a different ID, but applyTaskEnvironmentToWorkspaceInfo
was only updating info.TaskEnvironmentID when it was empty. So
downstream reconcilers received metadata + path from one env keyed by
a different env's ID.

Always overwrite info.TaskEnvironmentID with env.ID inside the apply
function — once we've picked an env to spread, the info should
identify that env consistently.

* fix(web): gate compatibility on effectiveAgentProfileId, not the raw form value

The previous fix passed fs.agentProfileId to useExecutorProfileCompat,
so workflow-locked sessions (where fs.agentProfileId is empty and the
real value comes from workflowAgentProfileId) bypassed the
noCompatibleAgent check entirely — selectedAgentProfileId was "" so
the new "selected agent isn't in compatible set" branch silently
returned false.

Thread `effectiveAgentProfileId` (which already coalesces the form
value with the workflow override) through to the compat hook so
locked agent selections are gated the same way as user-picked ones.

* test: cover detectBranchRemote + applyTaskEnvironmentToWorkspaceInfo stale-ID fallback

Two test additions for the Round 29/30 Claude blockers — both are
recent backend fixes (a07b8f1f, e6195f3f) that landed without
regression coverage.

  - worktree_test.go: TestDetectBranchRemote covers the three
    branches of the new helper — configured upstream returned,
    no upstream → "origin" fallback, non-git dir → "origin"
    fallback. GIT_CONFIG_NOSYSTEM=1 keeps host config from
    interfering.
  - service_turns_test.go: a regression test that drives
    GetWorkspaceInfoForSession down the fallback path
    (session.TaskEnvironmentID points at a missing row; real env
    exists under a different ID for the same task) and asserts
    info.TaskEnvironmentID is aligned with the resolved env, plus
    WorkspacePath/container_id come from the same env.

* fix(backend): extract defaultGitRemote constant to satisfy goconst

The new detectBranchRemote helper plus its tests pushed "origin"
literal occurrences past goconst's min-occurrences=3 threshold.

* fix(backend): inject git identity in worktree tests for CI

CI runners have no user.email/user.name configured, so
`git commit --allow-empty` aborts with "Author identity unknown".
GIT_AUTHOR_*/GIT_COMMITTER_* env vars override the config check.

* fix(backend): base mustGit env on os.Environ to keep HOME/PATH

Appending to a nil cmd.Env produced a single-element slice that
replaced the inherited environment, leaving the git subprocess
without HOME, PATH, etc. Base on os.Environ() instead.
2026-05-14 21:26:09 +01:00
Carlos Florêncio d7e183847c fix(web): hide stuck Resume session button after click + cover with e2e (#890) 2026-05-13 18:39:59 +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