Skip to content

feat: worktrees codeoid creates can build — a trusted, hash-pinned setup recipe - #424

Merged
saucam merged 3 commits into
mainfrom
feat/loops-workspace-setup
Oct 25, 2026
Merged

saucam merged 3 commits into
mainfrom
feat/loops-workspace-setup

Conversation

@saucam

@saucam saucam commented Oct 25, 2026

Copy link
Copy Markdown
Collaborator

What this adds

A worktree codeoid creates (forks, compare, routine worktrees) can now actually build and run tests. Until now it carried tracked files only, so .env and installed dependencies were missing and checks failed for reasons unrelated to the change being tested (docs/loop-engineering-design.md §12.7).

A repo can declare .codeoid/setup.yaml:

  • copy: gitignored files to copy from the primary checkout, for example .env.
  • share: directories to link from the primary checkout, for example node_modules. A share is a writable link back into the primary checkout, and config directories such as .claude, .vscode and .git* are refused.
  • command: an optional setup command such as bun install --ignore-scripts.

Running the command is something you opt into, and it is pinned:

  • It runs only with worktree.setup.runCommands: trusted-repos, and only for a recipe you trusted with codeoid worktree trust. Trust is recorded against the recipe's content hash and for your user.
  • Change the command and it stops running until you re-trust it.
  • It runs only on committed content, before the fork's uncommitted edits are applied.
  • It runs only when the worktree's base commit is already on the primary checkout's history. A fork of an agent's branch, a codeoid branch or an unmerged PR ref runs no command. Copy and share still apply.
  • Delegated tokens can't change these settings or grant trust, and refusals are audited.

What setup copied or linked is kept out of checkpoints and out of the branch a destroyed fork leaves behind. A failed setup is reported as an infrastructure problem, not the agent's.

Review

Two independent security and correctness audit rounds, all findings folded in.

  • Round 1 fixed:
    • the command running agent-edited scripts with no approval gate;
    • trust tied to a path instead of to content;
    • delegated tokens able to enable command execution;
    • leftover processes;
    • config-directory shares;
    • fail-open checkpoint exclusion;
    • unredacted output;
    • hard-link and symlink copy races.
  • Round 2 fixed:
    • commands running on unreviewed commits (base-commit ancestry check);
    • a stale policy snapshot;
    • half-applied carries;
    • reads during preparation;
    • per-owner trust.

Full suite: 3803 pass / 0 fail.

For agents: implementation
  • src/daemon/workspace-setup.ts:
    • The recipe is read from HEAD with ls-tree/cat-file, through a zod schema.
    • Copy plans use realpath containment, O_NOFOLLOW plus fstat, and skip files with nlink>1; destinations are opened O_EXCL and must be ignored where they land.
    • Share plans include the config-directory refusals.
    • The command runs in a process group, with the stripped env and a redacted output tail.
    • Before running it, trustDecision checks the base blob hash and merge-base --is-ancestor.
  • git-worktree.ts: createForkWorktree({ deferCarry }) with applyCarriedState (restored to HEAD on failure).
  • session-manager.ts:
    • fork, compare and the routine host call prepareWorktree;
    • the worktree.trust handler refuses delegated tokens and audits refusals;
    • the policy is snapshotted once per fork.
  • session.ts: setup notices, plus #pendingSetup refusing fs, fork and rewind while preparing.
  • checkpoints.ts: fail-closed exclusion of setup paths.
  • Config and settings: worktree.setup.runCommands; trustedRepos entries are {path, recipeHash, trustedBy}.
  • Protocol: worktree-trust.ts. CLI: codeoid worktree trust [dir] [--off] [--yes]. Docs: CONFIGURATION.md.

🤖 Generated with Claude Code

saucam and others added 3 commits October 25, 2026 10:12
A worktree codeoid creates (forks, compare branches, routine worktrees) now
applies the repository's .codeoid/setup.yaml: copy ignored files from the
primary checkout, symlink shared directories, and run a setup command.

