* fix(worktree): export executor-profile env into repo setup script
Executor-profile environment variables (with secrets revealed) were merged
into the launch request env and reached the agent subprocess, but the
repository setup script — run inside worktree.Manager.Create, where
`npm install` typically runs — was built with only the install-managed env
(GOCACHE). The profile env was never injected, so tokens like an npm auth
token were unavailable during install. This affected both single-repo and
multi-repo tasks, since both run their setup script through Create.
Thread the resolved profile env into the setup script at the point both
paths converge: add CreateRequest.ScriptEnv (transient, never persisted so
secrets stay out of the DB), set it from req.Env in buildWorktreeCreateRequest
(used by single-repo and per-repo in multi-repo), and merge it over the
managed env in runWorktreeSetupScript. Managed GOCACHE keeps precedence.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(worktree): avoid overflow-flagged alloc size, clarify env precedence
Address PR review: drop the len(a)+len(b) capacity hint in mergeScriptEnv
(CodeQL flagged the addition as a possible allocation-size overflow) and
reword the ScriptEnv doc comment to state that install-managed values such
as GOCACHE take precedence over profile values on key collisions.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(worktree): round out mergeScriptEnv edge cases
Add profile-only and managed-only assertions to TestMergeScriptEnv per PR
review, making the table exhaustive alongside the existing (nil,nil) and
(profile+managed) cases.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix: harden submodule init against malicious .gitmodules
initSubmodules ran a bare `git submodule update --init --recursive` in a
worktree whose HEAD is attacker-controlled during a fork-PR review task,
with no protocol restriction, no hooks disable, and no cmd.Env. Attacker
`.gitmodules` could pick a command-executing transport (`ext::sh -c`,
CVE-2018-17456), a local `file://` path, or a plain-http URL (cloud
metadata SSRF).
Harden the single sink so all call sites benefit:
- protocol.allow=never default-denies every transport; only https/ssh are
re-enabled (blocks file://, git://, plain http:// SSRF).
- protocol.ext.allow=never hard-pins the RCE transport off; on the command
line it outranks any ambient protocol.ext.allow=always.
- core.hooksPath=/dev/null neutralizes attacker-planted hooks.
- GIT_TERMINAL_PROMPT=0 stops attacker URLs from hanging on a prompt.
Residual: attacker-chosen https/ssh URLs are still fetched (transports real
submodules require); fully closing that needs gating submodule init off for
untrusted worktrees, tracked separately.
* fix: address submodule hardening review feedback
- Reuse newNonInteractiveGitCmd so submodule init inherits the full
non-interactive env set (GIT_SSH_COMMAND=ssh -oBatchMode=yes, SSH/GCM
askpass suppression, WaitDelay). Prevents an attacker ssh:// submodule
URL from hanging the review runner on host-key/passphrase prompts until
the context deadline (GIT_TERMINAL_PROMPT=0 alone only covers git's HTTP
prompt, not SSH).
- Correct the file:// comment: command-line -c outranks repo/global git
config, so ambient protocol.file.allow=always does NOT re-enable file
submodules here. This is intentional for untrusted trees; a trusted
opt-in must be an explicit per-repo -c, not implicit host config.
- Add a file:// URL variant to the transport-block test alongside the
bare-path form (CVE-2022-39253 classification differs by git version).
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
* fix: validate destination parent chain in copyfiles byte copy
copyState.copyFile wrote into the target worktree via os.MkdirAll +
os.Create with no destination-parent symlink validation, unlike the
sibling paths that were hardened for exactly this: symlinkMatch calls
validateParentChain and remote writeOneEntry calls secureDestination.
A worktree whose HEAD ships a directory segment as a symlink (a fork-PR
head can set a tree entry to mode 120000 pointing at an absolute host
path) let MkdirAll follow the link and os.Create write the source file's
bytes outside the worktree as the backend user — a zero-click,
content-driven arbitrary-file-write reachable on the PR-review path
(copyConfiguredFiles -> copyfiles.Copy during worktree creation).
Route the byte copy through the same secureDestination hardening: it
runs validateParentChain, creates the parent dirs, and re-confirms
containment via EvalSymlinks before returning a destination guaranteed
inside the worktree. A rejected destination emits a non-fatal warning,
mirroring symlinkMatch. The leaf os.Lstat skip-if-exists behavior is
preserved (only a symlinked parent was the escape vector).
Add byte-copy regression tests: a symlinked parent is rejected and
nothing is written outside the worktree, a leaf symlink is still
skipped, and a clean nested copy still works.
* fix: canonicalize copyfiles target root before containment checks
copyFile passed the raw s.targetDir into secureDestination, which
EvalSymlinks-resolves the created parent and then Rel-checks it against
that root. When the worktree is reached through a symlinked ancestor
(macOS /tmp -> /private/tmp, or a user-symlinked tasks dir) the canonical
parent looks like it escapes the non-canonical root, so every legitimate
byte copy is silently rejected.
Resolve targetDir once in Copy (falling back to the raw path when it does
not exist yet), mirroring the EvalSymlinks WriteEntries already performs.
Add a regression test that routes the target through a symlinked ancestor
and asserts the copy still lands at the real location.
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
* feat: unarchive tasks with worktree branch recovery
Archived tasks can now be restored. Unarchive works for manual/legacy
archives (empty cascade id, root-only) and cascade archives. Archive
deletes the local branch, so worktree recreate now falls back to
fetching the branch from origin, and unarchive writes the old branch
back as checkout_branch so a fresh session resumes the prior work.
Unrecoverable branches (never pushed) surface a warning. WS archive now
routes through the handoff cascade so it stays stamped and reversible.
UI: unarchive action on /tasks archived rows and the task top bar.
* fix: address PR review feedback on unarchive recovery
Forward PRNumber to the recreate fetch so fork-PR heads (only reachable
via pull/<N>/head) are recoverable, publish task.updated per task in the
cascade unarchive loop so the UI restores without a reload, and
re-publish task.updated after a checkout_branch restore since the
unarchive event goes out with the pre-restore repository rows.
* fix: harden unarchive recovery per review feedback
Propagate branch-probe errors instead of misreporting recoverable work
as gone, and reserve ErrBranchUnrecoverable for a confirmed-missing
remote ref (transient fetch failures keep their own error). Validate
the archive root exists so WS/HTTP archive no longer reports success
for unknown task IDs, CAS-guard manual unarchive on an empty cascade
stamp, detach branch recovery from the request context, and skip
checkout_branch restore for multi-branch repos. UI: in-flight guard on
the list unarchive button, pluralized unrecoverable-branch toast.
* docs: document CAS guard on UnarchiveTask interface contract
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
* fix(worktree): rmdir empty task parent after worktree removal (#1266)
removeWorktreeDir tore down only the inner {repoName}/ subdirectory under
{tasksBase}/{taskDirName}/, leaving the now-empty parent task directory
behind on every archive, delete, and rollback path. Husks accumulated
under ~/.kandev/tasks/ over time.
Now also attempts rmdir on the parent when it is a direct child of
TasksBasePath. os.Remove no-ops if the parent still has siblings or
workspace-scoped content, so multi-branch siblings and the tasksBase
itself are preserved.
* fix(worktree): normalize tasksBase path + cover multi-branch sibling case
Address PR #1267 review feedback:
- Apply filepath.Clean to ExpandedTasksBasePath() before comparing against
filepath.Dir(parent). A configured TasksBasePath with a trailing slash
or doubled separators previously fell through the equality guard,
silently re-introducing the issue-1266 husk for those users.
- Use cfg.ExpandedTasksBasePath() in the regression test for consistency
with production code.
- Add two test cases:
- multi-branch sibling worktrees share a parent; removing one preserves
the parent, removing both clears it.
- trailing-slash TasksBasePath still triggers cleanup.
* test(worktree): check ExpandedTasksBasePath error in tasksbase survival assert
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
* 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>
* 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>