- The recipe is read from the primary checkout's committed HEAD (git
  ls-tree + cat-file), never from the new worktree, a candidate branch, or
  uncommitted edits. Reading and parsing execute nothing.
- copy/share resolve inside the primary checkout only (realpath
  containment, never .git); destinations never follow a symlink and never
  overwrite; a copied file must be git-ignored where it lands.
- The command runs only under worktree.setup.runCommands "trusted-repos"
  for a repository in worktree.setup.trustedRepos (default never), with a
  built environment, a timeout that kills its process group, and an abort.
- Everything is validated before anything is written; an apply failure
  rolls back. resources are parsed and exposed, not yet leased.
- Forks prepare in the background ahead of fork.setup; the first turn waits
  and the result is an infrastructure notice. Routine worktrees prepare
  before the agent starts; a failure fails the run as an infrastructure
  error and removes only that run's fresh worktree.
- What a recipe added is recorded on SessionWorktree.setup, kept out of
  checkpoints (excludePaths), and taken back out before the destroy-time WIP
  snapshot so a copied secret or shared link never lands in a commit.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ound 1)

- A setup command runs on a CLEAN checkout of the worktree's base commit:
  createForkWorktree({ deferCarry }) hands the parent's uncommitted state
  back as `carry`, applied with applyCarriedState after setup (forks: with
  the earlier-turn file roll, in the first-turn gate; routines: before the
  agent starts). Trust is documented as covering the committed tree,
  including the scripts the command invokes.
- Trust is pinned to the recipe's git blob, direnv-allow style:
  worktree.setup.trustedRepos takes { path, recipeHash } (a bare path
  trusts no command). `codeoid worktree trust [dir] [--off] [--yes]` and a
  worktree.trust message show the command and record path + hash (refused
  if the recipe changed since shown). A changed recipe runs no command,
  still copies/shares, and surfaces worktree.setup.untrusted.
- worktree.setup.* settings and worktree.trust refuse delegated tokens and
  are audited old -> new; the policy is applied live.
- The command's process group is killed on exit too, settles on exit and
  drains briefly; routine runs pass their abort signal (cancel, shutdown).
- share refuses .claude, .codeoid, .vscode, .idea, .husky and .git*.
- Copies skip files with other hard links and go through O_NOFOLLOW fds
  re-checked by fstat; .git is matched case-insensitively with native
  realpaths; trust paths must be absolute; off on win32.
- Output tails are redacted; checkpoint exclusion fails closed; setup is
  recorded before the destroyed check; a failed routine setup cleans up
  with its setup paths; share planning can't throw.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…y history (B0d audit round 2)

- commandGate: besides the pinned trust, the worktree's actual base commit
  must carry the trusted recipe blob and be an ancestor of the primary
  checkout's HEAD; otherwise the command is skipped as base-not-on-primary
  (worktree.setup.untrusted notice), copy/share still apply. Forks decide
  whether to hold their files back from the same check, on the created
  worktree, with one policy snapshot passed through to prepare.
- A failed carry rolls the worktree back to HEAD (tracked files restored,
  carried additions removed) and says so.
- While a fork's files wait for setup, fs.list/fs.read, forking it and
  rewinding it are refused with a "still preparing" error.
- After a carry, shared links git replaced are reported.
- worktree.trust builds trustedRepos from config.json as it is now (hand
  edits survive), records trustedBy, and an entry holds only for that
  owner's sessions; refusals are audited as *.denied.
- Docs: the base-commit rule, and that an agent in the primary checkout can
  still commit to its branch.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@saucam
saucam force-pushed the feat/loops-workspace-setup branch from bad51d4 to 50e4d28 Compare October 25, 2026 02:18
@saucam
saucam merged commit 3b7b50b into main Oct 25, 2026
4 checks passed
@saucam
saucam deleted the feat/loops-workspace-setup branch October 25, 2026 03:02
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants