From 728624e8305db099cea0fa45840a7be17b503b0c Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Sun, 25 Oct 2026 07:57:12 +0800 Subject: [PATCH 1/3] feat: workspace setup recipe for codeoid-created worktrees 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) --- packages/protocol/src/types.ts | 7 + src/config.ts | 27 ++ src/daemon/checkpoints.ts | 39 +- src/daemon/git-worktree.ts | 52 ++- src/daemon/routines/service.ts | 14 +- src/daemon/session-manager.ts | 26 +- src/daemon/session.ts | 99 ++++- src/daemon/settings/manifest.ts | 19 + src/daemon/workspace-setup.ts | 645 ++++++++++++++++++++++++++++++ src/tests/routine-manager.test.ts | 37 ++ src/tests/session-fork.test.ts | 44 ++ src/tests/workspace-setup.test.ts | 395 ++++++++++++++++++ 12 files changed, 1383 insertions(+), 21 deletions(-) create mode 100644 src/daemon/workspace-setup.ts create mode 100644 src/tests/workspace-setup.test.ts diff --git a/packages/protocol/src/types.ts b/packages/protocol/src/types.ts index 49adf1e5..7cebe4ce 100644 --- a/packages/protocol/src/types.ts +++ b/packages/protocol/src/types.ts @@ -563,6 +563,13 @@ export interface SessionWorktree { * worktree the user already had — codeoid never removes those. */ createdByCodeoid: boolean; + /** + * What the repository's setup recipe put in this worktree, relative to its + * root: copied (ignored, often secret) files and symlinks to the primary + * checkout's shared directories. Kept out of checkpoints and taken back out + * before the worktree is removed. + */ + setup?: { copied: string[]; shared: string[] }; } /** diff --git a/src/config.ts b/src/config.ts index 8aac3cfe..bb8635e8 100644 --- a/src/config.ts +++ b/src/config.ts @@ -22,6 +22,7 @@ import { z } from "zod"; import { EFFORT_LEVELS, type EffortLevel } from "./protocol/types.js"; import type { AuthConfig } from "./daemon/auth.js"; import type { OAuthConfig } from "./daemon/oauth.js"; +import type { WorktreeSetupPolicy } from "./daemon/workspace-setup.js"; import { isLoopbackHost, LOCAL_TOKEN_ENV, @@ -1165,6 +1166,22 @@ const RootSchema = z.object({ setup: z.string().optional(), }) .default({}), + /** Worktrees codeoid creates (forks, compare, routines). */ + worktree: z + .object({ + /** The repository setup recipe, `.codeoid/setup.yaml` (src/daemon/workspace-setup.ts). */ + setup: z + .object({ + /** Whether a recipe's `command` may run. `never` (default) still copies + * and shares files; `trusted-repos` runs it for the repositories in + * `trustedRepos` only. Reading a recipe never executes anything. */ + runCommands: z.enum(["never", "trusted-repos"]).default("never"), + /** Primary checkouts whose recipe commands may run (`~` allowed). */ + trustedRepos: z.array(z.string().min(1)).default([]), + }) + .default({ runCommands: "never", trustedRepos: [] }), + }) + .default({ setup: { runCommands: "never", trustedRepos: [] } }), }); type ParsedConfig = z.infer; @@ -1322,6 +1339,15 @@ export interface CodeoidConfig { fork?: { setup?: string; }; + /** + * Worktrees codeoid creates. `setup` is the trust policy for the + * repository's `.codeoid/setup.yaml` recipe (workspace-setup.ts): its + * files are always applied; its command runs only for `trustedRepos` under + * `runCommands: "trusted-repos"`. Optional in the type; loadConfig fills it. + */ + worktree?: { + setup: WorktreeSetupPolicy; + }; /** * Send-class dispatch queue (P4). Optional in the type so hand-built test * configs stay minimal; loadConfig always populates it. Absent = enabled @@ -1788,6 +1814,7 @@ export function loadConfig(opts: LoadOptions = {}): CodeoidConfig { embed: parsed.embed, push: parsed.push, fork: parsed.fork, + worktree: parsed.worktree, }; } diff --git a/src/daemon/checkpoints.ts b/src/daemon/checkpoints.ts index 7864fb6f..33dffdc4 100644 --- a/src/daemon/checkpoints.ts +++ b/src/daemon/checkpoints.ts @@ -297,6 +297,12 @@ export async function createCheckpoint(opts: { * (the daemon's own data directory, should a workdir contain it). */ excludeDirs?: readonly string[]; + /** + * Absolute files (or symlinks) inside the workdir that must never be + * snapshotted, matched literally — what a worktree setup recipe copied in + * (secrets) or linked (shared dependency dirs). + */ + excludePaths?: readonly string[]; /** * Asked once the files have been read: has the turn already started? A * snapshot that finished after the agent began may include its first edits @@ -337,7 +343,7 @@ export async function createCheckpoint(opts: { const head = await run(["rev-parse", "--verify", "-q", "HEAD^{commit}"], { cwd: opts.workdir, env: gitEnv({}), timeoutMs: t }) .then((o) => o.trim()) .catch(() => ""); - const staged = await stageWorkTree(shadow, opts.workdir, limits, opts.excludeDirs); + const staged = await stageWorkTree(shadow, opts.workdir, limits, opts.excludeDirs, opts.excludePaths); if (!staged.ok) return staged; const late = opts.isLate?.() === true; const { env, first } = staged; @@ -372,6 +378,7 @@ async function stageWorkTree( workdir: string, limits: CheckpointLimits, excludeDirs: readonly string[] | undefined, + excludePaths?: readonly string[], ): Promise<{ ok: true; env: Record; first: boolean } | { ok: false; reason: string }> { const t = limits.timeoutMs; { @@ -379,10 +386,29 @@ async function stageWorkTree( // Ours are serialized by `inFlight`, so any lock here now is stale. await rm(path.join(shadow, "index.lock"), { force: true }); const env = gitEnv({ GIT_DIR: shadow, GIT_WORK_TREE: workdir }); - const extra = (excludeDirs ?? []) - .map((d) => path.relative(workdir, d)) - .filter((rel) => rel && !rel.startsWith("..") && !path.isAbsolute(rel)) - .map((rel) => `${rel.split(path.sep).join("/")}/**`); + const inside = (p: string) => { + const rel = path.relative(workdir, p); + return rel && !rel.startsWith("..") && !path.isAbsolute(rel) ? rel.split(path.sep).join("/") : null; + }; + // Exact paths git already ignores are left out of the add anyway, and a + // pathspec whose literal part names an ignored path makes `git add` + // refuse the whole snapshot — so only the ones it doesn't ignore are + // named (escaped: a literal name in a glob pathspec). These are files and + // symlinks; git never walks through a symlink, so the name is enough. + const literal = (excludePaths ?? []).map(inside).filter((r): r is string => !!r); + let ignored = new Set(literal); + if (literal.length > 0) { + ignored = await run(["check-ignore", "-z", "--stdin"], { cwd: workdir, env, timeoutMs: t, input: `${literal.join("\0")}\0` }) + .catch((err: unknown) => ((err as { code?: unknown }).code === 1 ? "" : literal.join("\0"))) + .then((out) => new Set(out.split("\0").filter(Boolean))); + } + const extra = [ + ...(excludeDirs ?? []).flatMap((d) => { + const rel = inside(d); + return rel ? [`${rel}/**`] : []; + }), + ...literal.filter((rel) => !ignored.has(rel)).map((rel) => rel.replace(/[*?[\]\\]/g, "\\$&")), + ]; const spec = pathspecs(extra); // Bound the content to hash before hashing anything. With no shadow index @@ -423,6 +449,7 @@ export async function currentTree(opts: { workdir: string; sessionId: string; excludeDirs?: readonly string[]; + excludePaths?: readonly string[]; limits?: Partial; }): Promise<{ ok: true; tree: string } | { ok: false; reason: string }> { const limits = { ...DEFAULT_CHECKPOINT_LIMITS, ...opts.limits }; @@ -431,7 +458,7 @@ export async function currentTree(opts: { inFlight.add(shadow); try { await ensureShadow(shadow, limits.timeoutMs); - const staged = await stageWorkTree(shadow, opts.workdir, limits, opts.excludeDirs); + const staged = await stageWorkTree(shadow, opts.workdir, limits, opts.excludeDirs, opts.excludePaths); if (!staged.ok) return staged; const tree = (await run(["write-tree"], { cwd: opts.workdir, env: staged.env, timeoutMs: limits.timeoutMs })).trim(); return { ok: true, tree }; diff --git a/src/daemon/git-worktree.ts b/src/daemon/git-worktree.ts index f32657f5..c2d5ed82 100644 --- a/src/daemon/git-worktree.ts +++ b/src/daemon/git-worktree.ts @@ -20,7 +20,8 @@ * the parent was. * * Untracked/ignored files are NOT carried (git stash create only snapshots - * tracked content) — a documented follow-up. Everything here runs the local + * tracked content); the repository's setup recipe (workspace-setup.ts) copies + * the ignored files it names and shares its dependency dirs. Everything here runs the local * `git` binary via execFile (the daemon is co-located with the repo); there is * no remote-host tunnel. * @@ -30,7 +31,7 @@ */ import { execFile } from "node:child_process"; -import { lstatSync, mkdirSync, realpathSync } from "node:fs"; +import { lstatSync, mkdirSync, realpathSync, type Stats, unlinkSync } from "node:fs"; import path from "node:path"; import { promisify } from "node:util"; @@ -99,7 +100,7 @@ function slug(label: string): string { * linked worktree we ask from — so new worktrees anchor beside the main repo * (`-worktrees/`), never nested inside another worktree. */ -async function mainWorktreeRoot(workdir: string): Promise { +export async function mainWorktreeRoot(workdir: string): Promise { const out = await git(["worktree", "list", "--porcelain"], workdir); const line = out.split("\n").find((l) => l.startsWith("worktree ")); if (!line) throw new WorktreeError("could not resolve the repo's main worktree"); @@ -301,9 +302,18 @@ export async function removeForkWorktree(opts: { worktreePath: string; branch?: string; deleteBranch?: boolean; + /** + * What the workspace setup recipe put in the worktree (worktree-relative, + * see workspace-setup.ts). Taken out BEFORE the WIP snapshot: a copied + * secret must never land in a commit (even if the agent un-ignored it), and + * a shared directory's symlink is the primary checkout's, not the fork's work. + */ + setup?: { copied?: readonly string[]; shared?: readonly string[] }; }): Promise { const mainRoot = await mainWorktreeRoot(opts.workdir).catch(() => opts.workdir); + if (opts.setup) removeSetupArtifacts(opts.worktreePath, opts.setup); + // Preserve uncommitted work on the KEPT branch before the forced removal — // otherwise `worktree remove --force` silently discards it, and "the branch // is kept so work survives" would only be true for already-committed work. @@ -343,6 +353,42 @@ export async function removeForkWorktree(opts: { } } +/** + * Take the setup recipe's files back out of a worktree: copied files (plain + * files only) and shared-directory symlinks (only while still symlinks — a + * directory the agent put there instead is its work). Best-effort, never + * throws, and never follows a symlink: a path whose parent is one is left + * alone, so this can only ever unlink an entry inside `root`. + */ +function removeSetupArtifacts(root: string, setup: { copied?: readonly string[]; shared?: readonly string[] }): void { + const entry = (rel: string): string | null => { + const parts = rel.split("/"); + if (!rel || path.isAbsolute(rel) || parts.some((p) => !p || p === "." || p === "..")) return null; + let at = root; + for (const part of parts.slice(0, -1)) { + at = path.join(at, part); + try { + const st = lstatSync(at); + if (st.isSymbolicLink() || !st.isDirectory()) return null; + } catch { + return null; + } + } + return path.join(root, rel); + }; + const unlinkIf = (rel: string, ok: (st: Stats) => boolean) => { + const at = entry(rel); + if (!at) return; + try { + if (ok(lstatSync(at))) unlinkSync(at); + } catch { + // already gone + } + }; + for (const rel of setup.shared ?? []) unlinkIf(rel, (st) => st.isSymbolicLink()); + for (const rel of setup.copied ?? []) unlinkIf(rel, (st) => st.isFile() || st.isSymbolicLink()); +} + /** Current branch of a workdir (for bind mode), or null when detached/non-repo. */ export async function currentBranch(workdir: string): Promise { try { diff --git a/src/daemon/routines/service.ts b/src/daemon/routines/service.ts index 1d5cdda0..3d9814dc 100644 --- a/src/daemon/routines/service.ts +++ b/src/daemon/routines/service.ts @@ -779,7 +779,8 @@ export class RoutineService { } } if (!sessionId) { - sessionId = await this.#host.createSession({ + const created = await this.#host + .createSession({ name: spec.session === "persistent" ? spec.name : `${spec.name}-${run.id.slice(0, 6)}`, workdir, provider: spec.provider, @@ -793,7 +794,16 @@ export class RoutineService { // TOTAL — only a fresh session's query is exactly one run. ...(spec.session === "fresh" && spec.maxCostUsd !== undefined ? { maxCostUsd: spec.maxCostUsd } : {}), label: spec.name, - }); + }) + .catch((err: unknown) => { + // A worktree that couldn't be prepared is an infrastructure + // error: say so, so it doesn't read as the agent failing. + if ((err as { infrastructure?: unknown }).infrastructure !== true) throw err; + finish("failed", { reason: clip(`workspace setup failed (an infrastructure error; the agent never started): ${err instanceof Error ? err.message : String(err)}`, 4_000) }); + return null; + }); + if (created === null) return; + sessionId = created; // Before any store write, so a failure still finds the session to disarm. live.sessionId = sessionId; if (spec.session === "persistent") this.#store.setPersistentSession(routine.id, sessionId); diff --git a/src/daemon/session-manager.ts b/src/daemon/session-manager.ts index ce6e462d..86dc605e 100644 --- a/src/daemon/session-manager.ts +++ b/src/daemon/session-manager.ts @@ -175,6 +175,7 @@ import { isGitRepo, removeForkWorktree, } from "./git-worktree.js"; +import { prepareWorktree, WorktreeSetupFailure, type WorktreeSetupResult } from "./workspace-setup.js"; import type { DailyUsageBucket, LifetimeUsageTotals } from "./memory/store.js"; /** @@ -4770,7 +4771,7 @@ mcpHub: this.#mcpHub, : "carrying your uncommitted changes"; const setupHint = this.#config?.fork?.setup ? " Running the configured fork setup now — the first turn will wait for it." - : " Build dependencies (e.g. node_modules) aren't present in a fresh worktree — run your project's setup before building."; + : " Ignored files and build dependencies (e.g. .env, node_modules) aren't in a fresh worktree unless the repository's committed .codeoid/setup.yaml brings them — otherwise run your project's setup before building."; workdirNote = `🌿 Isolated in a new git worktree on branch \`${wt.branch}\` (${origin}).${setupHint}`; } catch (err) { const reason = err instanceof Error ? err.message : String(err); @@ -4934,9 +4935,12 @@ mcpHub: this.#mcpHub, ); // Make the new worktree buildable in the background (fork stays cheap; the - // fork's first turn waits for it). Only for worktrees WE created. - if (worktree?.createdByCodeoid && this.#config?.fork?.setup) { - fork.beginSetup(this.#config.fork.setup); + // fork's first turn waits for it). Only for worktrees WE created: the + // repository's setup recipe first (its files, then its command when + // trusted), then the operator's fork.setup command. + if (worktree?.createdByCodeoid) { + fork.beginWorktreePrepare(this.#config?.worktree?.setup); + if (this.#config?.fork?.setup) fork.beginSetup(this.#config.fork.setup); } return { type: "response.ok", requestId: msg.id, data: fork.toInfo() }; @@ -5964,11 +5968,24 @@ mcpHub: this.#mcpHub, createSession: async (o) => { let workdir = o.workdir; let worktree: SessionWorktree | undefined; + let setup: WorktreeSetupResult | undefined; if (o.worktree) { if (!(await isGitRepo(o.workdir))) throw new Error(`worktree isolation needs a git repository: ${o.workdir}`); const wt = await createForkWorktree({ workdir: o.workdir, label: o.label, shortId: randomUUID().slice(0, 8) }); workdir = normalizeWorkdir(wt.workdir) ?? wt.path; worktree = { path: wt.path, branch: wt.branch, createdByCodeoid: true }; + // A routine run is unattended: prepare before the agent starts, and + // fail the run as an infrastructure error (not the agent's) when + // the worktree can't be made ready. Nothing but this run's fresh + // worktree is removed — the primary checkout is never touched. + setup = await prepareWorktree({ worktreePath: wt.path, policy: this.#config?.worktree?.setup }); + if (setup.error) { + await removeForkWorktree({ workdir: o.workdir, worktreePath: wt.path, branch: wt.branch, deleteBranch: true }).catch(() => {}); + throw new WorktreeSetupFailure(setup); + } + if (setup.copied.length > 0 || setup.shared.length > 0) { + worktree = { ...worktree, setup: { copied: setup.copied, shared: setup.shared } }; + } } const session = new Session({ // The conductor's name is reserved for the singleton. @@ -5996,6 +6013,7 @@ mcpHub: this.#mcpHub, onStatusChange: this.#statusObserver, ...this.#modelHooks, }); + if (setup) session.noteWorktreePrepared(setup); this.#sessions.set(session.id, session); return session.id; }, diff --git a/src/daemon/session.ts b/src/daemon/session.ts index c7a7b61d..5f83b505 100644 --- a/src/daemon/session.ts +++ b/src/daemon/session.ts @@ -71,6 +71,7 @@ import { createHash } from "node:crypto"; import { realpathSync } from "node:fs"; import { lstat, readdir } from "node:fs/promises"; import { removeForkWorktree } from "./git-worktree.js"; +import { describeSetupResult, prepareWorktree, type WorktreeSetupPolicy, type WorktreeSetupResult } from "./workspace-setup.js"; import { copyCheckpoints, createCheckpoint, @@ -688,8 +689,14 @@ export class Session { #pack?: PackActivation; /** Fork lineage (set from opts / restored from meta). */ readonly forkedFrom?: { sessionId: string; name: string; atTurn: number }; - /** Git worktree backing workdir, when isolated (set from opts / meta). */ - readonly worktree?: SessionWorktree; + /** + * Git worktree backing workdir, when isolated (set from opts / meta). + * Mutable only to record what its setup recipe put in it. + */ + #worktree?: SessionWorktree; + get worktree(): SessionWorktree | undefined { + return this.#worktree; + } /** * Goal + role→backend bindings when this session was created with the * Collaborative toggle (set from opts / restored from meta). Readonly: the @@ -1197,7 +1204,7 @@ export class Session { this.role = opts.role; this.#pack = opts.pack; this.forkedFrom = opts.forkedFrom; - this.worktree = opts.worktree; + this.#worktree = opts.worktree; this.collaboration = opts.collaboration; this.collaborationRole = opts.collaborationRole; this.#blackboardMcp = opts.blackboardMcp; @@ -2919,6 +2926,16 @@ export class Session { * worktree's dependencies are ready. Cleared once awaited. */ #pendingSetup: Promise | null = null; + /** The worktree setup recipe's step in that chain, and its abort (destroy stops it). */ + #setupRecipe: { done: Promise; abort: AbortController } | null = null; + + /** Queue `step` after any setup already pending; the first turn waits for the whole chain. */ + #chainSetup(step: () => Promise): Promise { + const prev = this.#pendingSetup ?? Promise.resolve(); + const next = prev.catch(() => {}).then(step); + this.#pendingSetup = next; + return next; + } async send( text: string, @@ -4056,11 +4073,19 @@ export class Session { // never blocks destroy). Ownership-gated: only worktrees WE created // (createdByCodeoid) are removed; a user-bound worktree is left alone. The // branch is KEPT by default so any work committed in the fork survives. + // A setup recipe still running is stopped first, so nothing writes into + // the worktree while it's being removed. + const recipe = this.#setupRecipe; + if (recipe) { + recipe.abort.abort(); + await recipe.done.catch(() => {}); + } if (this.worktree?.createdByCodeoid) { await removeForkWorktree({ workdir: this.worktree.path, worktreePath: this.worktree.path, branch: this.worktree.branch, + ...(this.worktree.setup ? { setup: this.worktree.setup } : {}), // A session undone by the request that made it leaves no branch behind. ...(opts.deleteBranch ? { deleteBranch: true } : {}), }).catch(() => {}); @@ -4430,8 +4455,8 @@ export class Session { this.#persistAndBuffer(start); this.#broadcastRaw(start); - const t0 = Date.now(); - this.#pendingSetup = (async () => { + this.#chainSetup(async () => { + const t0 = Date.now(); try { await execFileP("sh", ["-c", command], { cwd: this.workdir, @@ -4466,7 +4491,66 @@ export class Session { this.#persistAndBuffer(fail); this.#broadcastRaw(fail); } - })(); + }); + } + + /** + * Apply the repository's setup recipe (workspace-setup.ts) to the worktree + * codeoid created for this session, in the BACKGROUND like + * {@link beginSetup} and ahead of it: the first turn waits for both. A + * failure is reported as an infrastructure notice, never thrown, and the + * worktree is kept as it is. + */ + beginWorktreePrepare(policy: WorktreeSetupPolicy | undefined): void { + const wt = this.#worktree; + if (!wt?.createdByCodeoid) return; + const abort = new AbortController(); + const done = this.#chainSetup(async () => { + if (abort.signal.aborted || this.#destroyed) return; + const result = await prepareWorktree({ worktreePath: wt.path, policy, signal: abort.signal }); + if (this.#destroyed) return; + this.noteWorktreePrepared(result); + }); + this.#setupRecipe = { done, abort }; + void done.finally(() => { + if (this.#setupRecipe?.done === done) this.#setupRecipe = null; + }); + } + + /** + * Record what a setup recipe put in this session's worktree (so it stays + * out of checkpoints and is taken back out on removal) and tell the + * client. Called by {@link beginWorktreePrepare}, or directly when the + * recipe ran before the session existed (routines). + */ + noteWorktreePrepared(result: WorktreeSetupResult): void { + const wt = this.#worktree; + if (wt && (result.copied.length > 0 || result.shared.length > 0)) { + this.#worktree = { ...wt, setup: { copied: [...result.copied], shared: [...result.shared] } }; + this.#saveMetaNow(); + } + if (result.error) { + console.error(`[codeoid/session ${this.id.slice(0, 8)}] worktree setup failed: ${result.error}`); + } + const text = describeSetupResult(result); + if (!text) return; + const msg = this.#makeMessage("info", text, SYSTEM_IDENTITY, undefined, undefined, { + event: result.error ? "worktree.setup.failed" : "worktree.setup.done", + infrastructure: true, + copied: result.copied, + shared: result.shared, + ...(result.command ? { exitCode: result.command.exitCode, timedOut: result.command.timedOut } : {}), + ...(result.commandSkipped ? { commandSkipped: result.commandSkipped } : {}), + }); + this.#persistAndBuffer(msg); + this.#broadcastRaw(msg); + } + + /** Worktree-root paths the setup recipe added, as absolute paths (checkpoint exclusions). */ + #setupPaths(): string[] { + const wt = this.#worktree; + if (!wt?.setup) return []; + return [...wt.setup.copied, ...wt.setup.shared].map((rel) => join(wt.path, rel)); } /** @@ -7084,6 +7168,7 @@ export class Session { // start snapshot, past its wait; for an end one, the next turn began. isLate: opts.lateWhen ?? (() => started), excludeDirs: this.#protectedDirs(), + excludePaths: this.#setupPaths(), limits: this.#checkpointLimits(), ...(opts.onlyIfMissing ? { onlyIfMissing: true } : {}), }), @@ -7301,6 +7386,7 @@ export class Session { workdir: this.workdir, sessionId: this.id, excludeDirs: this.#protectedDirs(), + excludePaths: this.#setupPaths(), limits: this.#checkpointLimits(), }); if (!now.ok) throw new Error(now.reason); @@ -7449,6 +7535,7 @@ export class Session { workdir: this.workdir, sessionId: this.id, excludeDirs: this.#protectedDirs(), + excludePaths: this.#setupPaths(), limits: this.#checkpointLimits(), }); if (!now.ok) return { ...plan, planId: planHash([turnId, ...removedIds]), filesUnavailable: `the current files couldn't be read: ${now.reason}` }; diff --git a/src/daemon/settings/manifest.ts b/src/daemon/settings/manifest.ts index d7f55db2..429051d5 100644 --- a/src/daemon/settings/manifest.ts +++ b/src/daemon/settings/manifest.ts @@ -156,6 +156,25 @@ const general: SettingsTab = { }), ], }, + { + id: "worktree-setup", + title: "Worktree setup recipe", + description: + "A repository's committed .codeoid/setup.yaml says which ignored files to copy and which directories to share into a worktree codeoid creates. Those steps always apply; its command is a separate opt-in.", + fields: [ + cfg("worktree.setup.runCommands", "Run recipe commands", "Whether the recipe's setup command may run in a new worktree. Reading a recipe never runs anything.", { + kind: "enum", + default: "never", + options: [ + { value: "never", label: "Never", description: "Copy and share files only." }, + { value: "trusted-repos", label: "Trusted repositories", description: "Run it for the repositories listed below." }, + ], + }), + cfg("worktree.setup.trustedRepos", "Trusted repositories", "Primary checkouts (absolute paths, ~ allowed) whose recipe command may run.", { + kind: "string[]", + }), + ], + }, ], }; diff --git a/src/daemon/workspace-setup.ts b/src/daemon/workspace-setup.ts new file mode 100644 index 00000000..ed02e2bd --- /dev/null +++ b/src/daemon/workspace-setup.ts @@ -0,0 +1,645 @@ +/** + * Workspace setup recipe for the worktrees codeoid creates (design §12.7). + * + * A fresh worktree carries tracked files only: no `.env`, no installed + * dependencies, and (by design, see git-worktree.ts) no `post-checkout` hook. + * A repository says how to make one usable in `.codeoid/setup.yaml`: + * + * ```yaml + * copy: [.env, "config/*.local.json"] # ignored files, copied from the primary checkout + * share: [node_modules] # directories, symlinked to the primary checkout + * command: bun install --frozen-lockfile # run in the new worktree, only when trusted + * timeoutSec: 600 + * resources: { ports: 2, env: [DATABASE_URL] } # declared only; leased in a later slice + * ``` + * + * Trust, in order of how much each step could do: + * + * - The recipe is read from the PRIMARY checkout's committed `HEAD` (`git + * ls-tree` + `cat-file` on the source repo), never from the new + * worktree's files or a candidate branch — a branch cannot rewrite how + * its own worktree is prepared. Reading and parsing execute nothing. + * - `copy` / `share` sources resolve inside the primary checkout (realpath + * containment, never `.git`); destinations are created inside the new + * worktree without following a symlink. A copied file must be ignored by + * git there, so it can never be committed; nothing existing is overwritten. + * - `command` runs only under `worktree.setup.runCommands: "trusted-repos"` + * for a repository the operator listed — the pack-trust model (an + * untrusted command gate fails closed; declaring is not executing). It + * gets a built environment, never the daemon's own (GHSA-38vh). + * + * Everything is validated before anything is written; a failure while + * writing rolls back what this run created. A setup failure is an + * infrastructure error: it is reported, never thrown, and never removes the + * worktree or touches the primary checkout. + */ + +import { execFile, spawn, type ChildProcess } from "node:child_process"; +import { chmodSync, constants, copyFileSync, lstatSync, mkdirSync, realpathSync, rmdirSync, type Stats, statSync, symlinkSync, unlinkSync } from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; +import { z } from "zod"; +import { mainWorktreeRoot } from "./git-worktree.js"; +import { buildSubprocessEnv } from "./providers/env.js"; + +const execFileP = promisify(execFile); + +/** Where the recipe lives, relative to the repository root. */ +export const SETUP_RECIPE_PATH = ".codeoid/setup.yaml"; +/** Default wall-clock for the setup command (dependency installs can be slow). */ +export const DEFAULT_SETUP_TIMEOUT_SEC = 600; + +const LIMITS = { + RECIPE_BYTES: 64 * 1024, + COPY_FILES: 500, + COPY_BYTES: 256 * 1024 * 1024, + OUTPUT_TAIL: 8 * 1024, +} as const; + +// ── Recipe schema ──────────────────────────────────────────────────────────── + +/** `./a/b/` → `a/b`. */ +function normalizeRel(p: string): string { + let s = p.trim(); + while (s.startsWith("./")) s = s.slice(2); + while (s.length > 1 && s.endsWith("/")) s = s.slice(0, -1); + return s; +} + +const GLOB_CHARS = /[*?[\]{}]/; + +/** Why `p` can't name a path inside the repository, or null when it can. */ +function relPathProblem(p: string, glob: boolean): string | null { + if (p.includes("\0")) return "contains a NUL byte"; + if (p.includes("\\")) return "uses a backslash (use / separators)"; + if (path.posix.isAbsolute(p) || /^[A-Za-z]:/.test(p)) return "is absolute (paths are relative to the repository root)"; + if (p.startsWith("~")) return "starts with ~ (paths are relative to the repository root)"; + const segs = p.split("/"); + if (segs.includes("..")) return "has a .. segment (paths must stay inside the repository)"; + if (segs.includes(".git")) return "is inside .git"; + if (segs.every((s) => s === "" || s === ".")) return "names the repository root"; + if (!glob && GLOB_CHARS.test(p)) return "is a glob (share takes plain directory paths)"; + return null; +} + +const relPath = (glob: boolean) => + z + .string() + .min(1) + .max(1024) + .transform(normalizeRel) + .superRefine((p, ctx) => { + const why = relPathProblem(p, glob); + if (why) ctx.addIssue({ code: "custom", message: `${JSON.stringify(p)} ${why}` }); + }); + +export const SetupRecipeSchema = z + .object({ + /** Ignored files to copy from the primary checkout; globs allowed. */ + copy: z.array(relPath(true)).max(256).default([]), + /** Directories to symlink from the primary checkout (e.g. node_modules). */ + share: z.array(relPath(false)).max(64).default([]), + /** Shell command run in the new worktree's root — only for a trusted repository. */ + command: z.string().trim().min(1).max(16_384).optional(), + /** Wall-clock limit for `command`, in seconds. */ + timeoutSec: z.number().int().min(1).max(7_200).optional(), + /** Resources each worktree needs. Declared and validated only — leasing them is a later slice. */ + resources: z + .object({ + ports: z.number().int().min(0).max(64).optional(), + env: z + .array(z.string().regex(/^[A-Z_][A-Z0-9_]*$/, "must be an UPPER_SNAKE environment variable name")) + .max(64) + .optional(), + }) + .strict() + .optional(), + }) + .strict(); + +export type SetupRecipe = z.infer; + +export class SetupRecipeError extends Error { + override name = "SetupRecipeError"; +} + +/** Parse + validate a recipe's YAML text. Throws {@link SetupRecipeError} with every problem named. */ +export function parseSetupRecipe(text: string): SetupRecipe { + let raw: unknown; + try { + raw = Bun.YAML.parse(text); + } catch (err) { + throw new SetupRecipeError(`${SETUP_RECIPE_PATH} is not valid YAML: ${err instanceof Error ? err.message : String(err)}`); + } + if (raw === null || raw === undefined) raw = {}; + const parsed = SetupRecipeSchema.safeParse(raw); + if (!parsed.success) { + const problems = parsed.error.issues.map((i) => `${i.path.length ? i.path.join(".") : "(root)"}: ${i.message}`); + throw new SetupRecipeError(`${SETUP_RECIPE_PATH} is invalid — ${problems.join("; ")}`); + } + return parsed.data; +} + +// ── git ────────────────────────────────────────────────────────────────────── + +/** git with hooks and fsmonitor off (as git-worktree.ts); `okCodes` are exits that aren't errors. */ +async function git(args: string[], cwd: string, opts: { okCodes?: number[]; input?: string } = {}): Promise<{ code: number; stdout: string }> { + const argv = ["-c", "core.hooksPath=/dev/null", "-c", "core.fsmonitor=false", ...args]; + if (opts.input !== undefined) { + return new Promise((resolve, reject) => { + const child = spawn("git", argv, { cwd, stdio: ["pipe", "pipe", "pipe"] }); + let stdout = ""; + let stderr = ""; + child.stdout.on("data", (b: Buffer) => { + stdout += b.toString("utf8"); + }); + child.stderr.on("data", (b: Buffer) => { + stderr += b.toString("utf8"); + }); + child.on("error", reject); + child.on("close", (code) => { + const c = code ?? 1; + if (c === 0 || opts.okCodes?.includes(c)) resolve({ code: c, stdout }); + else reject(new Error(`git ${args[0]} exited ${c}: ${stderr.trim()}`)); + }); + child.stdin.end(opts.input); + }); + } + try { + const { stdout } = await execFileP("git", argv, { cwd, timeout: 60_000, maxBuffer: 16 * 1024 * 1024 }); + return { code: 0, stdout }; + } catch (err) { + const code = (err as { code?: unknown }).code; + if (typeof code === "number" && opts.okCodes?.includes(code)) { + return { code, stdout: String((err as { stdout?: unknown }).stdout ?? "") }; + } + throw err; + } +} + +/** Of `rels`, the ones git ignores in `worktree` (a tracked path is never ignored). */ +async function ignoredIn(worktree: string, rels: readonly string[]): Promise> { + if (rels.length === 0) return new Set(); + const { stdout } = await git(["check-ignore", "-z", "--stdin"], worktree, { okCodes: [1], input: `${rels.join("\0")}\0` }); + return new Set(stdout.split("\0").filter(Boolean)); +} + +/** + * The recipe at the primary checkout's committed `HEAD`, or null when there + * is none (no file, or no commit yet). The working tree is never read: the + * recipe that prepares a worktree is the one the user committed, not one an + * agent left uncommitted or put on a branch. Throws {@link SetupRecipeError} + * when the file is there but unusable. + */ +export async function readSetupRecipe(repoDir: string): Promise<{ recipe: SetupRecipe; rev: string; root: string } | null> { + const root = await mainWorktreeRoot(repoDir); + const head = await git(["rev-parse", "--verify", "--quiet", "HEAD^{commit}"], root, { okCodes: [1, 128] }); + const rev = head.stdout.trim(); + if (head.code !== 0 || !/^[0-9a-f]{40,64}$/.test(rev)) return null; + const listed = (await git(["ls-tree", "-l", "-z", rev, "--", SETUP_RECIPE_PATH], root)).stdout.split("\0").find(Boolean); + if (!listed) return null; + // ` \t` + const m = /^(\d+) (\w+) ([0-9a-f]+)\s+(-|\d+)\t/.exec(listed); + if (!m || m[2] !== "blob" || (m[1] !== "100644" && m[1] !== "100755")) { + throw new SetupRecipeError(`${SETUP_RECIPE_PATH} at ${rev.slice(0, 10)} must be a regular file`); + } + if (Number(m[4]) > LIMITS.RECIPE_BYTES) { + throw new SetupRecipeError(`${SETUP_RECIPE_PATH} is larger than ${LIMITS.RECIPE_BYTES / 1024} KiB`); + } + const text = (await git(["cat-file", "blob", m[3] as string], root)).stdout; + return { recipe: parseSetupRecipe(text), rev, root }; +} + +// ── Policy ─────────────────────────────────────────────────────────────────── + +/** `config.worktree.setup`: whether a recipe's `command` may run. */ +export interface WorktreeSetupPolicy { + /** `never` (default): never run a recipe command. `trusted-repos`: only for a repository in `trustedRepos`. */ + runCommands: "never" | "trusted-repos"; + /** Primary checkouts whose recipe commands may run (`~` allowed; compared by real path). */ + trustedRepos: readonly string[]; +} + +function realOrNull(p: string): string | null { + try { + return realpathSync(p); + } catch { + return null; + } +} + +function expandHome(p: string): string { + return p === "~" ? os.homedir() : p.startsWith("~/") ? path.join(os.homedir(), p.slice(2)) : p; +} + +/** Null when `root`'s recipe command may run, else why not. */ +export function commandRefusal(policy: WorktreeSetupPolicy | undefined, root: string): string | null { + if (!policy || policy.runCommands !== "trusted-repos") { + return "recipe commands are off (set worktree.setup.runCommands to trusted-repos and list this repository in worktree.setup.trustedRepos)"; + } + const real = realOrNull(root); + const trusted = !!real && policy.trustedRepos.some((r) => realOrNull(path.resolve(expandHome(r))) === real); + return trusted ? null : `this repository isn't in worktree.setup.trustedRepos (${root})`; +} + +// ── Prepare ────────────────────────────────────────────────────────────────── + +export interface WorktreeSetupCommandResult { + command: string; + /** Exit code; null when it couldn't start or was killed. */ + exitCode: number | null; + timedOut: boolean; + /** The last few KiB of combined stdout + stderr. */ + outputTail: string; + durationMs: number; +} + +export interface WorktreeSetupResult { + /** A recipe was found and its copy/share steps were put in place. */ + applied: boolean; + /** The commit the recipe was read from. */ + recipeRev?: string; + /** Worktree-relative files copied in — secrets, so excluded from evidence and checkpoints. */ + copied: string[]; + /** Worktree-relative symlinks to the primary checkout's directories. */ + shared: string[]; + /** Recipe entries left out, and why (nothing to copy, already present, not ignored, outside the repo…). */ + skipped: { path: string; reason: string }[]; + warnings: string[]; + /** The recipe's declared resources (not allocated yet). */ + resources?: NonNullable; + command?: WorktreeSetupCommandResult; + /** Why the recipe's command didn't run. */ + commandSkipped?: string; + /** Set when setup failed — an infrastructure error, never the agent's. */ + error?: string; +} + +/** + * Thrown where a caller can't continue without a prepared worktree (an + * unattended routine run). `infrastructure` marks it as a setup problem — + * never counted as the agent's failure. + */ +export class WorktreeSetupFailure extends Error { + override name = "WorktreeSetupFailure"; + readonly infrastructure = true; + constructor(readonly result: WorktreeSetupResult) { + const tail = result.command?.outputTail.trim(); + super(`${result.error ?? "worktree setup failed"}${tail ? `\n${tail.slice(-1_000)}` : ""}`); + } +} + +interface Planned { + rel: string; + src: string; +} + +const isInside = (child: string, root: string) => child === root || child.startsWith(root + path.sep); + +/** + * Walk `rel`'s parent directories under `root`, creating missing ones + * (recorded in `made`), refusing a symlink or non-directory on the way, and + * confirm the result resolves inside `root`. + */ +function ensureParents(root: string, rel: string, made: string[]): void { + const parts = rel.split("/").slice(0, -1); + let at = root; + for (const part of parts) { + at = path.join(at, part); + let st: ReturnType | undefined; + try { + st = lstatSync(at); + } catch { + st = undefined; + } + if (!st) { + mkdirSync(at); + made.push(at); + continue; + } + if (st.isSymbolicLink() || !st.isDirectory()) throw new Error(`${path.relative(root, at)} in the new worktree is not a plain directory`); + } + if (!isInside(realpathSync(at), realpathSync(root))) throw new Error(`${rel} would land outside the new worktree`); +} + +/** Why `rel` can't be created in `root` (a parent is a symlink or a file), or null. Creates nothing. */ +function parentProblem(root: string, rel: string): string | null { + let at = root; + for (const part of rel.split("/").slice(0, -1)) { + at = path.join(at, part); + let st: Stats; + try { + st = lstatSync(at); + } catch { + return null; // missing from here down: created as plain directories + } + if (st.isSymbolicLink() || !st.isDirectory()) return `has ${path.relative(root, at)} in its path, which isn't a plain directory in the new worktree`; + } + return null; +} + +const exists = (p: string): boolean => { + try { + lstatSync(p); + return true; + } catch { + return false; + } +}; + +/** Candidate repo-relative files for one `copy` entry, matched in the primary checkout. */ +function matchCopy(pattern: string, root: string): string[] { + if (!GLOB_CHARS.test(pattern)) return exists(path.join(root, pattern)) ? [pattern] : []; + const out: string[] = []; + for (const rel of new Bun.Glob(pattern).scanSync({ cwd: root, dot: true, onlyFiles: true, followSymlinks: false })) { + const posix = rel.split(path.sep).join("/"); + if (!posix.split("/").includes(".git")) out.push(posix); + if (out.length > LIMITS.COPY_FILES) break; + } + return out.sort(); +} + +/** + * Apply the repository's setup recipe to a worktree codeoid just created: + * copy the ignored files it names, symlink its shared directories, and (when + * the policy trusts the repository) run its command. `worktreePath` is the + * worktree ROOT. Never throws; failures come back in `error`. + */ +export async function prepareWorktree(opts: { + worktreePath: string; + policy?: WorktreeSetupPolicy; + /** Abort a running command (session destroyed, daemon stopping). */ + signal?: AbortSignal; +}): Promise { + const result: WorktreeSetupResult = { applied: false, copied: [], shared: [], skipped: [], warnings: [] }; + const fail = (err: unknown): WorktreeSetupResult => { + result.error = err instanceof Error ? err.message : String(err); + return result; + }; + + let found: Awaited>; + try { + found = await readSetupRecipe(opts.worktreePath); + } catch (err) { + return fail(err); + } + if (!found) return result; + const { recipe, rev, root } = found; + result.recipeRev = rev; + if (recipe.resources) result.resources = recipe.resources; + + const realRoot = realOrNull(root); + const worktree = realOrNull(opts.worktreePath); + if (!realRoot || !worktree) return fail(new Error("the primary checkout or the new worktree is not reachable")); + if (worktree === realRoot) return fail(new Error("refusing to apply a setup recipe to the primary checkout itself")); + const gitDir = path.join(realRoot, ".git"); + + // ── Plan: resolve and check everything before writing anything. ── + const skip = (p: string, reason: string) => result.skipped.push({ path: p, reason }); + /** The real source of a repo-relative path, when it stays in the primary checkout. */ + const source = (rel: string): string | null => { + const real = realOrNull(path.join(root, rel)); + if (!real) return null; + return isInside(real, realRoot) && real !== realRoot && !isInside(real, gitDir) ? real : null; + }; + + const copies: Planned[] = []; + let copyBytes = 0; + try { + const seen = new Set(); + for (const pattern of recipe.copy) { + const matched = matchCopy(pattern, root); + if (matched.length === 0) skip(pattern, "nothing matches it in the primary checkout"); + for (const rel of matched) { + if (seen.has(rel)) continue; + seen.add(rel); + const src = source(rel); + if (!src) { + skip(rel, "resolves outside the primary checkout (a symlink escape) or is missing"); + continue; + } + const st = statSync(src); + if (!st.isFile()) { + skip(rel, "is not a file (use share for directories)"); + continue; + } + const blocked = parentProblem(worktree, rel); + if (blocked) { + skip(rel, blocked); + continue; + } + if (exists(path.join(worktree, rel))) { + skip(rel, "already exists in the new worktree (not overwritten)"); + continue; + } + copyBytes += st.size; + copies.push({ rel, src }); + } + } + if (copies.length > LIMITS.COPY_FILES) throw new SetupRecipeError(`copy matches more than ${LIMITS.COPY_FILES} files`); + if (copyBytes > LIMITS.COPY_BYTES) throw new SetupRecipeError(`copy matches more than ${LIMITS.COPY_BYTES / 1024 / 1024} MiB`); + // A copied file must be ignored where it lands, so it can never be + // committed (the destroy-time WIP snapshot runs `git add -A`). + const ignored = await ignoredIn(worktree, copies.map((c) => c.rel)); + for (const c of copies.splice(0)) { + if (ignored.has(c.rel)) copies.push(c); + else skip(c.rel, "is not ignored by git in the new worktree (copying it would let it be committed)"); + } + } catch (err) { + return fail(err); + } + + const shares: Planned[] = []; + for (const rel of recipe.share) { + if (!exists(path.join(root, rel))) { + skip(rel, "doesn't exist in the primary checkout"); + continue; + } + const src = source(rel); + if (!src) { + skip(rel, "resolves outside the primary checkout (a symlink escape)"); + continue; + } + if (!statSync(src).isDirectory()) { + skip(rel, "is not a directory (use copy for files)"); + continue; + } + const blocked = parentProblem(worktree, rel); + if (blocked) { + skip(rel, blocked); + continue; + } + if (exists(path.join(worktree, rel))) { + skip(rel, "already exists in the new worktree (not replaced)"); + continue; + } + shares.push({ rel, src }); + } + + // ── Apply, rolling back on any failure. ── + const createdFiles: string[] = []; + const createdDirs: string[] = []; + try { + for (const c of copies) { + ensureParents(worktree, c.rel, createdDirs); + const dest = path.join(worktree, c.rel); + // EXCL: never overwrite, and never write through a symlink planted at dest. + copyFileSync(c.src, dest, constants.COPYFILE_EXCL); + createdFiles.push(dest); + chmodSync(dest, statSync(c.src).mode & 0o777); + result.copied.push(c.rel); + } + for (const s of shares) { + ensureParents(worktree, s.rel, createdDirs); + const dest = path.join(worktree, s.rel); + symlinkSync(s.src, dest, "dir"); + createdFiles.push(dest); + result.shared.push(s.rel); + } + } catch (err) { + for (const f of createdFiles.reverse()) { + try { + unlinkSync(f); + } catch {} + } + for (const d of createdDirs.reverse()) { + try { + rmdirSync(d); + } catch {} + } + result.copied = []; + result.shared = []; + return fail(new Error(`setup couldn't put the recipe's files in place (nothing was left behind): ${err instanceof Error ? err.message : String(err)}`)); + } + result.applied = true; + + if (result.shared.length > 0) { + try { + const ignored = await ignoredIn(worktree, result.shared); + for (const rel of result.shared) { + if (!ignored.has(rel)) { + result.warnings.push( + `the shared link ${rel} isn't ignored by git (a "${rel}/" pattern matches directories, not a symlink — use "${rel}"), so git status shows it`, + ); + } + } + } catch { + // advisory only + } + } + + if (recipe.command) { + const refusal = commandRefusal(opts.policy, root); + if (refusal) { + result.commandSkipped = refusal; + } else { + const timeoutSec = recipe.timeoutSec ?? DEFAULT_SETUP_TIMEOUT_SEC; + result.command = await runSetupCommand(recipe.command, { + cwd: worktree, + timeoutMs: timeoutSec * 1000, + env: { CODEOID_PRIMARY_CHECKOUT: realRoot, CODEOID_WORKTREE: worktree }, + ...(opts.signal ? { signal: opts.signal } : {}), + }); + if (result.command.timedOut) result.error = `the setup command timed out after ${timeoutSec}s`; + else if (result.command.exitCode !== 0) { + result.error = result.command.exitCode === null ? "the setup command didn't run to completion" : `the setup command exited ${result.command.exitCode}`; + } + } + } + return result; +} + +/** + * Run the setup command in its own process group (killed whole at the + * timeout or on abort — a package manager's children otherwise hold the + * pipes open), with a built environment: the shared safe basics, never the + * daemon's secrets. `CODEOID_AGENT_ENV_ALLOW` passes more, as everywhere. + */ +async function runSetupCommand( + command: string, + opts: { cwd: string; timeoutMs: number; env: Record; signal?: AbortSignal }, +): Promise { + const t0 = Date.now(); + return new Promise((resolve) => { + let tail = Buffer.alloc(0); + const push = (b: Buffer) => { + tail = Buffer.concat([tail, b]); + if (tail.length > LIMITS.OUTPUT_TAIL * 2) tail = tail.subarray(tail.length - LIMITS.OUTPUT_TAIL); + }; + let child: ChildProcess; + try { + child = spawn("/bin/sh", ["-c", command], { + cwd: opts.cwd, + env: { ...buildSubprocessEnv({ prefixes: ["XDG_"] }), ...opts.env }, + stdio: ["ignore", "pipe", "pipe"], + detached: true, + }); + } catch (err) { + resolve({ command, exitCode: null, timedOut: false, outputTail: err instanceof Error ? err.message : String(err), durationMs: 0 }); + return; + } + child.stdout?.on("data", push); + child.stderr?.on("data", push); + let timedOut = false; + let settled = false; + const killGroup = () => { + try { + if (child.pid) process.kill(-child.pid, "SIGKILL"); + } catch {} + }; + const done = (exitCode: number | null) => { + if (settled) return; + settled = true; + clearTimeout(timer); + opts.signal?.removeEventListener("abort", onAbort); + const text = tail.toString("utf8"); + resolve({ + command, + exitCode, + timedOut, + outputTail: text.length > LIMITS.OUTPUT_TAIL ? text.slice(-LIMITS.OUTPUT_TAIL) : text, + durationMs: Date.now() - t0, + }); + }; + const onAbort = () => { + if (settled) return; + killGroup(); + done(null); + }; + const timer = setTimeout(() => { + timedOut = true; + killGroup(); + done(null); + }, opts.timeoutMs); + if (opts.signal?.aborted) onAbort(); + else opts.signal?.addEventListener("abort", onAbort, { once: true }); + child.on("error", (e) => { + push(Buffer.from(e.message)); + done(null); + }); + child.on("close", (code) => done(code)); + }); +} + +/** + * One line (plus the output tail on a command failure) for the session's + * notice, or null when there was no recipe and nothing to say. + */ +export function describeSetupResult(r: WorktreeSetupResult): string | null { + if (!r.applied && !r.error) return null; + const parts: string[] = []; + if (r.copied.length) parts.push(`copied ${r.copied.join(", ")}`); + if (r.shared.length) parts.push(`shared ${r.shared.join(", ")}`); + if (r.command && !r.error) parts.push(`ran \`${r.command.command}\` in ${(r.command.durationMs / 1000).toFixed(1)}s`); + const skipped = r.skipped.map((s) => `${s.path} ${s.reason}`); + const notes = [...skipped, ...r.warnings, ...(r.commandSkipped ? [`the setup command didn't run: ${r.commandSkipped}`] : [])]; + const rev = r.recipeRev ? ` (recipe from ${r.recipeRev.slice(0, 10)})` : ""; + if (r.error) { + const tail = r.command?.outputTail.trim() ? `\n${r.command.outputTail.trim().slice(-600)}` : ""; + return `✗ Worktree setup failed${rev}: ${r.error}. This is a setup problem, not the agent's — the worktree is kept as it is.${ + parts.length ? ` Done before it failed: ${parts.join("; ")}.` : "" + }${notes.length ? ` Notes: ${notes.join("; ")}.` : ""}${tail}`; + } + return `✓ Worktree prepared${rev}${parts.length ? `: ${parts.join("; ")}` : ""}.${notes.length ? ` Notes: ${notes.join("; ")}.` : ""}`; +} diff --git a/src/tests/routine-manager.test.ts b/src/tests/routine-manager.test.ts index 7c808a10..fb2f4523 100644 --- a/src/tests/routine-manager.test.ts +++ b/src/tests/routine-manager.test.ts @@ -412,4 +412,41 @@ describe("budgets and isolation", () => { expect(s?.workdir).not.toBe(repo); expect(providers[0]!.capturedOpts[0]!.workdir).toBe(s!.workdir); }); + + const commitRecipe = (yaml: string) => { + const git = (...a: string[]) => execFileSync("git", a, { cwd: repo }); + mkdirSync(join(repo, ".codeoid"), { recursive: true }); + writeFileSync(join(repo, ".codeoid", "setup.yaml"), yaml); + writeFileSync(join(repo, ".gitignore"), ".env\n"); + git("add", "."); + git("commit", "-qm", "recipe"); + }; + + it("a worktree run gets the repository's setup recipe applied before the agent starts", async () => { + commitRecipe("copy: [.env]\n"); + writeFileSync(join(repo, ".env"), "K=v\n"); + await create({ worktree: true }); + await call({ type: "routine.run", routine: "triage" }); + await settled(); + const run = ((await call({ type: "routine.get", routine: "triage" })) as RoutineSnapshotMsg).runs![0]!; + expect(run.status).toBe("succeeded"); + const list = await call({ type: "session.list" }); + const s = list.type === "session.list.result" ? list.sessions.find((x) => x.id === run.sessionId)! : undefined; + expect(s?.worktree?.setup?.copied).toEqual([".env"]); + expect(readFileSync(join(s!.worktree!.path, ".env"), "utf8")).toBe("K=v\n"); + }); + + it("a setup recipe that fails is the run's infrastructure error — the agent never starts, the checkout is untouched", async () => { + commitRecipe("copy: [../outside]\n"); + await create({ worktree: true }); + await call({ type: "routine.run", routine: "triage" }); + await settled(); + const run = ((await call({ type: "routine.get", routine: "triage" })) as RoutineSnapshotMsg).runs![0]!; + expect(run.status).toBe("failed"); + expect(run.reason).toMatch(/^workspace setup failed \(an infrastructure error; the agent never started\): .*\.\. segment/); + expect(providers).toHaveLength(0); + // Its fresh worktree was removed; the repository has no leftover branch. + expect(execFileSync("git", ["branch", "--list", "codeoid/*"], { cwd: repo }).toString().trim()).toBe(""); + expect(existsSync(join(repo, "README.md"))).toBe(true); + }); }); diff --git a/src/tests/session-fork.test.ts b/src/tests/session-fork.test.ts index ea2a6625..5ac38a37 100644 --- a/src/tests/session-fork.test.ts +++ b/src/tests/session-fork.test.ts @@ -655,4 +655,48 @@ describe("SessionManager session.fork — git worktree isolation", () => { expect(events).toContain("fork.setup.start"); expect(events).toContain("fork.setup.done"); }); + + it("W8: the repository's setup recipe prepares the fork's worktree before its first turn, and destroy takes it back out", async () => { + const repo = await makeGitRepoWithDirtyEdit(); + mkdirSync(join(repo, ".codeoid")); + writeFileSync(join(repo, ".codeoid", "setup.yaml"), "copy: [.env]\ncommand: cat .env > .recipe-ran\n"); + writeFileSync(join(repo, ".gitignore"), ".env\n.recipe-ran\n"); + await gitIn(["add", ".codeoid", ".gitignore"], repo); + await gitIn(["commit", "-m", "recipe"], repo); + writeFileSync(join(repo, ".env"), "TOKEN=1\n"); + const { registry } = makeRegistry(); + const config = { ...loadConfig(), worktree: { setup: { runCommands: "trusted-repos" as const, trustedRepos: [repo] } } }; + const manager = new SessionManager(store, transcript, undefined, undefined, undefined, { + providers: registry, + config, + } as never); + const c = client(AUTH); + const parentId = await createIn(manager, c, repo); + await sendAndSettle(manager, c, parentId, "hello"); + + const resp = await manager.handle({ type: "session.fork", id: "w8", sessionId: parentId }, AUTH, c); + const fork = (resp as { data: { id: string; workdir: string; worktree: { path: string; branch: string } } }).data; + await sendAndSettle(manager, c, fork.id, "go"); + expect(readFileSync(join(fork.workdir, ".recipe-ran"), "utf8")).toBe("TOKEN=1\n"); + + // Recorded on the session (checkpoint exclusions, cleanup) and surfaced. + const info = (await manager.handle({ type: "session.list", id: "l8" }, AUTH, c)) as { + sessions: { id: string; worktree?: { setup?: { copied: string[] } } }[]; + }; + expect(info.sessions.find((s) => s.id === fork.id)?.worktree?.setup?.copied).toEqual([".env"]); + await transcript.flush(); + const done = (await transcript.loadTranscript(fork.id, {})) + .map((r) => r.message) + .filter((m) => m.type === "session.message") + .find((m) => (m as { metadata?: { event?: string } }).metadata?.event === "worktree.setup.done") as + | { content?: string; metadata?: { copied?: string[] } } + | undefined; + expect(done?.metadata?.copied).toEqual([".env"]); + + await manager.handle({ type: "session.destroy", id: "d8", sessionId: fork.id }, AUTH, c); + expect(existsSync(fork.worktree.path)).toBe(false); + const kept = (await gitIn(["ls-tree", "-r", "--name-only", fork.worktree.branch], repo)).split("\n"); + expect(kept).not.toContain(".env"); + expect(readFileSync(join(repo, ".env"), "utf8")).toBe("TOKEN=1\n"); + }); }); diff --git a/src/tests/workspace-setup.test.ts b/src/tests/workspace-setup.test.ts new file mode 100644 index 00000000..1766617f --- /dev/null +++ b/src/tests/workspace-setup.test.ts @@ -0,0 +1,395 @@ +/** + * workspace-setup — the repository setup recipe for worktrees codeoid + * creates (design §12.7). Real `git` against throwaway repos: the recipe is + * read from the primary checkout's committed HEAD (never a branch or the + * working tree), copy/share stay inside the primary checkout, the command + * runs only when the repository is trusted, and a bad recipe leaves nothing + * behind. + */ + +import { afterEach, beforeEach, describe, expect, it } from "bun:test"; +import { execFile } from "node:child_process"; +import { existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, readlinkSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { createCheckpoint, deleteCheckpoints, shadowGitLiteral } from "../daemon/checkpoints.js"; +import { createForkWorktree, removeForkWorktree } from "../daemon/git-worktree.js"; +import { + commandRefusal, + parseSetupRecipe, + prepareWorktree, + readSetupRecipe, + SetupRecipeError, + type WorktreeSetupPolicy, +} from "../daemon/workspace-setup.js"; +import { loadConfig } from "../config.js"; + +const execFileP = promisify(execFile); +const g = (args: string[], cwd: string) => execFileP("git", args, { cwd }).then((r) => r.stdout.trim()); + +let tmp: string; +let repo: string; +let n = 0; + +const NEVER: WorktreeSetupPolicy = { runCommands: "never", trustedRepos: [] }; +const trusted = (): WorktreeSetupPolicy => ({ runCommands: "trusted-repos", trustedRepos: [repo] }); + +async function commitRecipe(yaml: string, extra: Record = {}): Promise { + mkdirSync(join(repo, ".codeoid"), { recursive: true }); + writeFileSync(join(repo, ".codeoid", "setup.yaml"), yaml); + for (const [f, body] of Object.entries(extra)) writeFileSync(join(repo, f), body); + await g(["add", "-A"], repo); + await g(["commit", "-m", "recipe"], repo); +} + +async function newWorktree(opts: { baseBranch?: string; workdir?: string } = {}) { + return createForkWorktree({ workdir: opts.workdir ?? repo, label: "setup", shortId: `t${++n}`, ...(opts.baseBranch ? { baseBranch: opts.baseBranch } : {}) }); +} + +/** Everything in the worktree except .git, for "nothing was left behind" checks. */ +function listing(dir: string): string[] { + return readdirSync(dir, { recursive: true }) + .map(String) + .filter((p) => p !== ".git" && !p.startsWith(".git/")) + .sort(); +} + +beforeEach(async () => { + tmp = realpathSync(mkdtempSync(join(tmpdir(), "codeoid-setup-"))); + repo = join(tmp, "repo"); + await execFileP("git", ["init", "-b", "main", repo]); + await g(["config", "user.email", "t@t.dev"], repo); + await g(["config", "user.name", "t"], repo); + writeFileSync(join(repo, "file.txt"), "committed\n"); + writeFileSync(join(repo, ".gitignore"), ".env\n.env.*\nnode_modules\n*.local.json\n"); + await g(["add", "."], repo); + await g(["commit", "-m", "init"], repo); + // The primary checkout's ignored state a fresh worktree lacks. + writeFileSync(join(repo, ".env"), "SECRET=1\n"); + mkdirSync(join(repo, "node_modules", "dep"), { recursive: true }); + writeFileSync(join(repo, "node_modules", "dep", "index.js"), "module.exports = 1;\n"); +}); + +afterEach(() => { + rmSync(tmp, { recursive: true, force: true }); +}); + +describe("parseSetupRecipe", () => { + it("accepts the full shape and normalizes paths", () => { + const r = parseSetupRecipe( + "copy: [./.env, 'config/*.local.json']\nshare: [node_modules/]\ncommand: bun install\ntimeoutSec: 30\nresources: { ports: 2, env: [DATABASE_URL] }\n", + ); + expect(r.copy).toEqual([".env", "config/*.local.json"]); + expect(r.share).toEqual(["node_modules"]); + expect(r.command).toBe("bun install"); + expect(r.timeoutSec).toBe(30); + expect(r.resources).toEqual({ ports: 2, env: ["DATABASE_URL"] }); + }); + + it("an empty file is an empty recipe", () => { + expect(parseSetupRecipe("")).toEqual({ copy: [], share: [] }); + }); + + it("rejects paths that leave the repository, and unknown keys", () => { + for (const bad of [ + "copy: ['../outside']", + "copy: ['a/../../b']", + "copy: ['/etc/passwd']", + "copy: ['~/.ssh/id_rsa']", + "copy: ['.git/config']", + "copy: ['C:/x']", + "copy: ['a\\\\b']", + "share: ['node_*']", + "share: ['.']", + "share: ['../deps']", + "commands: echo", + "resources: { ports: 1, cpus: 2 }", + "resources: { env: [lower_case] }", + "timeoutSec: 0", + ]) { + expect(() => parseSetupRecipe(bad), bad).toThrow(SetupRecipeError); + } + }); + + it("invalid YAML names the file", () => { + expect(() => parseSetupRecipe("copy: [unclosed")).toThrow(/setup\.yaml is not valid YAML/); + }); +}); + +describe("readSetupRecipe", () => { + it("is null without a recipe and without a commit", async () => { + expect(await readSetupRecipe(repo)).toBeNull(); + const empty = join(tmp, "empty"); + await execFileP("git", ["init", "-b", "main", empty]); + expect(await readSetupRecipe(empty)).toBeNull(); + }); + + it("reads the committed HEAD of the primary checkout, never its working tree", async () => { + await commitRecipe("copy: [.env]\n"); + // An uncommitted edit (an agent's, say) is not the recipe. + writeFileSync(join(repo, ".codeoid", "setup.yaml"), "copy: [.env]\ncommand: touch pwned\n"); + const found = await readSetupRecipe(repo); + expect(found?.recipe.command).toBeUndefined(); + expect(found?.rev).toBe(await g(["rev-parse", "HEAD"], repo)); + }); + + it("refuses a recipe that is a symlink in the tree", async () => { + mkdirSync(join(repo, ".codeoid")); + symlinkSync("/etc/hostname", join(repo, ".codeoid", "setup.yaml")); + await g(["add", "-A"], repo); + await g(["commit", "-m", "link"], repo); + await expect(readSetupRecipe(repo)).rejects.toThrow(/must be a regular file/); + }); +}); + +describe("prepareWorktree", () => { + it("without a recipe, a worktree is exactly what createForkWorktree made", async () => { + const wt = await newWorktree(); + const before = listing(wt.path); + const r = await prepareWorktree({ worktreePath: wt.path, policy: trusted() }); + expect(r).toEqual({ applied: false, copied: [], shared: [], skipped: [], warnings: [] }); + expect(listing(wt.path)).toEqual(before); + }); + + it("copies ignored files and shares directories from the primary checkout", async () => { + mkdirSync(join(repo, "config")); + writeFileSync(join(repo, "config", "db.local.json"), "{}\n"); + await commitRecipe("copy: [.env, 'config/*.local.json']\nshare: [node_modules]\nresources: { ports: 1 }\n"); + const wt = await newWorktree(); + const r = await prepareWorktree({ worktreePath: wt.path, policy: NEVER }); + + expect(r.error).toBeUndefined(); + expect(r.applied).toBe(true); + expect(r.copied).toEqual([".env", "config/db.local.json"]); + expect(r.shared).toEqual(["node_modules"]); + expect(r.resources).toEqual({ ports: 1 }); + expect(readFileSync(join(wt.path, ".env"), "utf8")).toBe("SECRET=1\n"); + expect(lstatSync(join(wt.path, "node_modules")).isSymbolicLink()).toBe(true); + expect(readlinkSync(join(wt.path, "node_modules"))).toBe(join(repo, "node_modules")); + expect(existsSync(join(wt.path, "node_modules", "dep", "index.js"))).toBe(true); + // Nothing the recipe added shows up to git. + expect(await g(["status", "--porcelain"], wt.path)).toBe(""); + }); + + it("a recipe changed only on a branch is ignored — the primary checkout's HEAD wins", async () => { + await commitRecipe("copy: [.env]\n"); + await g(["checkout", "-b", "feature"], repo); + await commitRecipe("copy: [.env]\nshare: [node_modules]\ncommand: touch pwned\n"); + await g(["checkout", "main"], repo); + + // A clean worktree of the branch … + const wt = await newWorktree({ baseBranch: "feature" }); + expect(readFileSync(join(wt.path, ".codeoid", "setup.yaml"), "utf8")).toContain("pwned"); + const r = await prepareWorktree({ worktreePath: wt.path, policy: trusted() }); + expect(r.copied).toEqual([".env"]); + expect(r.shared).toEqual([]); + expect(r.command).toBeUndefined(); + expect(existsSync(join(wt.path, "pwned"))).toBe(false); + + // … and a fork whose parent is itself a worktree on that branch. + const parent = join(tmp, "feature-wt"); + await g(["worktree", "add", parent, "feature"], repo); + const child = await newWorktree({ workdir: parent }); + const r2 = await prepareWorktree({ worktreePath: child.path, policy: trusted() }); + expect(r2.recipeRev).toBe(await g(["rev-parse", "main"], repo)); + expect(r2.command).toBeUndefined(); + expect(existsSync(join(child.path, "pwned"))).toBe(false); + }); + + it("refuses symlink escapes out of the primary checkout", async () => { + const outside = join(tmp, "outside"); + mkdirSync(outside); + writeFileSync(join(outside, "secret.txt"), "not yours\n"); + symlinkSync(join(outside, "secret.txt"), join(repo, ".env.escape")); + symlinkSync(outside, join(repo, "deps")); + writeFileSync(join(repo, ".gitignore"), ".env\n.env.*\nnode_modules\ndeps\n"); + await commitRecipe("copy: [.env.escape]\nshare: [deps]\n"); + const wt = await newWorktree(); + const r = await prepareWorktree({ worktreePath: wt.path, policy: NEVER }); + + expect(r.copied).toEqual([]); + expect(r.shared).toEqual([]); + expect(r.skipped.map((s) => s.path).sort()).toEqual([".env.escape", "deps"]); + expect(r.skipped.every((s) => /outside the primary checkout/.test(s.reason))).toBe(true); + expect(existsSync(join(wt.path, ".env.escape"))).toBe(false); + expect(existsSync(join(wt.path, "deps"))).toBe(false); + }); + + it("never copies a file git wouldn't ignore, and never overwrites one", async () => { + await commitRecipe("copy: [notes.txt, file.txt]\n"); + writeFileSync(join(repo, "notes.txt"), "untracked, not ignored\n"); + const wt = await newWorktree(); + const r = await prepareWorktree({ worktreePath: wt.path, policy: NEVER }); + expect(r.copied).toEqual([]); + expect(r.skipped).toEqual([ + { path: "file.txt", reason: "already exists in the new worktree (not overwritten)" }, + { path: "notes.txt", reason: "is not ignored by git in the new worktree (copying it would let it be committed)" }, + ]); + expect(existsSync(join(wt.path, "notes.txt"))).toBe(false); + expect(readFileSync(join(wt.path, "file.txt"), "utf8")).toBe("committed\n"); + }); + + it("refuses to write through a tracked symlink in the worktree", async () => { + const outside = join(tmp, "outside-dir"); + mkdirSync(outside); + // A branch where `cfg` is a tracked symlink out of the repository. + const side = join(tmp, "side"); + await g(["worktree", "add", "-q", "-b", "linked", side], repo); + symlinkSync(outside, join(side, "cfg")); + await g(["add", "cfg"], side); + await g(["commit", "-q", "-m", "link"], side); + await g(["worktree", "remove", "--force", side], repo); + // In the primary checkout `cfg/.env` is a real, ignored file. + mkdirSync(join(repo, "cfg")); + writeFileSync(join(repo, "cfg", ".env"), "X=1\n"); + await commitRecipe("copy: [cfg/.env]\n"); + + const wt = await newWorktree({ baseBranch: "linked" }); + expect(lstatSync(join(wt.path, "cfg")).isSymbolicLink()).toBe(true); + const r = await prepareWorktree({ worktreePath: wt.path, policy: NEVER }); + expect(r.error).toBeUndefined(); + expect(r.copied).toEqual([]); + expect(r.skipped[0]?.reason).toMatch(/isn't a plain directory/); + expect(readdirSync(outside)).toEqual([]); + }); + + it("an invalid recipe is a clear error and leaves nothing behind", async () => { + await commitRecipe("copy: [.env]\nshare: [node_modules\n"); + const wt = await newWorktree(); + const before = listing(wt.path); + const r = await prepareWorktree({ worktreePath: wt.path, policy: trusted() }); + expect(r.applied).toBe(false); + expect(r.error).toMatch(/setup\.yaml is not valid YAML/); + expect(listing(wt.path)).toEqual(before); + + await commitRecipe("copy: [.env, ../escape]\n"); + const wt2 = await newWorktree(); + const r2 = await prepareWorktree({ worktreePath: wt2.path, policy: trusted() }); + expect(r2.error).toMatch(/\.\. segment/); + expect(existsSync(join(wt2.path, ".env"))).toBe(false); + }); + + it("does not apply a recipe to the primary checkout itself", async () => { + await commitRecipe("copy: [.env]\n"); + const r = await prepareWorktree({ worktreePath: repo, policy: NEVER }); + expect(r.error).toMatch(/primary checkout itself/); + }); +}); + +describe("prepareWorktree — the setup command", () => { + it("never runs when the setting is never (the default)", async () => { + expect(loadConfig().worktree?.setup).toEqual({ runCommands: "never", trustedRepos: [] }); + await commitRecipe("command: touch ran\n"); + const wt = await newWorktree(); + const r = await prepareWorktree({ worktreePath: wt.path, policy: NEVER }); + expect(r.command).toBeUndefined(); + expect(r.commandSkipped).toMatch(/recipe commands are off/); + expect(r.error).toBeUndefined(); + expect(existsSync(join(wt.path, "ran"))).toBe(false); + }); + + it("never runs for a repository the operator didn't list", async () => { + await commitRecipe("command: touch ran\n"); + const wt = await newWorktree(); + const r = await prepareWorktree({ worktreePath: wt.path, policy: { runCommands: "trusted-repos", trustedRepos: [join(tmp, "other")] } }); + expect(r.commandSkipped).toMatch(/isn't in worktree\.setup\.trustedRepos/); + expect(existsSync(join(wt.path, "ran"))).toBe(false); + }); + + it("runs in the new worktree for a trusted repository, after the files are in place", async () => { + await commitRecipe("copy: [.env]\ncommand: 'cat .env > ran && echo \"hello from setup\" && test -z \"$CODEOID_API_KEY\"'\n"); + const wt = await newWorktree(); + const prev = process.env.CODEOID_API_KEY; + process.env.CODEOID_API_KEY = "zid_sk_must_not_leak"; + try { + const r = await prepareWorktree({ worktreePath: wt.path, policy: trusted() }); + expect(r.error).toBeUndefined(); + expect(r.command?.exitCode).toBe(0); + expect(r.command?.timedOut).toBe(false); + expect(r.command?.outputTail).toContain("hello from setup"); + expect(readFileSync(join(wt.path, "ran"), "utf8")).toBe("SECRET=1\n"); + } finally { + if (prev === undefined) delete process.env.CODEOID_API_KEY; + else process.env.CODEOID_API_KEY = prev; + } + }); + + it("a failing command is reported with its output and keeps the worktree", async () => { + await commitRecipe("copy: [.env]\ncommand: 'echo boom >&2; exit 3'\n"); + const wt = await newWorktree(); + const r = await prepareWorktree({ worktreePath: wt.path, policy: trusted() }); + expect(r.command?.exitCode).toBe(3); + expect(r.command?.outputTail).toContain("boom"); + expect(r.error).toBe("the setup command exited 3"); + expect(r.copied).toEqual([".env"]); + expect(existsSync(join(wt.path, ".env"))).toBe(true); + }); + + it("times out, killing the command's whole process group", async () => { + await commitRecipe("command: 'sleep 30 & sleep 30'\ntimeoutSec: 1\n"); + const wt = await newWorktree(); + const t0 = Date.now(); + const r = await prepareWorktree({ worktreePath: wt.path, policy: trusted() }); + expect(Date.now() - t0).toBeLessThan(10_000); + expect(r.command?.timedOut).toBe(true); + expect(r.error).toBe("the setup command timed out after 1s"); + }); + + it("an abort stops a running command", async () => { + await commitRecipe("command: 'sleep 30'\n"); + const wt = await newWorktree(); + const abort = new AbortController(); + setTimeout(() => abort.abort(), 200); + const r = await prepareWorktree({ worktreePath: wt.path, policy: trusted(), signal: abort.signal }); + expect(r.command?.exitCode).toBeNull(); + expect(r.error).toBeDefined(); + }); + + it("commandRefusal compares real paths", () => { + expect(commandRefusal(undefined, repo)).toMatch(/off/); + expect(commandRefusal({ runCommands: "trusted-repos", trustedRepos: [`${repo}/`] }, repo)).toBeNull(); + }); +}); + +describe("what a recipe added stays out of commits and checkpoints", () => { + it("removeForkWorktree takes copied files and shared links out before the WIP snapshot", async () => { + // `node_modules/` matches a directory, not the symlink: the link is visible to git. + writeFileSync(join(repo, ".gitignore"), ".env\nnode_modules/\n"); + await commitRecipe("copy: [.env]\nshare: [node_modules]\n"); + const wt = await newWorktree(); + const r = await prepareWorktree({ worktreePath: wt.path, policy: NEVER }); + expect(r.shared).toEqual(["node_modules"]); + expect(r.warnings.join()).toMatch(/isn't ignored by git/); + writeFileSync(join(wt.path, "work.txt"), "the fork's own work\n"); + + await removeForkWorktree({ workdir: repo, worktreePath: wt.path, branch: wt.branch, setup: { copied: r.copied, shared: r.shared } }); + const files = (await g(["ls-tree", "-r", "--name-only", wt.branch], repo)).split("\n"); + expect(files).toContain("work.txt"); + expect(files).not.toContain("node_modules"); + expect(files).not.toContain(".env"); + // The primary checkout's directory is untouched. + expect(existsSync(join(repo, "node_modules", "dep", "index.js"))).toBe(true); + expect(readFileSync(join(repo, ".env"), "utf8")).toBe("SECRET=1\n"); + }); + + it("checkpoints exclude the recipe's paths even when git wouldn't ignore them", async () => { + writeFileSync(join(repo, "plain-secret.txt"), "s\n"); + symlinkSync(join(repo, "node_modules"), join(repo, "linked-deps")); + const root = join(tmp, "ckpt"); + const res = await createCheckpoint({ + root, + workdir: repo, + sessionId: "s1", + turnId: "t1", + excludePaths: [join(repo, "plain-secret.txt"), join(repo, "node_modules"), join(repo, "linked-deps")], + }); + expect(res).toMatchObject({ ok: true }); + const sha = (res as { sha: string }).sha; + const files = (await shadowGitLiteral(root, "s1", ["ls-tree", "-r", "--name-only", sha])).split("\n"); + expect(files).toContain("file.txt"); + expect(files).not.toContain("plain-secret.txt"); + expect(files).not.toContain("linked-deps"); + await deleteCheckpoints(root, "s1"); + }); +}); From 1e5093a51a928971414527f5723784f3c17a2210 Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Sun, 25 Oct 2026 08:29:34 +0800 Subject: [PATCH 2/3] fix: worktree setup runs only trusted, committed content (B0d audit round 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) --- CLAUDE.md | 1 + docs/CONFIGURATION.md | 29 ++ packages/protocol/src/index.ts | 1 + packages/protocol/src/schemas.test.ts | 1 + packages/protocol/src/schemas.ts | 13 + packages/protocol/src/types.ts | 3 + packages/protocol/src/worktree-trust.ts | 45 ++++ src/cli.ts | 87 ++++++ src/config.ts | 29 +- src/daemon/checkpoints.ts | 5 +- src/daemon/git-worktree.ts | 46 +++- src/daemon/routines/service.ts | 7 + src/daemon/session-manager.ts | 262 ++++++++++++++---- src/daemon/session.ts | 48 +++- src/daemon/settings/manifest.ts | 27 +- src/daemon/settings/store.ts | 19 ++ src/daemon/workspace-setup.ts | 342 +++++++++++++++++------- src/tests/git-worktree.test.ts | 32 +++ src/tests/protocol.test.ts | 2 + src/tests/routine-manager.test.ts | 26 +- src/tests/session-fork.test.ts | 76 +++++- src/tests/settings.test.ts | 14 + src/tests/workspace-setup.test.ts | 144 ++++++++-- 23 files changed, 1070 insertions(+), 189 deletions(-) create mode 100644 packages/protocol/src/worktree-trust.ts diff --git a/CLAUDE.md b/CLAUDE.md index cc18da74..73358808 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -183,6 +183,7 @@ codeoid mcp signin [--callback-url ] # sign in to an OAuth re codeoid evidence [--full] # one recorded check: what ran, on which tree, what it found codeoid routine add --prompt … [--cron|--every|--at] [--check ] # scheduled agent runs codeoid routine ls | show | runs | run [--wait] | edit | pause | resume | rm | cancel | token | preview +codeoid worktree trust [dir] [--off] [--yes] # trust the setup command of a repo's .codeoid/setup.yaml, pinned to its blob (docs/CONFIGURATION.md) ``` `/mcp/control` (off by default, `mcpControl.*`) lets external agents drive diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index d96a36ea..b60b888a 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -193,6 +193,35 @@ TELEGRAM_ALLOWED_USER_IDS=6714605885 # ANTHROPIC_API_KEY= # only if you have neither a sign-in nor a Claude Code login ``` +## Worktree setup recipe + +A worktree codeoid creates (a fork, a compare branch, a routine's `--worktree` run) holds tracked files only. +A repository says how to make one usable in a committed `.codeoid/setup.yaml`: + +```yaml +copy: [.env, "config/*.local.json"] # ignored files, copied from your checkout +share: [node_modules] # directories, linked to your checkout +command: bun install --ignore-scripts # runs in the new worktree, only once you trust it +timeoutSec: 600 +resources: { ports: 2, env: [DATABASE_URL] } # declared only, for now +``` + +The recipe is read from your checkout's committed `HEAD`, never from a branch, the new worktree, or uncommitted edits, so a branch can't change how its own worktree is prepared. +`copy` takes ignored files only, never overwrites anything, and never follows a symlink out of the repository. +`share` is a deliberate isolation break: each entry is a writable link into your checkout, so a worker writing there changes it for you and for every other worktree. +Keep it to regenerable dependency directories; config and tooling directories (`.claude`, `.codeoid`, `.vscode`, `.idea`, `.husky`, `.git*`) are refused. + +The `command` runs only when you opt in twice. +Set `worktree.setup.runCommands` to `trusted-repos` (Settings, or `config.json`), then run `codeoid worktree trust` in the repository. +It shows the command and records the repository with the recipe's git blob id, direnv-allow style. +When the recipe changes, its command stops running and the session says so until you trust it again; `codeoid worktree trust --off` withdraws trust. + +Trust covers the repository's committed content at setup time, not just the one line you reviewed. +The command runs as you, on a clean checkout of the worktree's base commit (a fork's uncommitted changes are applied only after it), together with every script it invokes from that tree: `package.json` lifecycle scripts, a `Makefile`, a script in the repo. +Prefer a command that runs no repository scripts, such as `bun install --ignore-scripts` or `npm ci --ignore-scripts`. +A setup failure is reported as an infrastructure problem, not the agent's; a routine run fails before its agent starts. +Recipes aren't supported on Windows. + ## Signing a backend in, instead of pasting a key Settings → **Claude** → **Sign in with Claude** runs the backend's own login diff --git a/packages/protocol/src/index.ts b/packages/protocol/src/index.ts index ad75c6ac..d774bf3f 100644 --- a/packages/protocol/src/index.ts +++ b/packages/protocol/src/index.ts @@ -3,6 +3,7 @@ export * from "./scopes.js"; export * from "./settings.js"; export * from "./backend-login.js"; export * from "./skill-grant.js"; +export * from "./worktree-trust.js"; export * from "./turns.js"; export * from "./approvals.js"; export * from "./compare.js"; diff --git a/packages/protocol/src/schemas.test.ts b/packages/protocol/src/schemas.test.ts index b1932ab7..d7e3aeaa 100644 --- a/packages/protocol/src/schemas.test.ts +++ b/packages/protocol/src/schemas.test.ts @@ -159,6 +159,7 @@ const samples: { [T in ClientTypes]: Extract } = { "mcp.oauth.disconnect": { type: "mcp.oauth.disconnect", id: "r92", server: "notion" }, "mcp.adopt": { type: "mcp.adopt", id: "r93", server: "notion", sessionId: "s1" }, "skill.grant": { type: "skill.grant", id: "r93", workdir: "/tmp/w", command: "./probe.sh", allowed: true }, + "worktree.trust": { type: "worktree.trust", id: "wt1", dir: "/tmp/repo", recipeHash: "0123456789abcdef0123456789abcdef01234567" }, "session.turns": { type: "session.turns", id: "r94", sessionId: "s1" }, "session.approvals": { type: "session.approvals", id: "r94a", sessionId: "s1" }, "claude.accounts.list": { type: "claude.accounts.list", id: "ca1" }, diff --git a/packages/protocol/src/schemas.ts b/packages/protocol/src/schemas.ts index 92be6847..c4e72a4d 100644 --- a/packages/protocol/src/schemas.ts +++ b/packages/protocol/src/schemas.ts @@ -902,6 +902,18 @@ export const compareKeepSchema = z.object({ discardOthers: z.boolean().optional(), }); +export const worktreeTrustSchema = z.object({ + ...base, + type: z.literal("worktree.trust"), + dir: pathField.min(1), + dryRun: z.boolean().optional(), + off: z.boolean().optional(), + recipeHash: z + .string() + .regex(/^[0-9a-f]{40,64}$/) + .optional(), +}); + export const skillGrantSchema = z.object({ ...base, type: z.literal("skill.grant"), @@ -1131,6 +1143,7 @@ export const clientMessageSchema = z.discriminatedUnion("type", [ backendLoginCancelSchema, backendLoginWaitSchema, skillGrantSchema, + worktreeTrustSchema, sessionTurnsSchema, sessionApprovalsSchema, claudeAccountsListSchema, diff --git a/packages/protocol/src/types.ts b/packages/protocol/src/types.ts index 7cebe4ce..db8b51ad 100644 --- a/packages/protocol/src/types.ts +++ b/packages/protocol/src/types.ts @@ -43,6 +43,7 @@ import type { SessionSetAccountMsg, } from "./accounts.js"; import type { SkillGrantMsg, SkillGrantResultMsg } from "./skill-grant.js"; +import type { WorktreeTrustMsg, WorktreeTrustResultMsg } from "./worktree-trust.js"; import type { SessionRewindMsg, SessionRewindResultMsg, SessionTurnsMsg, SessionTurnsResultMsg } from "./turns.js"; import type { SessionApprovalsMsg, SessionApprovalsResultMsg } from "./approvals.js"; import type { @@ -1153,6 +1154,7 @@ export type ClientMessage = | BackendLoginCancelMsg | BackendLoginWaitMsg | SkillGrantMsg + | WorktreeTrustMsg | SessionTurnsMsg | SessionApprovalsMsg | ClaudeAccountsListMsg @@ -2977,6 +2979,7 @@ export type DaemonMessage = | BackendLoginCancelResultMsg | BackendLoginWaitResultMsg | SkillGrantResultMsg + | WorktreeTrustResultMsg | SessionTurnsResultMsg | SessionApprovalsResultMsg | ClaudeAccountsResultMsg diff --git a/packages/protocol/src/worktree-trust.ts b/packages/protocol/src/worktree-trust.ts new file mode 100644 index 00000000..af5e131c --- /dev/null +++ b/packages/protocol/src/worktree-trust.ts @@ -0,0 +1,45 @@ +/** + * Trusting a repository's worktree setup recipe (`.codeoid/setup.yaml`, + * src/daemon/workspace-setup.ts) — direnv-allow style. + * + * A recipe's `command` runs in every worktree codeoid creates for that + * repository, as the daemon's user, so it runs only while the recipe's git + * blob is the one the owner trusted. Trusting is two steps: `dryRun` shows + * the command at the primary checkout's committed HEAD and its blob id; a + * second request with that `recipeHash` records it — refused if the recipe + * changed in between, so what was reviewed is what is trusted. + * + * Needs `settings:write` from an undelegated token. + */ + +// ── Messages (client → daemon) ──────────────────────────────────────────────── + +export interface WorktreeTrustMsg { + type: "worktree.trust"; + id: string; + /** Any directory of the repository; its primary checkout is what's trusted. */ + dir: string; + /** Only report the recipe and its trust; change nothing. */ + dryRun?: boolean; + /** Withdraw trust for the repository (every recorded recipe). */ + off?: boolean; + /** The blob id `dryRun` reported — required to record trust. */ + recipeHash?: string; +} + +// ── Messages (daemon → client) ──────────────────────────────────────────────── + +export interface WorktreeTrustResultMsg { + type: "worktree.trust.result"; + requestId: string; + /** The primary checkout. */ + path: string; + /** The recipe at its committed HEAD, when there is one. */ + recipeHash?: string; + command?: string; + /** The current recipe's command may run (also needs runCommands = trusted-repos). */ + trusted: boolean; + /** This request changed the recorded trust. */ + changed: boolean; + runCommands: "never" | "trusted-repos"; +} diff --git a/src/cli.ts b/src/cli.ts index b2da46d0..1eb5a82a 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1323,6 +1323,93 @@ program client.disconnect(); }); +// ── Worktree setup recipe trust (src/daemon/workspace-setup.ts) ──────────── + +const worktreeCmd = program + .command("worktree") + .description("Worktrees codeoid creates (forks, compare, routines): trust a repository's setup recipe, .codeoid/setup.yaml"); + +/** Ask y/N on the terminal; off a terminal, only --yes says yes. EOF is "no". */ +async function confirmOnTerminal(question: string, yes: boolean | undefined): Promise { + if (yes) return true; + if (!process.stdin.isTTY) { + console.error("Not trusted: pass --yes to trust without a terminal to confirm in."); + return false; + } + const { createInterface } = await import("node:readline"); + const rl = createInterface({ input: process.stdin, output: process.stdout }); + const answer = await new Promise((done) => { + rl.once("close", () => done("")); + rl.question(question, done); + }); + rl.close(); + return /^y(es)?$/i.test(answer.trim()); +} + +worktreeCmd + .command("trust [dir]") + .description( + "Review the setup command in the repository's committed .codeoid/setup.yaml and let it run in new worktrees — pinned to that exact recipe: a changed recipe runs nothing until trusted again", + ) + .option("--off", "stop trusting this repository's setup command") + .option("--yes", "trust without asking to confirm (needed without a terminal)") + .action(async (dir: string | undefined, o: { off?: boolean; yes?: boolean }) => { + const target = resolve(dir ?? process.cwd()); + const config = loadConfig(); + const client = new TerminalClient(config); + await client.connect(); + const fail = (resp: DaemonMessage): void => { + console.error(`Error: ${resp.type === "response.error" ? resp.error : `unexpected reply ${resp.type}`}`); + process.exitCode = 1; + }; + try { + const ask = await client.request({ type: "worktree.trust", id: randomUUID(), dir: target, ...(o.off ? { off: true } : { dryRun: true }) }); + if (ask.type !== "worktree.trust.result") return fail(ask); + if (o.off) { + console.log(ask.changed ? `No longer trusting the setup command of ${ask.path}.` : `${ask.path} had no trusted setup command.`); + return; + } + if (!ask.recipeHash) { + console.error(`There is no .codeoid/setup.yaml at the committed HEAD of ${ask.path}.`); + process.exitCode = 1; + return; + } + if (!ask.command) { + console.log(`The recipe in ${ask.path} has no setup command — nothing to trust (its copy/share steps need no trust).`); + return; + } + console.log( + [ + `Repository: ${ask.path}`, + `Recipe: .codeoid/setup.yaml at HEAD (blob ${ask.recipeHash.slice(0, 12)})`, + "Command:", + ...ask.command.split("\n").map((l) => ` ${l}`), + "", + "It runs, as you, in every new worktree codeoid creates for this repository — together with", + "any script it invokes from the committed tree (package.json lifecycle scripts, a Makefile, …).", + "Prefer a command that runs no repository scripts, e.g. `bun install --ignore-scripts`.", + ].join("\n"), + ); + if (ask.trusted) { + console.log("\nAlready trusted."); + return; + } + if (!(await confirmOnTerminal("\nTrust this setup command? [y/N] ", o.yes))) { + if (process.stdin.isTTY) console.error("Not trusted."); + process.exitCode = 1; + return; + } + const res = await client.request({ type: "worktree.trust", id: randomUUID(), dir: target, recipeHash: ask.recipeHash }); + if (res.type !== "worktree.trust.result") return fail(res); + console.log(`Trusted: new worktrees of ${res.path} run this setup command while the recipe stays as it is.`); + if (res.runCommands !== "trusted-repos") { + console.log("Note: worktree.setup.runCommands is \"never\" — set it to \"trusted-repos\" (Settings, or config.json) for the command to run."); + } + } finally { + client.disconnect(); + } + }); + // ── Outward control plane (/mcp/control — docs/mcp-control.md) ───────────── const mcp = program diff --git a/src/config.ts b/src/config.ts index bb8635e8..10eb8d83 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1173,11 +1173,27 @@ const RootSchema = z.object({ setup: z .object({ /** Whether a recipe's `command` may run. `never` (default) still copies - * and shares files; `trusted-repos` runs it for the repositories in - * `trustedRepos` only. Reading a recipe never executes anything. */ + * and shares files; `trusted-repos` runs it only while the recipe is + * the one trusted in `trustedRepos`. Reading a recipe executes nothing. */ runCommands: z.enum(["never", "trusted-repos"]).default("never"), - /** Primary checkouts whose recipe commands may run (`~` allowed). */ - trustedRepos: z.array(z.string().min(1)).default([]), + /** Trusted recipes, written by `codeoid worktree trust`: the primary + * checkout and the recipe's git blob id (a changed recipe runs no + * command until trusted again). A bare path is the older shape and + * trusts no command. Paths are absolute. */ + trustedRepos: z + .array( + z.union([ + z.string().min(1).refine(isAbsolute, "must be an absolute path"), + z + .object({ + path: z.string().min(1).refine(isAbsolute, "must be an absolute path"), + recipeHash: z.string().regex(/^[0-9a-f]{40,64}$/, "must be a git blob id"), + }) + .strict(), + ]), + ) + .max(256) + .default([]), }) .default({ runCommands: "never", trustedRepos: [] }), }) @@ -1342,8 +1358,9 @@ export interface CodeoidConfig { /** * Worktrees codeoid creates. `setup` is the trust policy for the * repository's `.codeoid/setup.yaml` recipe (workspace-setup.ts): its - * files are always applied; its command runs only for `trustedRepos` under - * `runCommands: "trusted-repos"`. Optional in the type; loadConfig fills it. + * files are always applied; its command runs only under + * `runCommands: "trusted-repos"` for a recipe pinned in `trustedRepos`. + * Optional in the type; loadConfig fills it. */ worktree?: { setup: WorktreeSetupPolicy; diff --git a/src/daemon/checkpoints.ts b/src/daemon/checkpoints.ts index 33dffdc4..785e0bc6 100644 --- a/src/daemon/checkpoints.ts +++ b/src/daemon/checkpoints.ts @@ -399,7 +399,10 @@ async function stageWorkTree( let ignored = new Set(literal); if (literal.length > 0) { ignored = await run(["check-ignore", "-z", "--stdin"], { cwd: workdir, env, timeoutMs: t, input: `${literal.join("\0")}\0` }) - .catch((err: unknown) => ((err as { code?: unknown }).code === 1 ? "" : literal.join("\0"))) + // Exit 1 = none ignored. Any other failure fails CLOSED the same way: + // every path is named, so it's excluded or `git add` refuses the + // snapshot — never a snapshot that might hold a copied secret. + .catch(() => "") .then((out) => new Set(out.split("\0").filter(Boolean))); } const extra = [ diff --git a/src/daemon/git-worktree.ts b/src/daemon/git-worktree.ts index c2d5ed82..65622f4d 100644 --- a/src/daemon/git-worktree.ts +++ b/src/daemon/git-worktree.ts @@ -128,6 +128,18 @@ export interface ForkWorktree { * the parent's workdir then don't line up with the fork's. */ subdirMapped: boolean; + /** + * With `deferCarry`: the parent's uncommitted tracked state, not yet in the + * worktree (which is a clean checkout of `head`). Apply it with + * {@link applyCarriedState} once setup has run. + */ + carry?: CarriedState; +} + +/** A parent's uncommitted tracked state (`git stash create`) and the HEAD it sits on. */ +export interface CarriedState { + snapshot: string; + head: string; } /** @@ -177,6 +189,12 @@ export async function createForkWorktree(opts: { * (an untracked directory isn't carried), so the fork still opens in it. */ ensureSubdir?: boolean; + /** + * Check out the parent's HEAD clean and hand the uncommitted state back as + * `carry` instead of applying it — so a setup command runs against + * committed content only, never the parent's (agent-written) edits. + */ + deferCarry?: boolean; }): Promise { const mainRoot = await mainWorktreeRoot(opts.workdir); @@ -192,6 +210,7 @@ export async function createForkWorktree(opts: { let base: string; let parentHead: string | null = null; let carriedSnapshot = false; + let deferred: CarriedState | undefined; if (opts.baseCommit) { if (!/^[0-9a-f]{40,64}$/.test(opts.baseCommit)) throw new WorktreeError("invalid base commit"); // Local when it was recorded: a miss means it's gone (gc'd), not remote. @@ -205,8 +224,13 @@ export async function createForkWorktree(opts: { throw new WorktreeError("repository has no commits yet (unborn HEAD)"); } const snapshot = (await git(["stash", "create"], opts.workdir)).trim(); - base = snapshot || parentHead; - carriedSnapshot = snapshot.length > 0; + if (snapshot && opts.deferCarry) { + base = parentHead; + deferred = { snapshot, head: parentHead }; + } else { + base = snapshot || parentHead; + carriedSnapshot = snapshot.length > 0; + } } try { @@ -260,7 +284,23 @@ export async function createForkWorktree(opts: { subdirMapped = false; } - return { path: worktreePath, workdir, branch, subdirMapped }; + return { path: worktreePath, workdir, branch, subdirMapped, ...(deferred ? { carry: deferred } : {}) }; +} + +/** + * Apply a deferred {@link CarriedState} to a worktree: its tracked files + * become the snapshot's (edits and deletions included) as UNCOMMITTED + * changes — the index stays at HEAD, exactly as the immediate path's + * `reset --mixed` leaves it. Untracked and ignored files (what a setup recipe + * put there) are left alone. Throws {@link WorktreeError}. + */ +export async function applyCarriedState(worktreePath: string, carry: CarriedState): Promise { + if (!/^[0-9a-f]{40,64}$/.test(carry.snapshot)) throw new WorktreeError("invalid carried snapshot"); + try { + await git(["restore", `--source=${carry.snapshot}`, "--worktree", "--", "."], worktreePath); + } catch (err) { + throw new WorktreeError(`couldn't carry the uncommitted changes: ${err instanceof Error ? err.message : String(err)}`); + } } /** diff --git a/src/daemon/routines/service.ts b/src/daemon/routines/service.ts index 3d9814dc..3b7d4fd7 100644 --- a/src/daemon/routines/service.ts +++ b/src/daemon/routines/service.ts @@ -145,6 +145,8 @@ export interface CreateRunSessionOpts { maxCostUsd?: number; /** Label for the worktree branch. */ label: string; + /** Aborted when the run is cancelled or the daemon stops (stops a running setup command). */ + signal?: AbortSignal; } export interface RoutineRunEvent { @@ -794,8 +796,13 @@ export class RoutineService { // TOTAL — only a fresh session's query is exactly one run. ...(spec.session === "fresh" && spec.maxCostUsd !== undefined ? { maxCostUsd: spec.maxCostUsd } : {}), label: spec.name, + signal: live.abort.signal, }) .catch((err: unknown) => { + if (live.cancelled) { + stopped(); + return null; + } // A worktree that couldn't be prepared is an infrastructure // error: say so, so it doesn't read as the agent failing. if ((err as { infrastructure?: unknown }).infrastructure !== true) throw err; diff --git a/src/daemon/session-manager.ts b/src/daemon/session-manager.ts index 86dc605e..aaad9a72 100644 --- a/src/daemon/session-manager.ts +++ b/src/daemon/session-manager.ts @@ -26,7 +26,7 @@ import type { HookBus } from "./hooks/bus.js"; import type { Store } from "./store.js"; import { createPushTransport, PushService } from "./push/index.js"; import { hasScope, SCOPES } from "../protocol/scopes.js"; -import { applyPatches, getManifest, getSnapshot, previewPatches, readMcpServerEntry, writeMcpServerEntry } from "./settings/store.js"; +import { applyPatches, getManifest, getSnapshot, previewPatches, readMcpServerEntry, writeMcpServerEntry, writeWorktreeTrust } from "./settings/store.js"; import { adoptability, adoptableName, adoptedEntry, adoptionRefusal, claudeMcpServers, fromRepoFile, fullUrlRequired, isSameAdoption, shellArg } from "./mcp/adopt.js"; import { readWorkdirServers } from "./mcp/workdir-servers.js"; import { cleanMcpError, commandSummary, MAX_SHOWN_KEYS, quotedVisible, redactMcpUrl, serverProgram, shownLabel, visibleCompact, visibleVerbatim } from "./mcp/redact.js"; @@ -170,12 +170,28 @@ import type { RoutineRow } from "./routines/types.js"; import { toRoutineWire, toRunWire } from "./routines/wire.js"; import { routineFirePath } from "../protocol/types.js"; import { + applyCarriedState, + type CarriedState, createForkWorktree, currentBranch, isGitRepo, + mainWorktreeRoot, removeForkWorktree, } from "./git-worktree.js"; -import { prepareWorktree, WorktreeSetupFailure, type WorktreeSetupResult } from "./workspace-setup.js"; +import { + loadSetupRecipe, + prepareWorktree, + type RecipeRead, + type FoundRecipe, + readSetupRecipe, + SETUP_RECIPE_PATH, + type TrustedRecipe, + WORKTREE_SETUP_SUPPORTED, + willRunCommand, + WorktreeSetupFailure, + type WorktreeSetupPolicy, + type WorktreeSetupResult, +} from "./workspace-setup.js"; import type { DailyUsageBucket, LifetimeUsageTotals } from "./memory/store.js"; /** @@ -435,6 +451,11 @@ export class SessionManager { */ #modelLimits = new Map(); #config?: CodeoidConfig; + /** + * `config.worktree.setup`, live: `codeoid worktree trust` changes it + * without a restart (and writes it through to config.json). + */ + #worktreeSetupPolicy: WorktreeSetupPolicy = { runCommands: "never", trustedRepos: [] }; #compressionRegistry?: CompressionRegistry; #dispatcher: Dispatcher; /** @@ -561,6 +582,7 @@ export class SessionManager { this.#rateLimiter = rateLimiter ?? new RateLimiter(); this.#memory = memory; this.#config = opts?.config; + if (opts?.config?.worktree?.setup) this.#worktreeSetupPolicy = opts.config.worktree.setup; this.#compressionRegistry = opts?.compressionRegistry; this.#endpoints = opts?.endpoints ?? new EndpointStore(); this.#endpointFetch = opts?._testEndpointFetch ?? fetch; @@ -1360,6 +1382,8 @@ mcpHub: this.#mcpHub, return this.#compareKeep(msg, auth, client); case "skill.grant": return this.#skillGrant(msg, auth); + case "worktree.trust": + return this.#worktreeTrust(msg, auth); case "mcp.oauth.begin": return this.#mcpOAuthBegin(msg, auth); case "mcp.oauth.complete": @@ -2106,6 +2130,13 @@ mcpHub: this.#mcpHub, code: "forbidden", }; } + // Worktree setup decides whether a repository's command runs unasked: + // an undelegated owner only, and every change audited old → new. + const setupPatches = msg.patches.filter((p) => p.key.startsWith("worktree.setup.")); + if (setupPatches.length > 0 && auth.delegationDepth > 0) { + return { type: "response.error", requestId: msg.id, error: "A delegated token can't change worktree setup settings.", code: "forbidden" }; + } + const setupBefore = setupPatches.length > 0 ? getSnapshot().values : undefined; try { // A config the next boot rejects — one that no longer loads, or whose // default backend can't be built — takes the daemon down, and with the @@ -2137,6 +2168,14 @@ mcpHub: this.#mcpHub, "", `keys=${msg.patches.map((p) => p.key).join(",")} ok=${result.ok}`, ); + if (result.ok && setupBefore) { + for (const p of setupPatches) { + this.#store.audit(auth.sub, "settings.set.worktree_setup", "", `key=${p.key} old=${JSON.stringify(setupBefore[p.key]?.value ?? null)} new=${JSON.stringify(p.value)}`); + if (p.key === "worktree.setup.runCommands") { + this.#worktreeSetupPolicy = { ...this.#worktreeSetupPolicy, runCommands: p.value === "trusted-repos" ? "trusted-repos" : "never" }; + } + } + } return { type: "settings.set.result", requestId: msg.id, @@ -2965,6 +3004,82 @@ mcpHub: this.#mcpHub, return { type: "skill.grant.result", requestId: msg.id, workdir, command, allowed: msg.allowed }; } + /** + * Trust (or stop trusting) a repository's worktree setup recipe, pinned to + * its git blob — direnv-allow style (protocol/worktree-trust.ts). Needs + * `settings:write` from an undelegated token: it lets the recipe's command + * run, unasked, in every later worktree of that repository. + */ + async #worktreeTrust(msg: Extract, auth: AuthContext): Promise { + if (!hasScope(auth.scopes as string[], SCOPES.SETTINGS_WRITE)) return this.#loginForbidden(msg.id); + if (auth.delegationDepth > 0) { + return { type: "response.error", requestId: msg.id, error: "A delegated token can't change which setup commands run.", code: "forbidden" }; + } + const invalid = (error: string): DaemonMessage => ({ type: "response.error", requestId: msg.id, error, code: "invalid_request" }); + if (!WORKTREE_SETUP_SUPPORTED) return invalid("worktree setup recipes aren't supported on Windows"); + const dir = normalizeWorkdir(msg.dir); + if (!dir || !(await isGitRepo(dir))) return invalid(`"${msg.dir}" is not a directory in a git repository`); + let root: string; + try { + root = realpathSync.native(await mainWorktreeRoot(dir)); + } catch (err) { + return invalid(err instanceof Error ? err.message : String(err)); + } + let found: FoundRecipe | null = null; + try { + found = await readSetupRecipe(root); + } catch (err) { + // Withdrawing trust needs no readable recipe. + if (!msg.off) return invalid(err instanceof Error ? err.message : String(err)); + } + const sameRepo = (e: string | TrustedRecipe): boolean => { + try { + return realpathSync.native(typeof e === "string" ? e : e.path) === root; + } catch { + return false; + } + }; + const policy = this.#worktreeSetupPolicy; + const before = policy.trustedRepos; + let next = before; + if (msg.off) { + next = before.filter((e) => !sameRepo(e)); + } else if (!msg.dryRun) { + if (!found) return invalid(`there is no ${SETUP_RECIPE_PATH} at the committed HEAD of ${root}`); + if (!msg.recipeHash) return invalid("recipeHash is required to trust a recipe — ask with dryRun first, and review its command"); + if (msg.recipeHash !== found.recipeHash) return invalid("the recipe changed since it was shown — review it again"); + next = [...before.filter((e) => !sameRepo(e)), { path: root, recipeHash: found.recipeHash }]; + } + const changed = JSON.stringify(next) !== JSON.stringify(before); + if (changed) { + const written = writeWorktreeTrust(next); + if (!written.ok) return { type: "response.error", requestId: msg.id, error: written.error, code: "internal" }; + this.#worktreeSetupPolicy = { ...policy, trustedRepos: next }; + const show = (list: readonly (string | TrustedRecipe)[]) => + list + .filter(sameRepo) + .map((e) => (typeof e === "string" ? "path-only" : e.recipeHash.slice(0, 12))) + .join(",") || "none"; + this.#store.audit( + auth.sub, + msg.off ? "worktree.untrust" : "worktree.trust", + "", + `repo=${root} old=${show(before)} new=${show(next)}${found?.recipe.command ? ` command=${redactCommand(found.recipe.command)}` : ""}`, + ); + } + const current = found; + const trusted = !!current && next.some((e) => typeof e !== "string" && sameRepo(e) && e.recipeHash === current.recipeHash); + return { + type: "worktree.trust.result", + requestId: msg.id, + path: root, + ...(current ? { recipeHash: current.recipeHash, ...(current.recipe.command ? { command: current.recipe.command } : {}) } : {}), + trusted, + changed, + runCommands: this.#worktreeSetupPolicy.runCommands, + }; + } + #loginForbidden(requestId: string): DaemonMessage { return { type: "response.error", @@ -4706,6 +4821,15 @@ mcpHub: this.#mcpHub, let forkFromHead: string | undefined; let forkHeadGone = false; let workdirNote: string | undefined; + // The repository's setup recipe, read once (from the primary checkout's + // committed HEAD) so the plan made here is what runs. When it will run a + // command, the worktree is created CLEAN and everything uncommitted — the + // parent's carried edits, an earlier turn's files — waits until after + // setup: a trusted command must only ever see committed content. + let recipe: RecipeRead | undefined; + let deferFiles = false; + let pendingCarry: CarriedState | undefined; + let putFiles: () => Promise = async () => {}; if (msg.workdir) { // Bind mode: run in a dir/worktree the user manages; record its branch // but never create or (later) remove it. Normalize + validate exactly @@ -4732,6 +4856,8 @@ mcpHub: this.#mcpHub, // the files rolled in below; make sure the parent's subdirectory // exists so the snapshot lands where it belongs. let rollHead = point?.files && "checkpointId" in point.files ? point.files.head : undefined; + recipe = await loadSetupRecipe(parent.workdir); + deferFiles = willRunCommand(recipe, this.#worktreeSetupPolicy); const make = (baseCommit?: string) => createForkWorktree({ workdir: parent.workdir, @@ -4739,7 +4865,8 @@ mcpHub: this.#mcpHub, shortId: worktreeShortId, ...(msg.baseBranch ? { baseBranch: msg.baseBranch } : {}), ...(baseCommit ? { baseCommit } : {}), - ...(point?.files && "checkpointId" in point.files ? { ensureSubdir: true } : {}), + ...((point?.files && "checkpointId" in point.files) || deferFiles ? { ensureSubdir: true } : {}), + ...(deferFiles ? { deferCarry: true } : {}), }); let headGone = false; const wt = await make(rollHead).catch((err: unknown) => { @@ -4762,6 +4889,7 @@ mcpHub: this.#mcpHub, forkSubdirMapped = wt.subdirMapped && inside; forkFromHead = rollHead; forkHeadGone = headGone; + pendingCarry = wt.carry; // Surface that this is a fresh isolated worktree (deps aren't // present) — previously the isolated path was silent. const origin = msg.baseBranch @@ -4870,45 +4998,60 @@ mcpHub: this.#mcpHub, (point ? point.entries : parent.turnIndex).filter((e) => forkTurnIds.has(e.turnId)), filesPoint ? [filesPoint] : [], ); - // ...and the files as they were right after that turn — only in a - // worktree codeoid made for this fork whose folder lines up with the - // parent's, never in a shared or bound one. - if (point?.files) { - // What the fork's folder holds when its files aren't rolled. - const asCheckedOut = forkFromHead - ? `the files of commit \`${forkFromHead.slice(0, 10)}\` as committed (without that turn's uncommitted changes)` - : "the parent's current files"; - let note: string; - if ("unavailable" in point.files) { - filesOutcome = "unavailable"; - note = `⚠️ This fork has the parent's current files: ${point.files.unavailable}.`; - } else if (!worktree?.createdByCodeoid) { - filesOutcome = "current"; - note = `⚠️ This fork shares or binds a working directory, so its files are the current ones, not those from right after prompt ${point.atTurn}.`; - } else if (!forkSubdirMapped) { - filesOutcome = "current"; - note = `⚠️ This fork's folder doesn't line up with the parent's, so its files weren't put back to right after prompt ${point.atTurn}; it has ${asCheckedOut}.`; - } else { - note = await fork - .rollFilesTo(filesPoint!, { asEndOf: msg.afterTurnId!, within: worktree.path }) - .then(({ restored, removed }) => { - filesOutcome = `rolled(${restored}/${removed})`; - const files = point!.files as { late?: boolean }; - return `📂 Files are as they were right after prompt ${point!.atTurn} (${restored} restored, ${removed} removed)${ - forkFromHead - ? "" - : forkHeadGone - ? "; git history wasn't rolled back (the commit checked out back then no longer exists)" - : "; git history wasn't rolled back (no commit was recorded with that snapshot)" - }${files.late ? "; that snapshot finished after the next prompt's agent had started, so it may include its first edits" : ""}.`; - }) - .catch((err: unknown) => { - filesOutcome = "failed"; - return `⚠️ The files couldn't be put back to right after prompt ${point!.atTurn} (${err instanceof Error ? err.message : String(err)}); this fork has ${asCheckedOut}.`; - }); + // The parent's uncommitted state, then the files as they were right + // after that turn — only in a worktree codeoid made for this fork whose + // folder lines up with the parent's, never in a shared or bound one. + // Held back until after the setup recipe when it runs a command. + const madeWorktree = worktree?.createdByCodeoid ? worktree : undefined; + putFiles = async (): Promise => { + if (pendingCarry && madeWorktree) { + try { + await applyCarriedState(madeWorktree.path, pendingCarry); + } catch (err) { + await fork.noteForkFiles( + `⚠️ This fork's worktree didn't get the parent's uncommitted changes (${err instanceof Error ? err.message : String(err)}); it has the parent's last commit as committed.`, + ); + } } - await fork.noteForkFiles(note); - } + if (point?.files) { + // What the fork's folder holds when its files aren't rolled. + const asCheckedOut = forkFromHead + ? `the files of commit \`${forkFromHead.slice(0, 10)}\` as committed (without that turn's uncommitted changes)` + : "the parent's current files"; + let note: string; + if ("unavailable" in point.files) { + filesOutcome = "unavailable"; + note = `⚠️ This fork has the parent's current files: ${point.files.unavailable}.`; + } else if (!madeWorktree) { + filesOutcome = "current"; + note = `⚠️ This fork shares or binds a working directory, so its files are the current ones, not those from right after prompt ${point.atTurn}.`; + } else if (!forkSubdirMapped) { + filesOutcome = "current"; + note = `⚠️ This fork's folder doesn't line up with the parent's, so its files weren't put back to right after prompt ${point.atTurn}; it has ${asCheckedOut}.`; + } else { + note = await fork + .rollFilesTo(filesPoint!, { asEndOf: msg.afterTurnId!, within: madeWorktree.path }) + .then(({ restored, removed }) => { + filesOutcome = `rolled(${restored}/${removed})`; + const files = point!.files as { late?: boolean }; + return `📂 Files are as they were right after prompt ${point!.atTurn} (${restored} restored, ${removed} removed)${ + forkFromHead + ? "" + : forkHeadGone + ? "; git history wasn't rolled back (the commit checked out back then no longer exists)" + : "; git history wasn't rolled back (no commit was recorded with that snapshot)" + }${files.late ? "; that snapshot finished after the next prompt's agent had started, so it may include its first edits" : ""}.`; + }) + .catch((err: unknown) => { + filesOutcome = "failed"; + return `⚠️ The files couldn't be put back to right after prompt ${point!.atTurn} (${err instanceof Error ? err.message : String(err)}); this fork has ${asCheckedOut}.`; + }); + } + await fork.noteForkFiles(note); + } + }; + if (deferFiles) filesOutcome = "after-setup"; + else await putFiles(); } catch (err) { // Orphan cleanup: if building the fork failed after we created its // worktree, remove it so no dangling worktree + branch is left behind. @@ -4939,7 +5082,18 @@ mcpHub: this.#mcpHub, // repository's setup recipe first (its files, then its command when // trusted), then the operator's fork.setup command. if (worktree?.createdByCodeoid) { - fork.beginWorktreePrepare(this.#config?.worktree?.setup); + const after = deferFiles ? putFiles : undefined; + fork.beginWorktreePrepare(this.#worktreeSetupPolicy, { + ...(recipe !== undefined ? { recipe } : {}), + ...(after + ? { + after: () => + after().catch((err: unknown) => { + console.error(`[codeoid/fork] files after setup failed for ${fork.id.slice(0, 8)}: ${err instanceof Error ? err.message : String(err)}`); + }), + } + : {}), + }); if (this.#config?.fork?.setup) fork.beginSetup(this.#config.fork.setup); } @@ -5971,18 +6125,36 @@ mcpHub: this.#mcpHub, let setup: WorktreeSetupResult | undefined; if (o.worktree) { if (!(await isGitRepo(o.workdir))) throw new Error(`worktree isolation needs a git repository: ${o.workdir}`); - const wt = await createForkWorktree({ workdir: o.workdir, label: o.label, shortId: randomUUID().slice(0, 8) }); + // Clean checkout first: a setup command sees committed content + // only, and the checkout's uncommitted state is carried after it. + const wt = await createForkWorktree({ workdir: o.workdir, label: o.label, shortId: randomUUID().slice(0, 8), deferCarry: true }); workdir = normalizeWorkdir(wt.workdir) ?? wt.path; worktree = { path: wt.path, branch: wt.branch, createdByCodeoid: true }; + const discard = (made?: WorktreeSetupResult) => + removeForkWorktree({ + workdir: o.workdir, + worktreePath: wt.path, + branch: wt.branch, + deleteBranch: true, + ...(made ? { setup: { copied: made.copied, shared: made.shared } } : {}), + }).catch(() => {}); // A routine run is unattended: prepare before the agent starts, and // fail the run as an infrastructure error (not the agent's) when // the worktree can't be made ready. Nothing but this run's fresh // worktree is removed — the primary checkout is never touched. - setup = await prepareWorktree({ worktreePath: wt.path, policy: this.#config?.worktree?.setup }); + setup = await prepareWorktree({ worktreePath: wt.path, policy: this.#worktreeSetupPolicy, ...(o.signal ? { signal: o.signal } : {}) }); if (setup.error) { - await removeForkWorktree({ workdir: o.workdir, worktreePath: wt.path, branch: wt.branch, deleteBranch: true }).catch(() => {}); + await discard(setup); throw new WorktreeSetupFailure(setup); } + if (wt.carry) { + try { + await applyCarriedState(wt.path, wt.carry); + } catch (err) { + await discard(setup); + throw err; + } + } if (setup.copied.length > 0 || setup.shared.length > 0) { worktree = { ...worktree, setup: { copied: setup.copied, shared: setup.shared } }; } diff --git a/src/daemon/session.ts b/src/daemon/session.ts index 5f83b505..057fbf9e 100644 --- a/src/daemon/session.ts +++ b/src/daemon/session.ts @@ -71,7 +71,14 @@ import { createHash } from "node:crypto"; import { realpathSync } from "node:fs"; import { lstat, readdir } from "node:fs/promises"; import { removeForkWorktree } from "./git-worktree.js"; -import { describeSetupResult, prepareWorktree, type WorktreeSetupPolicy, type WorktreeSetupResult } from "./workspace-setup.js"; +import { + describeSetupResult, + prepareWorktree, + type RecipeRead, + setupEvent, + type WorktreeSetupPolicy, + type WorktreeSetupResult, +} from "./workspace-setup.js"; import { copyCheckpoints, createCheckpoint, @@ -4501,15 +4508,36 @@ export class Session { * failure is reported as an infrastructure notice, never thrown, and the * worktree is kept as it is. */ - beginWorktreePrepare(policy: WorktreeSetupPolicy | undefined): void { + beginWorktreePrepare( + policy: WorktreeSetupPolicy | undefined, + opts: { + /** The recipe read the caller planned with (see prepareWorktree). */ + recipe?: RecipeRead; + /** + * Work that must wait for setup — a fork's carried uncommitted state + * and file roll, held back so a setup command only ever sees committed + * content. Runs whether or not setup succeeded; never after destroy. + */ + after?: () => Promise; + } = {}, + ): void { const wt = this.#worktree; if (!wt?.createdByCodeoid) return; const abort = new AbortController(); const done = this.#chainSetup(async () => { if (abort.signal.aborted || this.#destroyed) return; - const result = await prepareWorktree({ worktreePath: wt.path, policy, signal: abort.signal }); + const result = await prepareWorktree({ + worktreePath: wt.path, + policy, + signal: abort.signal, + ...(opts.recipe !== undefined ? { recipe: opts.recipe } : {}), + }); + // Recorded even when destroyed meanwhile: destroy's cleanup reads it to + // take the copied files and shared links back out. + this.#recordWorktreeSetup(result, !this.#destroyed); if (this.#destroyed) return; - this.noteWorktreePrepared(result); + this.#announceWorktreeSetup(result); + if (opts.after) await opts.after(); }); this.#setupRecipe = { done, abort }; void done.finally(() => { @@ -4524,18 +4552,26 @@ export class Session { * recipe ran before the session existed (routines). */ noteWorktreePrepared(result: WorktreeSetupResult): void { + this.#recordWorktreeSetup(result, true); + this.#announceWorktreeSetup(result); + } + + #recordWorktreeSetup(result: WorktreeSetupResult, persist: boolean): void { const wt = this.#worktree; if (wt && (result.copied.length > 0 || result.shared.length > 0)) { this.#worktree = { ...wt, setup: { copied: [...result.copied], shared: [...result.shared] } }; - this.#saveMetaNow(); + if (persist) this.#saveMetaNow(); } + } + + #announceWorktreeSetup(result: WorktreeSetupResult): void { if (result.error) { console.error(`[codeoid/session ${this.id.slice(0, 8)}] worktree setup failed: ${result.error}`); } const text = describeSetupResult(result); if (!text) return; const msg = this.#makeMessage("info", text, SYSTEM_IDENTITY, undefined, undefined, { - event: result.error ? "worktree.setup.failed" : "worktree.setup.done", + event: setupEvent(result), infrastructure: true, copied: result.copied, shared: result.shared, diff --git a/src/daemon/settings/manifest.ts b/src/daemon/settings/manifest.ts index 429051d5..be54d4d7 100644 --- a/src/daemon/settings/manifest.ts +++ b/src/daemon/settings/manifest.ts @@ -160,19 +160,22 @@ const general: SettingsTab = { id: "worktree-setup", title: "Worktree setup recipe", description: - "A repository's committed .codeoid/setup.yaml says which ignored files to copy and which directories to share into a worktree codeoid creates. Those steps always apply; its command is a separate opt-in.", + "A repository's committed .codeoid/setup.yaml says which ignored files to copy and which directories to share (writable links into your checkout) into a worktree codeoid creates. Those steps always apply; its command is a separate opt-in, per recipe.", fields: [ - cfg("worktree.setup.runCommands", "Run recipe commands", "Whether the recipe's setup command may run in a new worktree. Reading a recipe never runs anything.", { - kind: "enum", - default: "never", - options: [ - { value: "never", label: "Never", description: "Copy and share files only." }, - { value: "trusted-repos", label: "Trusted repositories", description: "Run it for the repositories listed below." }, - ], - }), - cfg("worktree.setup.trustedRepos", "Trusted repositories", "Primary checkouts (absolute paths, ~ allowed) whose recipe command may run.", { - kind: "string[]", - }), + cfg( + "worktree.setup.runCommands", + "Run recipe commands", + "Whether a recipe's setup command may run in a new worktree. With Trusted recipes it runs only while the recipe is the one you trusted with `codeoid worktree trust` in that repository — a changed recipe runs nothing until trusted again. It runs the repository's committed scripts as you. Reading a recipe never runs anything.", + { + kind: "enum", + default: "never", + applies: "live", + options: [ + { value: "never", label: "Never", description: "Copy and share files only." }, + { value: "trusted-repos", label: "Trusted recipes", description: "Run the command of a recipe trusted with `codeoid worktree trust`." }, + ], + }, + ), ], }, ], diff --git a/src/daemon/settings/store.ts b/src/daemon/settings/store.ts index 9aebd8c8..df49f0d6 100644 --- a/src/daemon/settings/store.ts +++ b/src/daemon/settings/store.ts @@ -248,6 +248,25 @@ export function writeMcpServerEntry(name: string, entry: Record return { ok: true }; } +/** + * Write `worktree.setup.trustedRepos` (what `codeoid worktree trust` + * records): merged onto the raw file, the whole config validated, then + * written atomically — nothing is written if it doesn't validate. + */ +export function writeWorktreeTrust(trustedRepos: readonly unknown[]): { ok: true } | { ok: false; error: string } { + let raw: Record; + try { + raw = readRawConfig(); + } catch (err) { + return { ok: false, error: `config.json is not valid JSON: ${errMsg(err)}` }; + } + setByPath(raw, "worktree.setup.trustedRepos", [...trustedRepos]); + const validation = validateConfigObject(raw); + if (!validation.ok) return { ok: false, error: validation.issues.map((i) => i.message).join("; ") }; + writeConfigJson(raw); + return { ok: true }; +} + // ── config.json IO ────────────────────────────────────────────────────────── function readRawConfig(): Record { diff --git a/src/daemon/workspace-setup.ts b/src/daemon/workspace-setup.ts index ed02e2bd..252e35f0 100644 --- a/src/daemon/workspace-setup.ts +++ b/src/daemon/workspace-setup.ts @@ -8,7 +8,7 @@ * ```yaml * copy: [.env, "config/*.local.json"] # ignored files, copied from the primary checkout * share: [node_modules] # directories, symlinked to the primary checkout - * command: bun install --frozen-lockfile # run in the new worktree, only when trusted + * command: bun install --ignore-scripts # run in the new worktree, only when trusted * timeoutSec: 600 * resources: { ports: 2, env: [DATABASE_URL] } # declared only; leased in a later slice * ``` @@ -22,25 +22,53 @@ * - `copy` / `share` sources resolve inside the primary checkout (realpath * containment, never `.git`); destinations are created inside the new * worktree without following a symlink. A copied file must be ignored by - * git there, so it can never be committed; nothing existing is overwritten. + * git there, so it can never be committed; nothing existing is + * overwritten, and a file with other hard links is never copied. + * - `share` is a deliberate isolation break: a WRITABLE link into the + * primary checkout, so a worker can change what every other worktree and + * the user see there. Use it for regenerable dependency directories only; + * config and tooling directories (`.claude`, `.codeoid`, `.vscode`, + * `.idea`, `.husky`, `.git*`) are refused. * - `command` runs only under `worktree.setup.runCommands: "trusted-repos"` - * for a repository the operator listed — the pack-trust model (an - * untrusted command gate fails closed; declaring is not executing). It - * gets a built environment, never the daemon's own (GHSA-38vh). + * and only while the recipe's git blob is the one the owner trusted with + * `codeoid worktree trust` (direnv-allow style: a changed recipe runs + * nothing until it is trusted again). It runs on the CLEAN checkout of the + * worktree's base commit, before any uncommitted state a fork carries is + * applied, with a built environment, never the daemon's own (GHSA-38vh). + * Trust therefore covers the repository's committed content at setup + * time — including every script the command invokes (package.json + * lifecycle scripts, a Makefile, a script in the repo). Prefer commands + * that run no repository scripts, e.g. `bun install --ignore-scripts`. * * Everything is validated before anything is written; a failure while * writing rolls back what this run created. A setup failure is an * infrastructure error: it is reported, never thrown, and never removes the - * worktree or touches the primary checkout. + * worktree or touches the primary checkout. Not available on Windows. */ import { execFile, spawn, type ChildProcess } from "node:child_process"; -import { chmodSync, constants, copyFileSync, lstatSync, mkdirSync, realpathSync, rmdirSync, type Stats, statSync, symlinkSync, unlinkSync } from "node:fs"; -import os from "node:os"; +import { + closeSync, + constants, + fchmodSync, + fstatSync, + lstatSync, + mkdirSync, + openSync, + readSync, + realpathSync, + rmdirSync, + type Stats, + statSync, + symlinkSync, + unlinkSync, + writeSync, +} from "node:fs"; import path from "node:path"; import { promisify } from "node:util"; import { z } from "zod"; import { mainWorktreeRoot } from "./git-worktree.js"; +import { redactSecretText } from "./mcp/redact.js"; import { buildSubprocessEnv } from "./providers/env.js"; const execFileP = promisify(execFile); @@ -49,14 +77,24 @@ const execFileP = promisify(execFile); export const SETUP_RECIPE_PATH = ".codeoid/setup.yaml"; /** Default wall-clock for the setup command (dependency installs can be slow). */ export const DEFAULT_SETUP_TIMEOUT_SEC = 600; +/** Recipes are POSIX-only: symlinked shares, process groups, `/bin/sh`. */ +export const WORKTREE_SETUP_SUPPORTED = process.platform !== "win32"; const LIMITS = { RECIPE_BYTES: 64 * 1024, COPY_FILES: 500, COPY_BYTES: 256 * 1024 * 1024, OUTPUT_TAIL: 8 * 1024, + /** After the shell exits, how long its output pipes may still drain. */ + DRAIN_MS: 500, } as const; +/** + * Directories `share` refuses: a writable link to one would let a worker + * change the primary checkout's agent config, editor settings or git hooks. + */ +const UNSHAREABLE = new Set([".claude", ".codeoid", ".vscode", ".idea", ".husky"]); + // ── Recipe schema ──────────────────────────────────────────────────────────── /** `./a/b/` → `a/b`. */ @@ -69,38 +107,45 @@ function normalizeRel(p: string): string { const GLOB_CHARS = /[*?[\]{}]/; +/** `.git` in any case: case-insensitive filesystems treat `.GIT` as the same directory. */ +const isGitSegment = (s: string) => s.toLowerCase() === ".git"; + /** Why `p` can't name a path inside the repository, or null when it can. */ -function relPathProblem(p: string, glob: boolean): string | null { +function relPathProblem(p: string, kind: "copy" | "share"): string | null { if (p.includes("\0")) return "contains a NUL byte"; if (p.includes("\\")) return "uses a backslash (use / separators)"; if (path.posix.isAbsolute(p) || /^[A-Za-z]:/.test(p)) return "is absolute (paths are relative to the repository root)"; if (p.startsWith("~")) return "starts with ~ (paths are relative to the repository root)"; const segs = p.split("/"); if (segs.includes("..")) return "has a .. segment (paths must stay inside the repository)"; - if (segs.includes(".git")) return "is inside .git"; + if (segs.some(isGitSegment)) return "is inside .git"; if (segs.every((s) => s === "" || s === ".")) return "names the repository root"; - if (!glob && GLOB_CHARS.test(p)) return "is a glob (share takes plain directory paths)"; + if (kind === "share") { + if (GLOB_CHARS.test(p)) return "is a glob (share takes plain directory paths)"; + const tooling = segs.find((s) => UNSHAREABLE.has(s.toLowerCase()) || s.toLowerCase().startsWith(".git")); + if (tooling) return `is a config or tooling directory (${tooling}) — sharing it would let a worker change the primary checkout's`; + } return null; } -const relPath = (glob: boolean) => +const relPath = (kind: "copy" | "share") => z .string() .min(1) .max(1024) .transform(normalizeRel) .superRefine((p, ctx) => { - const why = relPathProblem(p, glob); + const why = relPathProblem(p, kind); if (why) ctx.addIssue({ code: "custom", message: `${JSON.stringify(p)} ${why}` }); }); export const SetupRecipeSchema = z .object({ /** Ignored files to copy from the primary checkout; globs allowed. */ - copy: z.array(relPath(true)).max(256).default([]), - /** Directories to symlink from the primary checkout (e.g. node_modules). */ - share: z.array(relPath(false)).max(64).default([]), - /** Shell command run in the new worktree's root — only for a trusted repository. */ + copy: z.array(relPath("copy")).max(256).default([]), + /** Directories to symlink, writable, from the primary checkout (e.g. node_modules). */ + share: z.array(relPath("share")).max(64).default([]), + /** Shell command run in the new worktree's root — only for a trusted recipe. */ command: z.string().trim().min(1).max(16_384).optional(), /** Wall-clock limit for `command`, in seconds. */ timeoutSec: z.number().int().min(1).max(7_200).optional(), @@ -185,6 +230,20 @@ async function ignoredIn(worktree: string, rels: readonly string[]): Promise { +export async function readSetupRecipe(repoDir: string): Promise { const root = await mainWorktreeRoot(repoDir); const head = await git(["rev-parse", "--verify", "--quiet", "HEAD^{commit}"], root, { okCodes: [1, 128] }); const rev = head.stdout.trim(); @@ -207,40 +266,76 @@ export async function readSetupRecipe(repoDir: string): Promise<{ recipe: SetupR if (Number(m[4]) > LIMITS.RECIPE_BYTES) { throw new SetupRecipeError(`${SETUP_RECIPE_PATH} is larger than ${LIMITS.RECIPE_BYTES / 1024} KiB`); } - const text = (await git(["cat-file", "blob", m[3] as string], root)).stdout; - return { recipe: parseSetupRecipe(text), rev, root }; + const blob = m[3] as string; + const text = (await git(["cat-file", "blob", blob], root)).stdout; + return { recipe: parseSetupRecipe(text), rev, root, recipeHash: blob }; +} + +/** {@link readSetupRecipe} that never throws. */ +export async function loadSetupRecipe(repoDir: string): Promise { + try { + return await readSetupRecipe(repoDir); + } catch (err) { + return { error: err instanceof Error ? err.message : String(err) }; + } } // ── Policy ─────────────────────────────────────────────────────────────────── +/** A repository whose recipe command the owner trusted, pinned to the recipe's content. */ +export interface TrustedRecipe { + /** The primary checkout (absolute). */ + path: string; + /** The recipe's git blob id when it was trusted. */ + recipeHash: string; +} + /** `config.worktree.setup`: whether a recipe's `command` may run. */ export interface WorktreeSetupPolicy { - /** `never` (default): never run a recipe command. `trusted-repos`: only for a repository in `trustedRepos`. */ + /** `never` (default): never run a recipe command. `trusted-repos`: only a recipe in `trustedRepos`. */ runCommands: "never" | "trusted-repos"; - /** Primary checkouts whose recipe commands may run (`~` allowed; compared by real path). */ - trustedRepos: readonly string[]; + /** + * Trusted recipes. A bare path (the first shape of this setting) trusts no + * command: there is no content to pin, so it waits for `codeoid worktree trust`. + */ + trustedRepos: readonly (string | TrustedRecipe)[]; +} + +export interface CommandRefusal { + /** off: commands disabled; untrusted: never trusted; changed: the recipe changed since; unsupported: platform. */ + code: "off" | "untrusted" | "changed" | "unsupported"; + reason: string; } function realOrNull(p: string): string | null { try { - return realpathSync(p); + return realpathSync.native(p); } catch { return null; } } -function expandHome(p: string): string { - return p === "~" ? os.homedir() : p.startsWith("~/") ? path.join(os.homedir(), p.slice(2)) : p; -} +const TRUST_HINT = "run `codeoid worktree trust` in the repository to review its command and trust it"; -/** Null when `root`'s recipe command may run, else why not. */ -export function commandRefusal(policy: WorktreeSetupPolicy | undefined, root: string): string | null { +/** Null when `root`'s recipe (blob `recipeHash`) may run its command, else why not. */ +export function commandRefusal(policy: WorktreeSetupPolicy | undefined, root: string, recipeHash: string): CommandRefusal | null { + if (!WORKTREE_SETUP_SUPPORTED) return { code: "unsupported", reason: "setup commands aren't supported on Windows" }; if (!policy || policy.runCommands !== "trusted-repos") { - return "recipe commands are off (set worktree.setup.runCommands to trusted-repos and list this repository in worktree.setup.trustedRepos)"; + return { code: "off", reason: "recipe commands are off (set worktree.setup.runCommands to trusted-repos, then trust the recipe with `codeoid worktree trust`)" }; } const real = realOrNull(root); - const trusted = !!real && policy.trustedRepos.some((r) => realOrNull(path.resolve(expandHome(r))) === real); - return trusted ? null : `this repository isn't in worktree.setup.trustedRepos (${root})`; + const mine = real ? policy.trustedRepos.filter((e) => path.isAbsolute(typeof e === "string" ? e : e.path) && realOrNull(typeof e === "string" ? e : e.path) === real) : []; + const pinned = mine.filter((e): e is TrustedRecipe => typeof e !== "string"); + if (pinned.some((e) => e.recipeHash === recipeHash)) return null; + if (pinned.length > 0) return { code: "changed", reason: `the setup command changed since you trusted it — ${TRUST_HINT}` }; + if (mine.length > 0) return { code: "untrusted", reason: `this repository is trusted by path only, which runs no command — ${TRUST_HINT}` }; + return { code: "untrusted", reason: `this repository's recipe isn't trusted — ${TRUST_HINT}` }; +} + +/** Whether preparing from `read` under `policy` would run a command (it then needs the clean checkout). */ +export function willRunCommand(read: RecipeRead, policy: WorktreeSetupPolicy | undefined): boolean { + if (!read || "error" in read || !read.recipe.command) return false; + return commandRefusal(policy, read.root, read.recipeHash) === null; } // ── Prepare ────────────────────────────────────────────────────────────────── @@ -250,7 +345,7 @@ export interface WorktreeSetupCommandResult { /** Exit code; null when it couldn't start or was killed. */ exitCode: number | null; timedOut: boolean; - /** The last few KiB of combined stdout + stderr. */ + /** The last few KiB of combined stdout + stderr, with token-like strings redacted. */ outputTail: string; durationMs: number; } @@ -260,6 +355,8 @@ export interface WorktreeSetupResult { applied: boolean; /** The commit the recipe was read from. */ recipeRev?: string; + /** The recipe's git blob id. */ + recipeHash?: string; /** Worktree-relative files copied in — secrets, so excluded from evidence and checkpoints. */ copied: string[]; /** Worktree-relative symlinks to the primary checkout's directories. */ @@ -272,6 +369,7 @@ export interface WorktreeSetupResult { command?: WorktreeSetupCommandResult; /** Why the recipe's command didn't run. */ commandSkipped?: string; + commandSkippedCode?: CommandRefusal["code"]; /** Set when setup failed — an infrastructure error, never the agent's. */ error?: string; } @@ -293,9 +391,12 @@ export class WorktreeSetupFailure extends Error { interface Planned { rel: string; src: string; + st?: Stats; } const isInside = (child: string, root: string) => child === root || child.startsWith(root + path.sep); +/** {@link isInside}, case-insensitively — for `.git`, which a case-insensitive filesystem also answers to as `.GIT`. */ +const isInsideCi = (child: string, root: string) => isInside(child.toLowerCase(), root.toLowerCase()); /** * Walk `rel`'s parent directories under `root`, creating missing ones @@ -354,39 +455,75 @@ function matchCopy(pattern: string, root: string): string[] { const out: string[] = []; for (const rel of new Bun.Glob(pattern).scanSync({ cwd: root, dot: true, onlyFiles: true, followSymlinks: false })) { const posix = rel.split(path.sep).join("/"); - if (!posix.split("/").includes(".git")) out.push(posix); + if (!posix.split("/").some(isGitSegment)) out.push(posix); if (out.length > LIMITS.COPY_FILES) break; } return out.sort(); } +/** + * Copy `src` to a new `dest` through file descriptors: the source opened + * without following a symlink and re-checked to be the very file planned (a + * single-link regular file), the destination created exclusively, never + * through a symlink. `dest` goes into `created` as soon as it exists, so a + * failure part-way still rolls it back. + */ +function copyNoFollow(src: string, dest: string, planned: Stats, created: string[]): void { + const from = openSync(src, constants.O_RDONLY | constants.O_NOFOLLOW); + try { + const st = fstatSync(from); + if (!st.isFile() || st.nlink !== 1 || st.ino !== planned.ino || st.dev !== planned.dev) { + throw new Error(`${src} changed while setup was copying it`); + } + const mode = st.mode & 0o777; + const to = openSync(dest, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, mode); + created.push(dest); + try { + const buf = Buffer.allocUnsafe(1 << 20); + for (let pos = 0; ; ) { + const n = readSync(from, buf, 0, buf.length, pos); + if (n === 0) break; + for (let off = 0; off < n; ) off += writeSync(to, buf, off, n - off); + pos += n; + } + fchmodSync(to, mode); + } finally { + closeSync(to); + } + } finally { + closeSync(from); + } +} + /** * Apply the repository's setup recipe to a worktree codeoid just created: * copy the ignored files it names, symlink its shared directories, and (when - * the policy trusts the repository) run its command. `worktreePath` is the - * worktree ROOT. Never throws; failures come back in `error`. + * the owner trusted this exact recipe) run its command. `worktreePath` is the + * worktree ROOT, still a clean checkout of its base commit when a command + * will run (see git-worktree.ts `deferCarry`). `recipe`, when given, is the + * read a caller already decided on — so what it planned for is what runs. + * Never throws; failures come back in `error`. */ export async function prepareWorktree(opts: { worktreePath: string; policy?: WorktreeSetupPolicy; - /** Abort a running command (session destroyed, daemon stopping). */ + /** Abort a running command (session destroyed, routine cancelled, daemon stopping). */ signal?: AbortSignal; + recipe?: RecipeRead; }): Promise { const result: WorktreeSetupResult = { applied: false, copied: [], shared: [], skipped: [], warnings: [] }; const fail = (err: unknown): WorktreeSetupResult => { result.error = err instanceof Error ? err.message : String(err); return result; }; + if (!WORKTREE_SETUP_SUPPORTED) return result; - let found: Awaited>; - try { - found = await readSetupRecipe(opts.worktreePath); - } catch (err) { - return fail(err); - } + const found = opts.recipe !== undefined ? opts.recipe : await loadSetupRecipe(opts.worktreePath); if (!found) return result; - const { recipe, rev, root } = found; + if ("error" in found) return fail(found.error); + const { recipe, rev, root, recipeHash } = found; result.recipeRev = rev; + result.recipeHash = recipeHash; if (recipe.resources) result.resources = recipe.resources; const realRoot = realOrNull(root); @@ -401,12 +538,13 @@ export async function prepareWorktree(opts: { const source = (rel: string): string | null => { const real = realOrNull(path.join(root, rel)); if (!real) return null; - return isInside(real, realRoot) && real !== realRoot && !isInside(real, gitDir) ? real : null; + return isInside(real, realRoot) && real !== realRoot && !isInsideCi(real, gitDir) ? real : null; }; const copies: Planned[] = []; - let copyBytes = 0; + const shares: Planned[] = []; try { + let copyBytes = 0; const seen = new Set(); for (const pattern of recipe.copy) { const matched = matchCopy(pattern, root); @@ -419,11 +557,15 @@ export async function prepareWorktree(opts: { skip(rel, "resolves outside the primary checkout (a symlink escape) or is missing"); continue; } - const st = statSync(src); + const st = lstatSync(src); if (!st.isFile()) { skip(rel, "is not a file (use share for directories)"); continue; } + if (st.nlink > 1) { + skip(rel, "has other hard links (the same file may live outside the primary checkout)"); + continue; + } const blocked = parentProblem(worktree, rel); if (blocked) { skip(rel, blocked); @@ -434,7 +576,7 @@ export async function prepareWorktree(opts: { continue; } copyBytes += st.size; - copies.push({ rel, src }); + copies.push({ rel, src, st }); } } if (copies.length > LIMITS.COPY_FILES) throw new SetupRecipeError(`copy matches more than ${LIMITS.COPY_FILES} files`); @@ -446,35 +588,34 @@ export async function prepareWorktree(opts: { if (ignored.has(c.rel)) copies.push(c); else skip(c.rel, "is not ignored by git in the new worktree (copying it would let it be committed)"); } - } catch (err) { - return fail(err); - } - const shares: Planned[] = []; - for (const rel of recipe.share) { - if (!exists(path.join(root, rel))) { - skip(rel, "doesn't exist in the primary checkout"); - continue; - } - const src = source(rel); - if (!src) { - skip(rel, "resolves outside the primary checkout (a symlink escape)"); - continue; - } - if (!statSync(src).isDirectory()) { - skip(rel, "is not a directory (use copy for files)"); - continue; - } - const blocked = parentProblem(worktree, rel); - if (blocked) { - skip(rel, blocked); - continue; - } - if (exists(path.join(worktree, rel))) { - skip(rel, "already exists in the new worktree (not replaced)"); - continue; + for (const rel of recipe.share) { + if (!exists(path.join(root, rel))) { + skip(rel, "doesn't exist in the primary checkout"); + continue; + } + const src = source(rel); + if (!src) { + skip(rel, "resolves outside the primary checkout (a symlink escape)"); + continue; + } + if (!statSync(src).isDirectory()) { + skip(rel, "is not a directory (use copy for files)"); + continue; + } + const blocked = parentProblem(worktree, rel); + if (blocked) { + skip(rel, blocked); + continue; + } + if (exists(path.join(worktree, rel))) { + skip(rel, "already exists in the new worktree (not replaced)"); + continue; + } + shares.push({ rel, src }); } - shares.push({ rel, src }); + } catch (err) { + return fail(err); } // ── Apply, rolling back on any failure. ── @@ -483,11 +624,7 @@ export async function prepareWorktree(opts: { try { for (const c of copies) { ensureParents(worktree, c.rel, createdDirs); - const dest = path.join(worktree, c.rel); - // EXCL: never overwrite, and never write through a symlink planted at dest. - copyFileSync(c.src, dest, constants.COPYFILE_EXCL); - createdFiles.push(dest); - chmodSync(dest, statSync(c.src).mode & 0o777); + copyNoFollow(c.src, path.join(worktree, c.rel), c.st as Stats, createdFiles); result.copied.push(c.rel); } for (const s of shares) { @@ -530,9 +667,10 @@ export async function prepareWorktree(opts: { } if (recipe.command) { - const refusal = commandRefusal(opts.policy, root); + const refusal = commandRefusal(opts.policy, root, recipeHash); if (refusal) { - result.commandSkipped = refusal; + result.commandSkipped = refusal.reason; + result.commandSkippedCode = refusal.code; } else { const timeoutSec = recipe.timeoutSec ?? DEFAULT_SETUP_TIMEOUT_SEC; result.command = await runSetupCommand(recipe.command, { @@ -542,6 +680,7 @@ export async function prepareWorktree(opts: { ...(opts.signal ? { signal: opts.signal } : {}), }); if (result.command.timedOut) result.error = `the setup command timed out after ${timeoutSec}s`; + else if (opts.signal?.aborted) result.error = "the setup command was stopped"; else if (result.command.exitCode !== 0) { result.error = result.command.exitCode === null ? "the setup command didn't run to completion" : `the setup command exited ${result.command.exitCode}`; } @@ -551,10 +690,12 @@ export async function prepareWorktree(opts: { } /** - * Run the setup command in its own process group (killed whole at the - * timeout or on abort — a package manager's children otherwise hold the - * pipes open), with a built environment: the shared safe basics, never the - * daemon's secrets. `CODEOID_AGENT_ENV_ALLOW` passes more, as everywhere. + * Run the setup command in its own process group, with a built environment: + * the shared safe basics, never the daemon's secrets + * (`CODEOID_AGENT_ENV_ALLOW` passes more, as everywhere). The WHOLE group is + * killed when the shell exits as well as at the timeout or an abort, so a + * daemonized descendant can't outlive setup and keep running in the + * worktree. Settles on the shell's exit, then lets the pipes drain briefly. */ async function runSetupCommand( command: string, @@ -579,12 +720,16 @@ async function runSetupCommand( resolve({ command, exitCode: null, timedOut: false, outputTail: err instanceof Error ? err.message : String(err), durationMs: 0 }); return; } + // Listeners go on before anything can be emitted. child.stdout?.on("data", push); child.stderr?.on("data", push); let timedOut = false; let settled = false; + let exited: number | null | undefined; + let drain: ReturnType | undefined; const killGroup = () => { try { + // Negative pid: the process group the detached shell leads. if (child.pid) process.kill(-child.pid, "SIGKILL"); } catch {} }; @@ -592,8 +737,9 @@ async function runSetupCommand( if (settled) return; settled = true; clearTimeout(timer); + if (drain) clearTimeout(drain); opts.signal?.removeEventListener("abort", onAbort); - const text = tail.toString("utf8"); + const text = redactSecretText(tail.toString("utf8")); resolve({ command, exitCode, @@ -618,10 +764,22 @@ async function runSetupCommand( push(Buffer.from(e.message)); done(null); }); - child.on("close", (code) => done(code)); + child.on("exit", (code) => { + exited = code; + killGroup(); + drain = setTimeout(() => done(exited ?? null), LIMITS.DRAIN_MS); + }); + child.on("close", (code) => done(exited !== undefined ? exited : code)); }); } +/** The session event a result is surfaced as. */ +export function setupEvent(r: WorktreeSetupResult): "worktree.setup.failed" | "worktree.setup.untrusted" | "worktree.setup.done" { + if (r.error) return "worktree.setup.failed"; + if (r.commandSkippedCode === "changed" || r.commandSkippedCode === "untrusted") return "worktree.setup.untrusted"; + return "worktree.setup.done"; +} + /** * One line (plus the output tail on a command failure) for the session's * notice, or null when there was no recipe and nothing to say. @@ -630,10 +788,11 @@ export function describeSetupResult(r: WorktreeSetupResult): string | null { if (!r.applied && !r.error) return null; const parts: string[] = []; if (r.copied.length) parts.push(`copied ${r.copied.join(", ")}`); - if (r.shared.length) parts.push(`shared ${r.shared.join(", ")}`); + if (r.shared.length) parts.push(`shared ${r.shared.join(", ")} (writable links into the primary checkout)`); if (r.command && !r.error) parts.push(`ran \`${r.command.command}\` in ${(r.command.durationMs / 1000).toFixed(1)}s`); const skipped = r.skipped.map((s) => `${s.path} ${s.reason}`); - const notes = [...skipped, ...r.warnings, ...(r.commandSkipped ? [`the setup command didn't run: ${r.commandSkipped}`] : [])]; + const untrusted = setupEvent(r) === "worktree.setup.untrusted"; + const notes = [...skipped, ...r.warnings, ...(r.commandSkipped && !untrusted ? [`the setup command didn't run: ${r.commandSkipped}`] : [])]; const rev = r.recipeRev ? ` (recipe from ${r.recipeRev.slice(0, 10)})` : ""; if (r.error) { const tail = r.command?.outputTail.trim() ? `\n${r.command.outputTail.trim().slice(-600)}` : ""; @@ -641,5 +800,6 @@ export function describeSetupResult(r: WorktreeSetupResult): string | null { parts.length ? ` Done before it failed: ${parts.join("; ")}.` : "" }${notes.length ? ` Notes: ${notes.join("; ")}.` : ""}${tail}`; } - return `✓ Worktree prepared${rev}${parts.length ? `: ${parts.join("; ")}` : ""}.${notes.length ? ` Notes: ${notes.join("; ")}.` : ""}`; + const head = untrusted ? `⚠️ Worktree prepared without its setup command${rev}: ${r.commandSkipped}.` : `✓ Worktree prepared${rev}.`; + return `${head}${parts.length ? ` ${parts.join("; ")}.` : ""}${notes.length ? ` Notes: ${notes.join("; ")}.` : ""}`; } diff --git a/src/tests/git-worktree.test.ts b/src/tests/git-worktree.test.ts index 0defa38e..769946c5 100644 --- a/src/tests/git-worktree.test.ts +++ b/src/tests/git-worktree.test.ts @@ -12,6 +12,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { promisify } from "node:util"; import { + applyCarriedState, createForkWorktree, currentBranch, isGitRepo, @@ -58,6 +59,37 @@ describe("validateBranchName", () => { }); describe("createForkWorktree", () => { + it("deferCarry: a clean checkout first, then applyCarriedState brings edits, deletions and staged files — untracked ones untouched", async () => { + writeFileSync(join(repo, "gone.txt"), "x\n"); + await g(["add", "gone.txt"], repo); + await g(["commit", "-m", "gone"], repo); + writeFileSync(join(repo, "file.txt"), "committed\nWIP edit\n"); + rmSync(join(repo, "gone.txt")); + writeFileSync(join(repo, "staged.txt"), "new\n"); + await g(["add", "staged.txt"], repo); + const parentStatus = await g(["status", "--porcelain"], repo); + + const wt = await createForkWorktree({ workdir: repo, label: "defer", shortId: "d1", deferCarry: true }); + expect(wt.carry).toBeDefined(); + expect(readFileSync(join(wt.path, "file.txt"), "utf8")).toBe("committed\n"); + expect(existsSync(join(wt.path, "gone.txt"))).toBe(true); + writeFileSync(join(wt.path, "setup-made.txt"), "untracked\n"); + + await applyCarriedState(wt.path, wt.carry!); + expect(readFileSync(join(wt.path, "file.txt"), "utf8")).toBe("committed\nWIP edit\n"); + expect(existsSync(join(wt.path, "gone.txt"))).toBe(false); + expect(readFileSync(join(wt.path, "staged.txt"), "utf8")).toBe("new\n"); + expect(readFileSync(join(wt.path, "setup-made.txt"), "utf8")).toBe("untracked\n"); + expect(await g(["rev-parse", "HEAD"], wt.path)).toBe(await g(["rev-parse", "HEAD"], repo)); + // The parent is untouched. + expect(await g(["status", "--porcelain"], repo)).toBe(parentStatus); + }); + + it("deferCarry on a clean parent carries nothing", async () => { + const wt = await createForkWorktree({ workdir: repo, label: "defer", shortId: "d2", deferCarry: true }); + expect(wt.carry).toBeUndefined(); + }); + it("carries the parent's UNCOMMITTED tracked edit into an isolated worktree, parent untouched", async () => { // Parent has a dirty tracked edit. writeFileSync(join(repo, "file.txt"), "committed\nWIP edit\n"); diff --git a/src/tests/protocol.test.ts b/src/tests/protocol.test.ts index 0271bac8..196ea627 100644 --- a/src/tests/protocol.test.ts +++ b/src/tests/protocol.test.ts @@ -460,6 +460,8 @@ describe("DaemonMessage routing", () => { return `login.wait:${msg.ok}`; case "skill.grant.result": return `skill.grant:${msg.allowed}`; + case "worktree.trust.result": + return `worktree.trust:${msg.trusted}`; case "session.turns.result": return `turns:${msg.turns.length}`; case "session.approvals.result": diff --git a/src/tests/routine-manager.test.ts b/src/tests/routine-manager.test.ts index fb2f4523..2c670ca7 100644 --- a/src/tests/routine-manager.test.ts +++ b/src/tests/routine-manager.test.ts @@ -17,6 +17,7 @@ import { MockSessionProvider } from "../daemon/providers/mock/session-provider.j import { ProviderRegistry } from "../daemon/providers/registry.js"; import { handleRoutineFire } from "../daemon/routines/http.js"; import { SessionManager } from "../daemon/session-manager.js"; +import { readSetupRecipe } from "../daemon/workspace-setup.js"; import { Store } from "../daemon/store.js"; import { TranscriptStore } from "../daemon/transcript.js"; import { ALL_SCOPES, OPERATOR_SCOPES, SCOPES, WATCHER_SCOPES } from "../protocol/scopes.js"; @@ -79,6 +80,7 @@ let manager: SessionManager; let providers: MockSessionProvider[]; let nextScript: ProviderEvent[][]; let hookFile: string; +let registry: ProviderRegistry; const CLIENT = (auth: AuthContext) => ({ id: `c-${auth.sub}`, auth, send: () => {} }); const call = (msg: Record, auth: AuthContext = OWNER): Promise => @@ -109,7 +111,7 @@ beforeEach(() => { providers = []; nextScript = [reply("Found 2 stale PRs: #12, #15.")]; hookFile = join(tmp, "hook.json"); - const registry = new ProviderRegistry("claude"); + registry = new ProviderRegistry("claude"); registry.register({ id: "claude", displayName: "claude", @@ -436,6 +438,28 @@ describe("budgets and isolation", () => { expect(readFileSync(join(s!.worktree!.path, ".env"), "utf8")).toBe("K=v\n"); }); + it("a trusted setup command runs on the committed tree; the checkout's uncommitted edit is carried after it", async () => { + commitRecipe("command: cat README.md > .seen\n"); + writeFileSync(join(repo, ".gitignore"), ".env\n.seen\n"); + execFileSync("git", ["commit", "-qam", "ignore"], { cwd: repo }); + const recipeHash = (await readSetupRecipe(repo))!.recipeHash; + writeFileSync(join(repo, "README.md"), "hi\nuncommitted\n"); + await manager.drain(2_000).catch(() => {}); + manager = new SessionManager(store, transcript, undefined, undefined, undefined, { + config: { ...mkConfig(tmp), worktree: { setup: { runCommands: "trusted-repos", trustedRepos: [{ path: repo, recipeHash }] } } } as CodeoidConfig, + providers: registry, + }); + await create({ worktree: true }); + await call({ type: "routine.run", routine: "triage" }); + await settled(); + const run = ((await call({ type: "routine.get", routine: "triage" })) as RoutineSnapshotMsg).runs![0]!; + expect(run.status).toBe("succeeded"); + const list = await call({ type: "session.list" }); + const s = list.type === "session.list.result" ? list.sessions.find((x) => x.id === run.sessionId)! : undefined; + expect(readFileSync(join(s!.worktree!.path, ".seen"), "utf8")).toBe("hi\n"); + expect(readFileSync(join(s!.worktree!.path, "README.md"), "utf8")).toBe("hi\nuncommitted\n"); + }); + it("a setup recipe that fails is the run's infrastructure error — the agent never starts, the checkout is untouched", async () => { commitRecipe("copy: [../outside]\n"); await create({ worktree: true }); diff --git a/src/tests/session-fork.test.ts b/src/tests/session-fork.test.ts index 5ac38a37..5cf21e33 100644 --- a/src/tests/session-fork.test.ts +++ b/src/tests/session-fork.test.ts @@ -13,7 +13,7 @@ */ import { describe, it, expect, beforeEach, afterEach } from "bun:test"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { randomUUID } from "node:crypto"; @@ -30,6 +30,7 @@ import type { CanonicalTurn } from "../daemon/providers/canonical.js"; import type { AuthContext, DaemonMessage } from "../protocol/types.js"; import { ALL_SCOPES } from "../protocol/scopes.js"; import { loadConfig } from "../config.js"; +import { readSetupRecipe } from "../daemon/workspace-setup.js"; const AUTH: AuthContext = { sub: "user:fork", @@ -659,13 +660,14 @@ describe("SessionManager session.fork — git worktree isolation", () => { it("W8: the repository's setup recipe prepares the fork's worktree before its first turn, and destroy takes it back out", async () => { const repo = await makeGitRepoWithDirtyEdit(); mkdirSync(join(repo, ".codeoid")); - writeFileSync(join(repo, ".codeoid", "setup.yaml"), "copy: [.env]\ncommand: cat .env > .recipe-ran\n"); + writeFileSync(join(repo, ".codeoid", "setup.yaml"), "copy: [.env]\ncommand: cat .env file.txt > .recipe-ran\n"); writeFileSync(join(repo, ".gitignore"), ".env\n.recipe-ran\n"); await gitIn(["add", ".codeoid", ".gitignore"], repo); await gitIn(["commit", "-m", "recipe"], repo); writeFileSync(join(repo, ".env"), "TOKEN=1\n"); const { registry } = makeRegistry(); - const config = { ...loadConfig(), worktree: { setup: { runCommands: "trusted-repos" as const, trustedRepos: [repo] } } }; + const recipeHash = (await readSetupRecipe(repo))!.recipeHash; + const config = { ...loadConfig(), worktree: { setup: { runCommands: "trusted-repos" as const, trustedRepos: [{ path: repo, recipeHash }] } } }; const manager = new SessionManager(store, transcript, undefined, undefined, undefined, { providers: registry, config, @@ -677,7 +679,10 @@ describe("SessionManager session.fork — git worktree isolation", () => { const resp = await manager.handle({ type: "session.fork", id: "w8", sessionId: parentId }, AUTH, c); const fork = (resp as { data: { id: string; workdir: string; worktree: { path: string; branch: string } } }).data; await sendAndSettle(manager, c, fork.id, "go"); - expect(readFileSync(join(fork.workdir, ".recipe-ran"), "utf8")).toBe("TOKEN=1\n"); + // The command ran on the committed tree — before the parent's + // uncommitted edit was carried in, which the fork has once it's ready. + expect(readFileSync(join(fork.workdir, ".recipe-ran"), "utf8")).toBe("TOKEN=1\ncommitted\n"); + expect(readFileSync(join(fork.workdir, "file.txt"), "utf8")).toBe("committed\nWIP\n"); // Recorded on the session (checkpoint exclusions, cleanup) and surfaced. const info = (await manager.handle({ type: "session.list", id: "l8" }, AUTH, c)) as { @@ -699,4 +704,67 @@ describe("SessionManager session.fork — git worktree isolation", () => { expect(kept).not.toContain(".env"); expect(readFileSync(join(repo, ".env"), "utf8")).toBe("TOKEN=1\n"); }); + + it("W9: `worktree.trust` pins the recipe — owner only; a changed recipe runs nothing until trusted again", async () => { + const prevXdg = process.env.XDG_CONFIG_HOME; + process.env.XDG_CONFIG_HOME = join(tmp, "xdg"); // config.json writes land here + try { + const repo = await makeGitRepoWithDirtyEdit(); + mkdirSync(join(repo, ".codeoid")); + writeFileSync(join(repo, ".codeoid", "setup.yaml"), "command: touch .ran-v1\n"); + writeFileSync(join(repo, ".gitignore"), ".ran-*\n"); + await gitIn(["add", ".codeoid", ".gitignore"], repo); + await gitIn(["commit", "-m", "recipe"], repo); + const { registry } = makeRegistry(); + const config = { ...loadConfig(), worktree: { setup: { runCommands: "trusted-repos" as const, trustedRepos: [] } } }; + const manager = new SessionManager(store, transcript, undefined, undefined, undefined, { providers: registry, config } as never); + const c = client(AUTH); + const trust = (extra: Record, auth: AuthContext = AUTH) => + manager.handle({ type: "worktree.trust", id: randomUUID(), dir: repo, ...extra } as never, auth, c); + + const shown = (await trust({ dryRun: true })) as { type: string; command?: string; recipeHash?: string; trusted: boolean }; + expect(shown).toMatchObject({ type: "worktree.trust.result", command: "touch .ran-v1", trusted: false, changed: false }); + expect(await trust({ recipeHash: shown.recipeHash }, { ...AUTH, delegationDepth: 1 })).toMatchObject({ type: "response.error", code: "forbidden" }); + expect(await trust({ recipeHash: shown.recipeHash }, { ...AUTH, scopes: ["session:create"] as AuthContext["scopes"] })).toMatchObject({ code: "forbidden" }); + expect(await trust({ recipeHash: "f".repeat(40) })).toMatchObject({ type: "response.error", code: "invalid_request" }); + expect(await trust({ recipeHash: shown.recipeHash })).toMatchObject({ trusted: true, changed: true }); + const written = JSON.parse(readFileSync(join(tmp, "xdg", "codeoid", "config.json"), "utf8")); + expect(written.worktree.setup.trustedRepos).toEqual([{ path: realpathSync(repo), recipeHash: shown.recipeHash }]); + + const parentId = await createIn(manager, c, repo); + await sendAndSettle(manager, c, parentId, "hello"); + const forkOnce = async (id: string) => { + const resp = await manager.handle({ type: "session.fork", id, sessionId: parentId }, AUTH, c); + const fork = (resp as { data: { id: string; workdir: string } }).data; + await sendAndSettle(manager, c, fork.id, "go"); + await transcript.flush(); + const events = (await transcript.loadTranscript(fork.id, {})) + .map((r) => r.message) + .filter((m) => m.type === "session.message") + .map((m) => (m as { metadata?: { event?: string } }).metadata?.event); + return { fork, events }; + }; + const first = await forkOnce("w9a"); + expect(existsSync(join(first.fork.workdir, ".ran-v1"))).toBe(true); + + // The recipe changes: the next fork runs nothing and says why. + writeFileSync(join(repo, ".codeoid", "setup.yaml"), "command: touch .ran-v2\n"); + await gitIn(["commit", "-qam", "recipe v2"], repo); + const second = await forkOnce("w9b"); + expect(existsSync(join(second.fork.workdir, ".ran-v2"))).toBe(false); + expect(second.events).toContain("worktree.setup.untrusted"); + + // Trusted again, it runs. + const again = (await trust({ dryRun: true })) as { recipeHash?: string }; + await trust({ recipeHash: again.recipeHash }); + const third = await forkOnce("w9c"); + expect(existsSync(join(third.fork.workdir, ".ran-v2"))).toBe(true); + + expect(await trust({ off: true })).toMatchObject({ trusted: false, changed: true }); + expect(JSON.parse(readFileSync(join(tmp, "xdg", "codeoid", "config.json"), "utf8")).worktree.setup.trustedRepos).toEqual([]); + } finally { + if (prevXdg === undefined) delete process.env.XDG_CONFIG_HOME; + else process.env.XDG_CONFIG_HOME = prevXdg; + } + }); }); diff --git a/src/tests/settings.test.ts b/src/tests/settings.test.ts index 4d2fd582..89223a1c 100644 --- a/src/tests/settings.test.ts +++ b/src/tests/settings.test.ts @@ -5,6 +5,7 @@ */ import { describe, it, expect, beforeEach, afterEach } from "bun:test"; +import { Database } from "bun:sqlite"; import { mkdtempSync, rmSync, mkdirSync, existsSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -271,6 +272,19 @@ describe("settings RPC handlers", () => { expect(ok.manifest.tabs.length).toBeGreaterThan(0); }); + it("worktree setup settings: a delegated token is refused, and a change is audited old → new", async () => { + const owner = auth([SCOPES.SETTINGS_WRITE, SCOPES.SETTINGS_READ]); + const patch = { type: "settings.set" as const, id: "w1", patches: [{ key: "worktree.setup.runCommands", value: "trusted-repos" }] }; + const denied = await mgr().handle(patch, { ...owner, delegationDepth: 1 }, client); + expect(denied).toMatchObject({ type: "response.error", code: "forbidden" }); + const ok = await mgr().handle(patch, owner, client); + expect(ok).toMatchObject({ type: "settings.set.result", ok: true }); + const db = new Database(join(tmp, "codeoid.db"), { readonly: true }); + const row = db.query("SELECT detail FROM audit_log WHERE action = 'settings.set.worktree_setup'").get() as { detail: string } | null; + db.close(); + expect(row?.detail).toBe('key=worktree.setup.runCommands old="never" new="trusted-repos"'); + }); + it("settings.get requires settings:read and returns a snapshot", async () => { const denied = await mgr().handle({ type: "settings.get", id: "1" }, auth([]), client); expect(denied).toMatchObject({ type: "response.error", code: "forbidden" }); diff --git a/src/tests/workspace-setup.test.ts b/src/tests/workspace-setup.test.ts index 1766617f..691cadb4 100644 --- a/src/tests/workspace-setup.test.ts +++ b/src/tests/workspace-setup.test.ts @@ -9,21 +9,23 @@ import { afterEach, beforeEach, describe, expect, it } from "bun:test"; import { execFile } from "node:child_process"; -import { existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, readlinkSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; +import { existsSync, linkSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, readlinkSync, realpathSync, rmSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { promisify } from "node:util"; import { createCheckpoint, deleteCheckpoints, shadowGitLiteral } from "../daemon/checkpoints.js"; -import { createForkWorktree, removeForkWorktree } from "../daemon/git-worktree.js"; +import { applyCarriedState, createForkWorktree, removeForkWorktree } from "../daemon/git-worktree.js"; import { commandRefusal, + describeSetupResult, parseSetupRecipe, + setupEvent, prepareWorktree, readSetupRecipe, SetupRecipeError, type WorktreeSetupPolicy, } from "../daemon/workspace-setup.js"; -import { loadConfig } from "../config.js"; +import { loadConfig, validateConfigObject } from "../config.js"; const execFileP = promisify(execFile); const g = (args: string[], cwd: string) => execFileP("git", args, { cwd }).then((r) => r.stdout.trim()); @@ -33,7 +35,11 @@ let repo: string; let n = 0; const NEVER: WorktreeSetupPolicy = { runCommands: "never", trustedRepos: [] }; -const trusted = (): WorktreeSetupPolicy => ({ runCommands: "trusted-repos", trustedRepos: [repo] }); +/** Trust the recipe now committed at the primary checkout's HEAD, as `codeoid worktree trust` records it. */ +const trusted = async (): Promise => { + const found = await readSetupRecipe(repo).catch(() => null); + return { runCommands: "trusted-repos", trustedRepos: found ? [{ path: repo, recipeHash: found.recipeHash }] : [] }; +}; async function commitRecipe(yaml: string, extra: Record = {}): Promise { mkdirSync(join(repo, ".codeoid"), { recursive: true }); @@ -103,6 +109,14 @@ describe("parseSetupRecipe", () => { "share: ['node_*']", "share: ['.']", "share: ['../deps']", + "share: ['.claude']", + "share: ['.codeoid']", + "share: ['tools/.VSCode']", + "share: ['.idea']", + "share: ['.husky']", + "share: ['.github']", + "share: ['.git-hooks']", + "copy: ['.GIT/config']", "commands: echo", "resources: { ports: 1, cpus: 2 }", "resources: { env: [lower_case] }", @@ -147,7 +161,7 @@ describe("prepareWorktree", () => { it("without a recipe, a worktree is exactly what createForkWorktree made", async () => { const wt = await newWorktree(); const before = listing(wt.path); - const r = await prepareWorktree({ worktreePath: wt.path, policy: trusted() }); + const r = await prepareWorktree({ worktreePath: wt.path, policy: await trusted() }); expect(r).toEqual({ applied: false, copied: [], shared: [], skipped: [], warnings: [] }); expect(listing(wt.path)).toEqual(before); }); @@ -181,7 +195,7 @@ describe("prepareWorktree", () => { // A clean worktree of the branch … const wt = await newWorktree({ baseBranch: "feature" }); expect(readFileSync(join(wt.path, ".codeoid", "setup.yaml"), "utf8")).toContain("pwned"); - const r = await prepareWorktree({ worktreePath: wt.path, policy: trusted() }); + const r = await prepareWorktree({ worktreePath: wt.path, policy: await trusted() }); expect(r.copied).toEqual([".env"]); expect(r.shared).toEqual([]); expect(r.command).toBeUndefined(); @@ -191,7 +205,7 @@ describe("prepareWorktree", () => { const parent = join(tmp, "feature-wt"); await g(["worktree", "add", parent, "feature"], repo); const child = await newWorktree({ workdir: parent }); - const r2 = await prepareWorktree({ worktreePath: child.path, policy: trusted() }); + const r2 = await prepareWorktree({ worktreePath: child.path, policy: await trusted() }); expect(r2.recipeRev).toBe(await g(["rev-parse", "main"], repo)); expect(r2.command).toBeUndefined(); expect(existsSync(join(child.path, "pwned"))).toBe(false); @@ -216,6 +230,37 @@ describe("prepareWorktree", () => { expect(existsSync(join(wt.path, "deps"))).toBe(false); }); + it("never copies a file with other hard links", async () => { + const outside = join(tmp, "outside-secret"); + writeFileSync(outside, "elsewhere\n"); + linkSync(outside, join(repo, ".env.linked")); + await commitRecipe("copy: [.env.linked, .env]\n"); + const wt = await newWorktree(); + const r = await prepareWorktree({ worktreePath: wt.path, policy: NEVER }); + expect(r.copied).toEqual([".env"]); + expect(r.skipped).toEqual([{ path: ".env.linked", reason: "has other hard links (the same file may live outside the primary checkout)" }]); + }); + + it("with deferCarry, the command sees only committed content and the uncommitted edits arrive after", async () => { + await commitRecipe("copy: [.env]\ncommand: cat file.txt > seen.txt\n"); + writeFileSync(join(repo, ".gitignore"), ".env\n.env.*\nnode_modules\n*.local.json\nseen.txt\n"); + await g(["commit", "-qam", "ignore seen"], repo); + const policy = await trusted(); + writeFileSync(join(repo, "file.txt"), "committed\nagent edit\n"); // uncommitted + const wt = await createForkWorktree({ workdir: repo, label: "setup", shortId: `t${++n}`, deferCarry: true }); + expect(wt.carry).toBeDefined(); + expect(readFileSync(join(wt.path, "file.txt"), "utf8")).toBe("committed\n"); + const r = await prepareWorktree({ worktreePath: wt.path, policy }); + expect(r.command?.exitCode).toBe(0); + expect(readFileSync(join(wt.path, "seen.txt"), "utf8")).toBe("committed\n"); + await applyCarriedState(wt.path, wt.carry!); + expect(readFileSync(join(wt.path, "file.txt"), "utf8")).toBe("committed\nagent edit\n"); + // Uncommitted, on HEAD — as an immediate carry leaves it — and the recipe's files untouched. + expect(await g(["rev-parse", "HEAD"], wt.path)).toBe(await g(["rev-parse", "HEAD"], repo)); + expect(await g(["status", "--porcelain"], wt.path)).toBe("M file.txt"); + expect(readFileSync(join(wt.path, ".env"), "utf8")).toBe("SECRET=1\n"); + }); + it("never copies a file git wouldn't ignore, and never overwrites one", async () => { await commitRecipe("copy: [notes.txt, file.txt]\n"); writeFileSync(join(repo, "notes.txt"), "untracked, not ignored\n"); @@ -258,14 +303,14 @@ describe("prepareWorktree", () => { await commitRecipe("copy: [.env]\nshare: [node_modules\n"); const wt = await newWorktree(); const before = listing(wt.path); - const r = await prepareWorktree({ worktreePath: wt.path, policy: trusted() }); + const r = await prepareWorktree({ worktreePath: wt.path, policy: await trusted() }); expect(r.applied).toBe(false); expect(r.error).toMatch(/setup\.yaml is not valid YAML/); expect(listing(wt.path)).toEqual(before); await commitRecipe("copy: [.env, ../escape]\n"); const wt2 = await newWorktree(); - const r2 = await prepareWorktree({ worktreePath: wt2.path, policy: trusted() }); + const r2 = await prepareWorktree({ worktreePath: wt2.path, policy: await trusted() }); expect(r2.error).toMatch(/\.\. segment/); expect(existsSync(join(wt2.path, ".env"))).toBe(false); }); @@ -279,7 +324,14 @@ describe("prepareWorktree", () => { describe("prepareWorktree — the setup command", () => { it("never runs when the setting is never (the default)", async () => { - expect(loadConfig().worktree?.setup).toEqual({ runCommands: "never", trustedRepos: [] }); + const prevXdg = process.env.XDG_CONFIG_HOME; + process.env.XDG_CONFIG_HOME = tmp; // an empty config: the defaults + try { + expect(loadConfig().worktree?.setup).toEqual({ runCommands: "never", trustedRepos: [] }); + } finally { + if (prevXdg === undefined) delete process.env.XDG_CONFIG_HOME; + else process.env.XDG_CONFIG_HOME = prevXdg; + } await commitRecipe("command: touch ran\n"); const wt = await newWorktree(); const r = await prepareWorktree({ worktreePath: wt.path, policy: NEVER }); @@ -289,12 +341,53 @@ describe("prepareWorktree — the setup command", () => { expect(existsSync(join(wt.path, "ran"))).toBe(false); }); - it("never runs for a repository the operator didn't list", async () => { - await commitRecipe("command: touch ran\n"); + it("never runs for a repository the owner didn't trust, nor one trusted by path only", async () => { + await commitRecipe("copy: [.env]\ncommand: touch ran\n"); const wt = await newWorktree(); const r = await prepareWorktree({ worktreePath: wt.path, policy: { runCommands: "trusted-repos", trustedRepos: [join(tmp, "other")] } }); - expect(r.commandSkipped).toMatch(/isn't in worktree\.setup\.trustedRepos/); + expect(r.commandSkippedCode).toBe("untrusted"); + expect(r.commandSkipped).toMatch(/isn't trusted — run `codeoid worktree trust`/); + expect(r.copied).toEqual([".env"]); + const wt2 = await newWorktree(); + const r2 = await prepareWorktree({ worktreePath: wt2.path, policy: { runCommands: "trusted-repos", trustedRepos: [repo] } }); + expect(r2.commandSkippedCode).toBe("untrusted"); + expect(r2.commandSkipped).toMatch(/trusted by path only/); + expect(setupEvent(r2)).toBe("worktree.setup.untrusted"); expect(existsSync(join(wt.path, "ran"))).toBe(false); + expect(existsSync(join(wt2.path, "ran"))).toBe(false); + }); + + it("a recipe changed since it was trusted runs no command — copy/share still apply — until trusted again", async () => { + await commitRecipe("copy: [.env]\ncommand: touch ran\n"); + const policy = await trusted(); + await commitRecipe("copy: [.env]\ncommand: touch ran-changed\n"); + + const wt = await newWorktree(); + const r = await prepareWorktree({ worktreePath: wt.path, policy }); + expect(r.commandSkippedCode).toBe("changed"); + expect(r.commandSkipped).toMatch(/the setup command changed since you trusted it — run `codeoid worktree trust`/); + expect(setupEvent(r)).toBe("worktree.setup.untrusted"); + expect(describeSetupResult(r)).toMatch(/without its setup command/); + expect(r.error).toBeUndefined(); + expect(r.copied).toEqual([".env"]); + expect(existsSync(join(wt.path, "ran-changed"))).toBe(false); + + // Re-trusting the new recipe runs it. + const wt2 = await newWorktree(); + const r2 = await prepareWorktree({ worktreePath: wt2.path, policy: await trusted() }); + expect(r2.command?.exitCode).toBe(0); + expect(existsSync(join(wt2.path, "ran-changed"))).toBe(true); + }); + + it("kills what the command left running when it exits, and redacts secrets in its output", async () => { + await commitRecipe("command: '(sleep 1; touch late) & echo api_key=sk-abcdef0123456789abcdef0123'\n"); + const wt = await newWorktree(); + const r = await prepareWorktree({ worktreePath: wt.path, policy: await trusted() }); + expect(r.command?.exitCode).toBe(0); + expect(r.command?.outputTail).toContain("api_key="); + expect(r.command?.outputTail).not.toContain("sk-abcdef0123456789abcdef0123"); + await new Promise((res) => setTimeout(res, 1_500)); + expect(existsSync(join(wt.path, "late"))).toBe(false); }); it("runs in the new worktree for a trusted repository, after the files are in place", async () => { @@ -303,7 +396,7 @@ describe("prepareWorktree — the setup command", () => { const prev = process.env.CODEOID_API_KEY; process.env.CODEOID_API_KEY = "zid_sk_must_not_leak"; try { - const r = await prepareWorktree({ worktreePath: wt.path, policy: trusted() }); + const r = await prepareWorktree({ worktreePath: wt.path, policy: await trusted() }); expect(r.error).toBeUndefined(); expect(r.command?.exitCode).toBe(0); expect(r.command?.timedOut).toBe(false); @@ -318,7 +411,7 @@ describe("prepareWorktree — the setup command", () => { it("a failing command is reported with its output and keeps the worktree", async () => { await commitRecipe("copy: [.env]\ncommand: 'echo boom >&2; exit 3'\n"); const wt = await newWorktree(); - const r = await prepareWorktree({ worktreePath: wt.path, policy: trusted() }); + const r = await prepareWorktree({ worktreePath: wt.path, policy: await trusted() }); expect(r.command?.exitCode).toBe(3); expect(r.command?.outputTail).toContain("boom"); expect(r.error).toBe("the setup command exited 3"); @@ -330,7 +423,7 @@ describe("prepareWorktree — the setup command", () => { await commitRecipe("command: 'sleep 30 & sleep 30'\ntimeoutSec: 1\n"); const wt = await newWorktree(); const t0 = Date.now(); - const r = await prepareWorktree({ worktreePath: wt.path, policy: trusted() }); + const r = await prepareWorktree({ worktreePath: wt.path, policy: await trusted() }); expect(Date.now() - t0).toBeLessThan(10_000); expect(r.command?.timedOut).toBe(true); expect(r.error).toBe("the setup command timed out after 1s"); @@ -341,14 +434,25 @@ describe("prepareWorktree — the setup command", () => { const wt = await newWorktree(); const abort = new AbortController(); setTimeout(() => abort.abort(), 200); - const r = await prepareWorktree({ worktreePath: wt.path, policy: trusted(), signal: abort.signal }); + const r = await prepareWorktree({ worktreePath: wt.path, policy: await trusted(), signal: abort.signal }); expect(r.command?.exitCode).toBeNull(); expect(r.error).toBeDefined(); }); - it("commandRefusal compares real paths", () => { - expect(commandRefusal(undefined, repo)).toMatch(/off/); - expect(commandRefusal({ runCommands: "trusted-repos", trustedRepos: [`${repo}/`] }, repo)).toBeNull(); + it("commandRefusal pins the blob and compares real, absolute paths", () => { + const hash = "0".repeat(40); + expect(commandRefusal(undefined, repo, hash)?.code).toBe("off"); + expect(commandRefusal({ runCommands: "trusted-repos", trustedRepos: [{ path: `${repo}/`, recipeHash: hash }] }, repo, hash)).toBeNull(); + expect(commandRefusal({ runCommands: "trusted-repos", trustedRepos: [{ path: `${repo}/`, recipeHash: hash }] }, repo, "1".repeat(40))?.code).toBe("changed"); + // A relative path is never a trust entry. + const rel = join("..", repo.split("/").slice(1).join("/")); + expect(commandRefusal({ runCommands: "trusted-repos", trustedRepos: [{ path: rel, recipeHash: hash }] }, repo, hash)?.code).toBe("untrusted"); + }); + + it("the config schema takes trust entries with an absolute path and a blob id", () => { + expect(validateConfigObject({ worktree: { setup: { trustedRepos: ["/abs", { path: "/abs", recipeHash: "a".repeat(40) }] } } }).ok).toBe(true); + expect(validateConfigObject({ worktree: { setup: { trustedRepos: ["relative/path"] } } }).ok).toBe(false); + expect(validateConfigObject({ worktree: { setup: { trustedRepos: [{ path: "/abs", recipeHash: "nope" }] } } }).ok).toBe(false); }); }); From 50e4d289f13ad3c65e2c1a7a92a5560bcd7fa70d Mon Sep 17 00:00:00 2001 From: Yash Datta Date: Sun, 25 Oct 2026 08:53:30 +0800 Subject: [PATCH 3/3] fix: a trusted setup command runs only on a base commit on the primary 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) --- docs/CONFIGURATION.md | 9 +- src/config.ts | 2 + src/daemon/git-worktree.ts | 21 ++++- src/daemon/session-manager.ts | 140 +++++++++++++++++++++--------- src/daemon/session.ts | 42 ++++++--- src/daemon/settings/store.ts | 20 +++-- src/daemon/workspace-setup.ts | 88 ++++++++++++++++--- src/tests/session-fork.test.ts | 36 +++++++- src/tests/workspace-setup.test.ts | 81 +++++++++++++++++ 9 files changed, 362 insertions(+), 77 deletions(-) diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index b60b888a..7519bb1a 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -213,9 +213,16 @@ Keep it to regenerable dependency directories; config and tooling directories (` The `command` runs only when you opt in twice. Set `worktree.setup.runCommands` to `trusted-repos` (Settings, or `config.json`), then run `codeoid worktree trust` in the repository. -It shows the command and records the repository with the recipe's git blob id, direnv-allow style. +It shows the command and records the repository with the recipe's git blob id, direnv-allow style, and with who trusted it: on a shared daemon, the entry applies only to that person's sessions. When the recipe changes, its command stops running and the session says so until you trust it again; `codeoid worktree trust --off` withdraws trust. +A trusted command also runs only when the worktree's own base commit is reviewed content. +That commit must carry the very recipe you trusted, and be on your checkout's branch (an ancestor of its `HEAD`). +A fork from another branch, a fetched pull request, a fork of a session on a `codeoid/*` branch with commits of its own, or the work-in-progress commit codeoid saves when a session ends all fail that check. +Such a worktree still gets its copied files and shared directories; its command is skipped, and the session says why. +This doesn't cover everything: an agent working directly in your checkout can still commit to your branch, and those commits pass. +That is one more reason to prefer `--ignore-scripts`. + Trust covers the repository's committed content at setup time, not just the one line you reviewed. The command runs as you, on a clean checkout of the worktree's base commit (a fork's uncommitted changes are applied only after it), together with every script it invokes from that tree: `package.json` lifecycle scripts, a `Makefile`, a script in the repo. Prefer a command that runs no repository scripts, such as `bun install --ignore-scripts` or `npm ci --ignore-scripts`. diff --git a/src/config.ts b/src/config.ts index 10eb8d83..86702d66 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1188,6 +1188,8 @@ const RootSchema = z.object({ .object({ path: z.string().min(1).refine(isAbsolute, "must be an absolute path"), recipeHash: z.string().regex(/^[0-9a-f]{40,64}$/, "must be a git blob id"), + /** Who trusted it: honoured only for that owner's sessions. Absent = every owner. */ + trustedBy: z.string().min(1).max(512).optional(), }) .strict(), ]), diff --git a/src/daemon/git-worktree.ts b/src/daemon/git-worktree.ts index 65622f4d..d4eef74b 100644 --- a/src/daemon/git-worktree.ts +++ b/src/daemon/git-worktree.ts @@ -299,7 +299,26 @@ export async function applyCarriedState(worktreePath: string, carry: CarriedStat try { await git(["restore", `--source=${carry.snapshot}`, "--worktree", "--", "."], worktreePath); } catch (err) { - throw new WorktreeError(`couldn't carry the uncommitted changes: ${err instanceof Error ? err.message : String(err)}`); + const why = err instanceof Error ? err.message : String(err); + // Never leave a half-carried tree: put the tracked files back to HEAD + // and take out the files the carry added. + let added: string[] = []; + try { + added = (await git(["diff", "--name-only", "-z", "--no-renames", "--diff-filter=A", carry.head, carry.snapshot], worktreePath)).split("\0").filter(Boolean); + } catch { + // the snapshot is unreadable, so the restore wrote nothing of it + } + try { + await git(["restore", "--source=HEAD", "--worktree", "--", "."], worktreePath); + removeSetupArtifacts(worktreePath, { copied: added }); + } catch (rollbackErr) { + throw new WorktreeError( + `couldn't carry the uncommitted changes (${why}), and putting the worktree back to its last commit failed too, so they may be partially applied: ${ + rollbackErr instanceof Error ? rollbackErr.message : String(rollbackErr) + }`, + ); + } + throw new WorktreeError(`couldn't carry the uncommitted changes (${why}); the worktree was put back to its last commit`); } } diff --git a/src/daemon/session-manager.ts b/src/daemon/session-manager.ts index aaad9a72..d2392902 100644 --- a/src/daemon/session-manager.ts +++ b/src/daemon/session-manager.ts @@ -187,6 +187,8 @@ import { SETUP_RECIPE_PATH, type TrustedRecipe, WORKTREE_SETUP_SUPPORTED, + brokenShares, + commandRefusal, willRunCommand, WorktreeSetupFailure, type WorktreeSetupPolicy, @@ -2134,6 +2136,7 @@ mcpHub: this.#mcpHub, // an undelegated owner only, and every change audited old → new. const setupPatches = msg.patches.filter((p) => p.key.startsWith("worktree.setup.")); if (setupPatches.length > 0 && auth.delegationDepth > 0) { + this.#store.audit(auth.sub, "settings.set.denied", "", `keys=${setupPatches.map((p) => p.key).join(",")} reason=delegated`); return { type: "response.error", requestId: msg.id, error: "A delegated token can't change worktree setup settings.", code: "forbidden" }; } const setupBefore = setupPatches.length > 0 ? getSnapshot().values : undefined; @@ -3011,10 +3014,12 @@ mcpHub: this.#mcpHub, * run, unasked, in every later worktree of that repository. */ async #worktreeTrust(msg: Extract, auth: AuthContext): Promise { - if (!hasScope(auth.scopes as string[], SCOPES.SETTINGS_WRITE)) return this.#loginForbidden(msg.id); - if (auth.delegationDepth > 0) { - return { type: "response.error", requestId: msg.id, error: "A delegated token can't change which setup commands run.", code: "forbidden" }; - } + const denied = (error: string, code: "forbidden" | "invalid_request"): DaemonMessage => { + this.#store.audit(auth.sub, "worktree.trust.denied", "", `dir=${msg.dir} reason=${error}`); + return { type: "response.error", requestId: msg.id, error, code }; + }; + if (!hasScope(auth.scopes as string[], SCOPES.SETTINGS_WRITE)) return denied("Missing scope: settings:write", "forbidden"); + if (auth.delegationDepth > 0) return denied("A delegated token can't change which setup commands run.", "forbidden"); const invalid = (error: string): DaemonMessage => ({ type: "response.error", requestId: msg.id, error, code: "invalid_request" }); if (!WORKTREE_SETUP_SUPPORTED) return invalid("worktree setup recipes aren't supported on Windows"); const dir = normalizeWorkdir(msg.dir); @@ -3032,43 +3037,50 @@ mcpHub: this.#mcpHub, // Withdrawing trust needs no readable recipe. if (!msg.off) return invalid(err instanceof Error ? err.message : String(err)); } - const sameRepo = (e: string | TrustedRecipe): boolean => { + const sameRepo = (e: unknown): boolean => { + const p = typeof e === "string" ? e : e && typeof e === "object" ? (e as { path?: unknown }).path : undefined; + if (typeof p !== "string") return false; try { - return realpathSync.native(typeof e === "string" ? e : e.path) === root; + return realpathSync.native(p) === root; } catch { return false; } }; - const policy = this.#worktreeSetupPolicy; - const before = policy.trustedRepos; - let next = before; - if (msg.off) { - next = before.filter((e) => !sameRepo(e)); - } else if (!msg.dryRun) { + let entry: TrustedRecipe | undefined; + if (!msg.off && !msg.dryRun) { if (!found) return invalid(`there is no ${SETUP_RECIPE_PATH} at the committed HEAD of ${root}`); if (!msg.recipeHash) return invalid("recipeHash is required to trust a recipe — ask with dryRun first, and review its command"); - if (msg.recipeHash !== found.recipeHash) return invalid("the recipe changed since it was shown — review it again"); - next = [...before.filter((e) => !sameRepo(e)), { path: root, recipeHash: found.recipeHash }]; - } - const changed = JSON.stringify(next) !== JSON.stringify(before); - if (changed) { - const written = writeWorktreeTrust(next); + if (msg.recipeHash !== found.recipeHash) return denied("the recipe changed since it was shown — review it again", "invalid_request"); + entry = { path: root, recipeHash: found.recipeHash, trustedBy: auth.sub }; + } + // Built from config.json as it is now, not this daemon's copy, so a hand + // edit made since it started survives. + let list: readonly (string | TrustedRecipe)[] = this.#worktreeSetupPolicy.trustedRepos; + let changed = false; + if (msg.off || entry) { + const add = entry; + const written = writeWorktreeTrust((current) => [...current.filter((e) => !sameRepo(e)), ...(add ? [add] : [])]); if (!written.ok) return { type: "response.error", requestId: msg.id, error: written.error, code: "internal" }; - this.#worktreeSetupPolicy = { ...policy, trustedRepos: next }; - const show = (list: readonly (string | TrustedRecipe)[]) => - list - .filter(sameRepo) - .map((e) => (typeof e === "string" ? "path-only" : e.recipeHash.slice(0, 12))) - .join(",") || "none"; - this.#store.audit( - auth.sub, - msg.off ? "worktree.untrust" : "worktree.trust", - "", - `repo=${root} old=${show(before)} new=${show(next)}${found?.recipe.command ? ` command=${redactCommand(found.recipe.command)}` : ""}`, - ); + changed = JSON.stringify(written.after) !== JSON.stringify(written.before); + list = written.after as (string | TrustedRecipe)[]; + this.#worktreeSetupPolicy = { ...this.#worktreeSetupPolicy, trustedRepos: list }; + if (changed) { + const show = (l: readonly unknown[]) => + l + .filter(sameRepo) + .map((e) => (typeof e === "string" ? "path-only" : `${String((e as TrustedRecipe).recipeHash).slice(0, 12)}@${(e as TrustedRecipe).trustedBy ?? "config"}`)) + .join(",") || "none"; + this.#store.audit( + auth.sub, + msg.off ? "worktree.untrust" : "worktree.trust", + "", + `repo=${root} old=${show(written.before)} new=${show(written.after)}${found?.recipe.command ? ` command=${redactCommand(found.recipe.command)}` : ""}`, + ); + } } const current = found; - const trusted = !!current && next.some((e) => typeof e !== "string" && sameRepo(e) && e.recipeHash === current.recipeHash); + const trusted = + !!current && commandRefusal({ runCommands: "trusted-repos", trustedRepos: list }, root, current.recipeHash, auth.sub) === null; return { type: "worktree.trust.result", requestId: msg.id, @@ -3080,6 +3092,19 @@ mcpHub: this.#mcpHub, }; } + /** + * A fork whose files wait for its worktree setup recipe: its folder is the + * clean checkout, not its files yet, so nothing may read, fork or rewind it. + */ + #stillPreparing(requestId: string): DaemonMessage { + return { + type: "response.error", + requestId, + error: "This session is still preparing its worktree (running its setup recipe) — try again once it's ready.", + code: "invalid_request", + }; + } + #loginForbidden(requestId: string): DaemonMessage { return { type: "response.error", @@ -3416,6 +3441,7 @@ mcpHub: this.#mcpHub, code: "not_found", }; } + if (session.filesPending) return this.#stillPreparing(msg.id); try { return await handleFsList(msg, session.workdir); } catch (err) { @@ -3444,6 +3470,7 @@ mcpHub: this.#mcpHub, code: "not_found", }; } + if (session.filesPending) return this.#stillPreparing(msg.id); try { return await handleFsRead(msg, session.workdir); } catch (err) { @@ -4703,6 +4730,7 @@ mcpHub: this.#mcpHub, if (parent.role) { return { type: "response.error", requestId: msg.id, error: `Cannot fork a ${parent.role} session`, code: "invalid_request" }; } + if (parent.filesPending) return this.#stillPreparing(msg.id); const providerId = msg.providerId ?? parent.providerId; if (providerId && !this.#providers.has(providerId)) { @@ -4827,9 +4855,12 @@ mcpHub: this.#mcpHub, // parent's carried edits, an earlier turn's files — waits until after // setup: a trusted command must only ever see committed content. let recipe: RecipeRead | undefined; + // One snapshot of the policy: what decided the plan is what prepares it. + const policy = this.#worktreeSetupPolicy; let deferFiles = false; + let carryNote: string | undefined; let pendingCarry: CarriedState | undefined; - let putFiles: () => Promise = async () => {}; + let putFiles: (setup?: WorktreeSetupResult) => Promise = async () => {}; if (msg.workdir) { // Bind mode: run in a dir/worktree the user manages; record its branch // but never create or (later) remove it. Normalize + validate exactly @@ -4857,7 +4888,10 @@ mcpHub: this.#mcpHub, // exists so the snapshot lands where it belongs. let rollHead = point?.files && "checkpointId" in point.files ? point.files.head : undefined; recipe = await loadSetupRecipe(parent.workdir); - deferFiles = willRunCommand(recipe, this.#worktreeSetupPolicy); + // Whether the command can run at all is known only once the base + // commit is checked out (commandGate): create clean whenever a + // trusted recipe has one, then decide. + const mayRun = !!recipe && !("error" in recipe) && !!recipe.recipe.command && commandRefusal(policy, recipe.root, recipe.recipeHash, auth.sub) === null; const make = (baseCommit?: string) => createForkWorktree({ workdir: parent.workdir, @@ -4865,8 +4899,8 @@ mcpHub: this.#mcpHub, shortId: worktreeShortId, ...(msg.baseBranch ? { baseBranch: msg.baseBranch } : {}), ...(baseCommit ? { baseCommit } : {}), - ...((point?.files && "checkpointId" in point.files) || deferFiles ? { ensureSubdir: true } : {}), - ...(deferFiles ? { deferCarry: true } : {}), + ...((point?.files && "checkpointId" in point.files) || mayRun ? { ensureSubdir: true } : {}), + ...(mayRun ? { deferCarry: true } : {}), }); let headGone = false; const wt = await make(rollHead).catch((err: unknown) => { @@ -4889,7 +4923,14 @@ mcpHub: this.#mcpHub, forkSubdirMapped = wt.subdirMapped && inside; forkFromHead = rollHead; forkHeadGone = headGone; - pendingCarry = wt.carry; + deferFiles = mayRun && (await willRunCommand(recipe, policy, wt.path, auth.sub)); + if (deferFiles) pendingCarry = wt.carry; + else if (wt.carry) { + // No command will run here after all: carry now, as without a recipe. + await applyCarriedState(wt.path, wt.carry).catch((err: unknown) => { + carryNote = `⚠️ This fork's worktree didn't get the parent's uncommitted changes: ${err instanceof Error ? err.message : String(err)}.`; + }); + } // Surface that this is a fresh isolated worktree (deps aren't // present) — previously the isolated path was silent. const origin = msg.baseBranch @@ -4900,7 +4941,7 @@ mcpHub: this.#mcpHub, const setupHint = this.#config?.fork?.setup ? " Running the configured fork setup now — the first turn will wait for it." : " Ignored files and build dependencies (e.g. .env, node_modules) aren't in a fresh worktree unless the repository's committed .codeoid/setup.yaml brings them — otherwise run your project's setup before building."; - workdirNote = `🌿 Isolated in a new git worktree on branch \`${wt.branch}\` (${origin}).${setupHint}`; + workdirNote = `🌿 Isolated in a new git worktree on branch \`${wt.branch}\` (${origin}).${setupHint}${carryNote ? ` ${carryNote}` : ""}`; } catch (err) { const reason = err instanceof Error ? err.message : String(err); // An explicit baseBranch that can't be honored is a user error @@ -5003,13 +5044,17 @@ mcpHub: this.#mcpHub, // folder lines up with the parent's, never in a shared or bound one. // Held back until after the setup recipe when it runs a command. const madeWorktree = worktree?.createdByCodeoid ? worktree : undefined; - putFiles = async (): Promise => { + putFiles = async (setup?: WorktreeSetupResult): Promise => { if (pendingCarry && madeWorktree) { try { await applyCarriedState(madeWorktree.path, pendingCarry); } catch (err) { + await fork.noteForkFiles(`⚠️ This fork's worktree didn't get the parent's uncommitted changes: ${err instanceof Error ? err.message : String(err)}.`); + } + const broken = setup ? brokenShares(madeWorktree.path, setup.shared) : []; + if (broken.length > 0) { await fork.noteForkFiles( - `⚠️ This fork's worktree didn't get the parent's uncommitted changes (${err instanceof Error ? err.message : String(err)}); it has the parent's last commit as committed.`, + `⚠️ The parent's uncommitted changes replaced the shared link${broken.length > 1 ? "s" : ""} ${broken.join(", ")} with their own files, so ${broken.length > 1 ? "they aren't" : "it isn't"} shared with your checkout any more.`, ); } } @@ -5083,12 +5128,12 @@ mcpHub: this.#mcpHub, // trusted), then the operator's fork.setup command. if (worktree?.createdByCodeoid) { const after = deferFiles ? putFiles : undefined; - fork.beginWorktreePrepare(this.#worktreeSetupPolicy, { + fork.beginWorktreePrepare(policy, { ...(recipe !== undefined ? { recipe } : {}), ...(after ? { - after: () => - after().catch((err: unknown) => { + after: (setup: WorktreeSetupResult) => + after(setup).catch((err: unknown) => { console.error(`[codeoid/fork] files after setup failed for ${fork.id.slice(0, 8)}: ${err instanceof Error ? err.message : String(err)}`); }), } @@ -6142,7 +6187,12 @@ mcpHub: this.#mcpHub, // fail the run as an infrastructure error (not the agent's) when // the worktree can't be made ready. Nothing but this run's fresh // worktree is removed — the primary checkout is never touched. - setup = await prepareWorktree({ worktreePath: wt.path, policy: this.#worktreeSetupPolicy, ...(o.signal ? { signal: o.signal } : {}) }); + setup = await prepareWorktree({ + worktreePath: wt.path, + policy: this.#worktreeSetupPolicy, + ownerSub: o.ownerAuth.sub, + ...(o.signal ? { signal: o.signal } : {}), + }); if (setup.error) { await discard(setup); throw new WorktreeSetupFailure(setup); @@ -6154,6 +6204,9 @@ mcpHub: this.#mcpHub, await discard(setup); throw err; } + for (const rel of brokenShares(wt.path, setup.shared)) { + setup.warnings.push(`the checkout's uncommitted changes replaced the shared link ${rel} with their own files, so it isn't shared any more`); + } } if (setup.copied.length > 0 || setup.shared.length > 0) { worktree = { ...worktree, setup: { copied: setup.copied, shared: setup.shared } }; @@ -7504,6 +7557,7 @@ mcpHub: this.#mcpHub, if (session.role) { return { type: "response.error", requestId: msg.id, error: `Cannot go back a turn in a ${session.role} session`, code: "invalid_request" }; } + if (session.filesPending) return this.#stillPreparing(msg.id); const canStop = hasScope(auth.scopes as string[], SCOPES.SESSION_INTERRUPT); if (msg.dryRun === true) { // A preview shows prompts, file paths and commands from the session's diff --git a/src/daemon/session.ts b/src/daemon/session.ts index 057fbf9e..72f20cc9 100644 --- a/src/daemon/session.ts +++ b/src/daemon/session.ts @@ -2933,6 +2933,12 @@ export class Session { * worktree's dependencies are ready. Cleared once awaited. */ #pendingSetup: Promise | null = null; + /** A fork's own files are still waiting for its setup recipe (see beginWorktreePrepare). */ + #filesPending = false; + get filesPending(): boolean { + return this.#filesPending; + } + /** The worktree setup recipe's step in that chain, and its abort (destroy stops it). */ #setupRecipe: { done: Promise; abort: AbortController } | null = null; @@ -4518,26 +4524,34 @@ export class Session { * and file roll, held back so a setup command only ever sees committed * content. Runs whether or not setup succeeded; never after destroy. */ - after?: () => Promise; + after?: (result: WorktreeSetupResult) => Promise; } = {}, ): void { const wt = this.#worktree; if (!wt?.createdByCodeoid) return; const abort = new AbortController(); + // Until `after` has run the folder is the clean checkout, not the fork's + // files: a fork, rewind or file read of it is refused meanwhile. + if (opts.after) this.#filesPending = true; const done = this.#chainSetup(async () => { - if (abort.signal.aborted || this.#destroyed) return; - const result = await prepareWorktree({ - worktreePath: wt.path, - policy, - signal: abort.signal, - ...(opts.recipe !== undefined ? { recipe: opts.recipe } : {}), - }); - // Recorded even when destroyed meanwhile: destroy's cleanup reads it to - // take the copied files and shared links back out. - this.#recordWorktreeSetup(result, !this.#destroyed); - if (this.#destroyed) return; - this.#announceWorktreeSetup(result); - if (opts.after) await opts.after(); + try { + if (abort.signal.aborted || this.#destroyed) return; + const result = await prepareWorktree({ + worktreePath: wt.path, + policy, + signal: abort.signal, + ownerSub: this.createdBy, + ...(opts.recipe !== undefined ? { recipe: opts.recipe } : {}), + }); + // Recorded even when destroyed meanwhile: destroy's cleanup reads it to + // take the copied files and shared links back out. + this.#recordWorktreeSetup(result, !this.#destroyed); + if (this.#destroyed) return; + this.#announceWorktreeSetup(result); + if (opts.after) await opts.after(result); + } finally { + this.#filesPending = false; + } }); this.#setupRecipe = { done, abort }; void done.finally(() => { diff --git a/src/daemon/settings/store.ts b/src/daemon/settings/store.ts index df49f0d6..59965e70 100644 --- a/src/daemon/settings/store.ts +++ b/src/daemon/settings/store.ts @@ -249,22 +249,30 @@ export function writeMcpServerEntry(name: string, entry: Record } /** - * Write `worktree.setup.trustedRepos` (what `codeoid worktree trust` - * records): merged onto the raw file, the whole config validated, then - * written atomically — nothing is written if it doesn't validate. + * Update `worktree.setup.trustedRepos` (what `codeoid worktree trust` + * records) from what config.json holds NOW — so a hand edit made since the + * daemon started isn't overwritten — then validate the whole config and + * write it atomically; nothing is written if it doesn't validate. Returns the + * list before and after. */ -export function writeWorktreeTrust(trustedRepos: readonly unknown[]): { ok: true } | { ok: false; error: string } { +export function writeWorktreeTrust( + update: (current: readonly unknown[]) => unknown[], +): { ok: true; before: unknown[]; after: unknown[] } | { ok: false; error: string } { let raw: Record; try { raw = readRawConfig(); } catch (err) { return { ok: false, error: `config.json is not valid JSON: ${errMsg(err)}` }; } - setByPath(raw, "worktree.setup.trustedRepos", [...trustedRepos]); + const current = getByPath(raw, "worktree.setup.trustedRepos"); + const before = Array.isArray(current) ? [...current] : []; + const after = update(before); + if (JSON.stringify(after) === JSON.stringify(before)) return { ok: true, before, after }; + setByPath(raw, "worktree.setup.trustedRepos", after); const validation = validateConfigObject(raw); if (!validation.ok) return { ok: false, error: validation.issues.map((i) => i.message).join("; ") }; writeConfigJson(raw); - return { ok: true }; + return { ok: true, before, after }; } // ── config.json IO ────────────────────────────────────────────────────────── diff --git a/src/daemon/workspace-setup.ts b/src/daemon/workspace-setup.ts index 252e35f0..ed84e6ea 100644 --- a/src/daemon/workspace-setup.ts +++ b/src/daemon/workspace-setup.ts @@ -32,7 +32,12 @@ * - `command` runs only under `worktree.setup.runCommands: "trusted-repos"` * and only while the recipe's git blob is the one the owner trusted with * `codeoid worktree trust` (direnv-allow style: a changed recipe runs - * nothing until it is trusted again). It runs on the CLEAN checkout of the + * nothing until it is trusted again; an entry holds only for the owner + * who recorded it), and only when the worktree's base commit carries that + * recipe and is on the primary checkout's history ({@link commandGate}) — + * never another branch, a pull request, or an agent's own commit. An + * agent working in the primary checkout itself can still commit there. + * It runs on the CLEAN checkout of the * worktree's base commit, before any uncommitted state a fork carries is * applied, with a built environment, never the daemon's own (GHSA-38vh). * Trust therefore covers the repository's committed content at setup @@ -288,6 +293,12 @@ export interface TrustedRecipe { path: string; /** The recipe's git blob id when it was trusted. */ recipeHash: string; + /** + * Who trusted it. Honoured only for worktrees whose session owner is the + * same subject (a shared daemon); an entry without it (hand-written in + * config.json) holds for every owner. + */ + trustedBy?: string; } /** `config.worktree.setup`: whether a recipe's `command` may run. */ @@ -302,8 +313,13 @@ export interface WorktreeSetupPolicy { } export interface CommandRefusal { - /** off: commands disabled; untrusted: never trusted; changed: the recipe changed since; unsupported: platform. */ - code: "off" | "untrusted" | "changed" | "unsupported"; + /** + * off: commands disabled; untrusted: never trusted (by this owner); + * changed: the recipe changed since; base-not-on-primary: the worktree's + * base commit isn't reviewed content (see {@link commandGate}); + * unsupported: platform. + */ + code: "off" | "untrusted" | "changed" | "base-not-on-primary" | "unsupported"; reason: string; } @@ -318,24 +334,57 @@ function realOrNull(p: string): string | null { const TRUST_HINT = "run `codeoid worktree trust` in the repository to review its command and trust it"; /** Null when `root`'s recipe (blob `recipeHash`) may run its command, else why not. */ -export function commandRefusal(policy: WorktreeSetupPolicy | undefined, root: string, recipeHash: string): CommandRefusal | null { +export function commandRefusal(policy: WorktreeSetupPolicy | undefined, root: string, recipeHash: string, ownerSub?: string): CommandRefusal | null { if (!WORKTREE_SETUP_SUPPORTED) return { code: "unsupported", reason: "setup commands aren't supported on Windows" }; if (!policy || policy.runCommands !== "trusted-repos") { return { code: "off", reason: "recipe commands are off (set worktree.setup.runCommands to trusted-repos, then trust the recipe with `codeoid worktree trust`)" }; } const real = realOrNull(root); const mine = real ? policy.trustedRepos.filter((e) => path.isAbsolute(typeof e === "string" ? e : e.path) && realOrNull(typeof e === "string" ? e : e.path) === real) : []; - const pinned = mine.filter((e): e is TrustedRecipe => typeof e !== "string"); + const pinned = mine.filter((e): e is TrustedRecipe => typeof e !== "string" && (e.trustedBy === undefined || e.trustedBy === ownerSub)); if (pinned.some((e) => e.recipeHash === recipeHash)) return null; if (pinned.length > 0) return { code: "changed", reason: `the setup command changed since you trusted it — ${TRUST_HINT}` }; if (mine.length > 0) return { code: "untrusted", reason: `this repository is trusted by path only, which runs no command — ${TRUST_HINT}` }; return { code: "untrusted", reason: `this repository's recipe isn't trusted — ${TRUST_HINT}` }; } -/** Whether preparing from `read` under `policy` would run a command (it then needs the clean checkout). */ -export function willRunCommand(read: RecipeRead, policy: WorktreeSetupPolicy | undefined): boolean { +/** + * The whole decision on whether a worktree's setup command may run — the + * trust in {@link commandRefusal}, then the worktree's ACTUAL base commit + * (its HEAD, still clean at setup time): it must carry the very recipe + * that was trusted, and be on the primary checkout's history. A branch, a + * fetched pull request, an agent's commit on a `codeoid/*` branch or the WIP + * commit codeoid saves on destroy is content nobody reviewed, and the + * command would run its scripts. Null when it may run. + */ +export async function commandGate( + read: FoundRecipe, + policy: WorktreeSetupPolicy | undefined, + worktreePath: string, + ownerSub?: string, +): Promise { + const refusal = commandRefusal(policy, read.root, read.recipeHash, ownerSub); + if (refusal) return refusal; + const off: CommandRefusal = { + code: "base-not-on-primary", + reason: + "this worktree starts from a commit that isn't on your checkout's branch (another branch, a pull request, or an agent's commit), so the trusted command would run code you haven't reviewed", + }; + try { + const base = (await git(["rev-parse", "--verify", "HEAD^{commit}"], worktreePath)).stdout.trim(); + const blob = await git(["rev-parse", "--verify", "--quiet", `${base}:${SETUP_RECIPE_PATH}`], read.root, { okCodes: [1, 128] }); + if (blob.code !== 0 || blob.stdout.trim() !== read.recipeHash) return off; + const onPrimary = await git(["merge-base", "--is-ancestor", base, read.rev], read.root, { okCodes: [1] }); + return onPrimary.code === 0 ? null : off; + } catch { + return off; + } +} + +/** Whether preparing `worktreePath` from `read` would run a command (it then needs the clean checkout). */ +export async function willRunCommand(read: RecipeRead, policy: WorktreeSetupPolicy | undefined, worktreePath: string, ownerSub?: string): Promise { if (!read || "error" in read || !read.recipe.command) return false; - return commandRefusal(policy, read.root, read.recipeHash) === null; + return (await commandGate(read, policy, worktreePath, ownerSub)) === null; } // ── Prepare ────────────────────────────────────────────────────────────────── @@ -510,6 +559,8 @@ export async function prepareWorktree(opts: { /** Abort a running command (session destroyed, routine cancelled, daemon stopping). */ signal?: AbortSignal; recipe?: RecipeRead; + /** The session owner's subject — trust entries recorded by someone else don't apply. */ + ownerSub?: string; }): Promise { const result: WorktreeSetupResult = { applied: false, copied: [], shared: [], skipped: [], warnings: [] }; const fail = (err: unknown): WorktreeSetupResult => { @@ -667,7 +718,7 @@ export async function prepareWorktree(opts: { } if (recipe.command) { - const refusal = commandRefusal(opts.policy, root, recipeHash); + const refusal = await commandGate(found, opts.policy, worktree, opts.ownerSub); if (refusal) { result.commandSkipped = refusal.reason; result.commandSkippedCode = refusal.code; @@ -773,10 +824,27 @@ async function runSetupCommand( }); } +/** + * Shared entries that are no longer the recipe's symlinks — what carrying a + * fork's uncommitted state can do when it holds tracked files at that path + * (git replaces the link with a directory). Empty when all are intact. + */ +export function brokenShares(worktreePath: string, shared: readonly string[]): string[] { + return shared.filter((rel) => { + try { + return !lstatSync(path.join(worktreePath, rel)).isSymbolicLink(); + } catch { + return true; + } + }); +} + /** The session event a result is surfaced as. */ export function setupEvent(r: WorktreeSetupResult): "worktree.setup.failed" | "worktree.setup.untrusted" | "worktree.setup.done" { if (r.error) return "worktree.setup.failed"; - if (r.commandSkippedCode === "changed" || r.commandSkippedCode === "untrusted") return "worktree.setup.untrusted"; + if (r.commandSkippedCode === "changed" || r.commandSkippedCode === "untrusted" || r.commandSkippedCode === "base-not-on-primary") { + return "worktree.setup.untrusted"; + } return "worktree.setup.done"; } diff --git a/src/tests/session-fork.test.ts b/src/tests/session-fork.test.ts index 5cf21e33..35961156 100644 --- a/src/tests/session-fork.test.ts +++ b/src/tests/session-fork.test.ts @@ -705,6 +705,35 @@ describe("SessionManager session.fork — git worktree isolation", () => { expect(readFileSync(join(repo, ".env"), "utf8")).toBe("TOKEN=1\n"); }); + it("W10: while a fork's files wait for its setup command, reading, forking or rewinding it is refused", async () => { + const repo = await makeGitRepoWithDirtyEdit(); + mkdirSync(join(repo, ".codeoid")); + writeFileSync(join(repo, ".codeoid", "setup.yaml"), "command: sleep 1\n"); + await gitIn(["add", ".codeoid"], repo); + await gitIn(["commit", "-m", "recipe"], repo); + const recipeHash = (await readSetupRecipe(repo))!.recipeHash; + const { registry } = makeRegistry(); + const config = { ...loadConfig(), worktree: { setup: { runCommands: "trusted-repos" as const, trustedRepos: [{ path: repo, recipeHash }] } } }; + const manager = new SessionManager(store, transcript, undefined, undefined, undefined, { providers: registry, config } as never); + const c = client(AUTH); + const parentId = await createIn(manager, c, repo); + await sendAndSettle(manager, c, parentId, "hello"); + + const resp = await manager.handle({ type: "session.fork", id: "w10", sessionId: parentId }, AUTH, c); + const fork = (resp as { data: { id: string; workdir: string } }).data; + const preparing = /still preparing its worktree/; + expect(await manager.handle({ type: "fs.list", id: "l", sessionId: fork.id, path: "." } as never, AUTH, c)).toMatchObject({ error: expect.stringMatching(preparing) }); + expect(await manager.handle({ type: "fs.read", id: "r", sessionId: fork.id, path: "file.txt" } as never, AUTH, c)).toMatchObject({ error: expect.stringMatching(preparing) }); + expect(await manager.handle({ type: "session.fork", id: "ff", sessionId: fork.id }, AUTH, c)).toMatchObject({ error: expect.stringMatching(preparing) }); + expect(await manager.handle({ type: "session.rewind", id: "rw", sessionId: fork.id, turnId: "t", dryRun: true } as never, AUTH, c)).toMatchObject({ + error: expect.stringMatching(preparing), + }); + + await sendAndSettle(manager, c, fork.id, "go"); + expect(await manager.handle({ type: "fs.read", id: "r2", sessionId: fork.id, path: "file.txt" } as never, AUTH, c)).toMatchObject({ type: "fs.read.result" }); + expect(readFileSync(join(fork.workdir, "file.txt"), "utf8")).toBe("committed\nWIP\n"); + }); + it("W9: `worktree.trust` pins the recipe — owner only; a changed recipe runs nothing until trusted again", async () => { const prevXdg = process.env.XDG_CONFIG_HOME; process.env.XDG_CONFIG_HOME = join(tmp, "xdg"); // config.json writes land here @@ -727,9 +756,12 @@ describe("SessionManager session.fork — git worktree isolation", () => { expect(await trust({ recipeHash: shown.recipeHash }, { ...AUTH, delegationDepth: 1 })).toMatchObject({ type: "response.error", code: "forbidden" }); expect(await trust({ recipeHash: shown.recipeHash }, { ...AUTH, scopes: ["session:create"] as AuthContext["scopes"] })).toMatchObject({ code: "forbidden" }); expect(await trust({ recipeHash: "f".repeat(40) })).toMatchObject({ type: "response.error", code: "invalid_request" }); + // A hand edit made while the daemon runs survives a trust change. + mkdirSync(join(tmp, "xdg", "codeoid"), { recursive: true }); + writeFileSync(join(tmp, "xdg", "codeoid", "config.json"), JSON.stringify({ worktree: { setup: { trustedRepos: ["/hand/edited"] } } })); expect(await trust({ recipeHash: shown.recipeHash })).toMatchObject({ trusted: true, changed: true }); const written = JSON.parse(readFileSync(join(tmp, "xdg", "codeoid", "config.json"), "utf8")); - expect(written.worktree.setup.trustedRepos).toEqual([{ path: realpathSync(repo), recipeHash: shown.recipeHash }]); + expect(written.worktree.setup.trustedRepos).toEqual(["/hand/edited", { path: realpathSync(repo), recipeHash: shown.recipeHash, trustedBy: AUTH.sub }]); const parentId = await createIn(manager, c, repo); await sendAndSettle(manager, c, parentId, "hello"); @@ -761,7 +793,7 @@ describe("SessionManager session.fork — git worktree isolation", () => { expect(existsSync(join(third.fork.workdir, ".ran-v2"))).toBe(true); expect(await trust({ off: true })).toMatchObject({ trusted: false, changed: true }); - expect(JSON.parse(readFileSync(join(tmp, "xdg", "codeoid", "config.json"), "utf8")).worktree.setup.trustedRepos).toEqual([]); + expect(JSON.parse(readFileSync(join(tmp, "xdg", "codeoid", "config.json"), "utf8")).worktree.setup.trustedRepos).toEqual(["/hand/edited"]); } finally { if (prevXdg === undefined) delete process.env.XDG_CONFIG_HOME; else process.env.XDG_CONFIG_HOME = prevXdg; diff --git a/src/tests/workspace-setup.test.ts b/src/tests/workspace-setup.test.ts index 691cadb4..83ce5e3d 100644 --- a/src/tests/workspace-setup.test.ts +++ b/src/tests/workspace-setup.test.ts @@ -16,6 +16,7 @@ import { promisify } from "node:util"; import { createCheckpoint, deleteCheckpoints, shadowGitLiteral } from "../daemon/checkpoints.js"; import { applyCarriedState, createForkWorktree, removeForkWorktree } from "../daemon/git-worktree.js"; import { + brokenShares, commandRefusal, describeSetupResult, parseSetupRecipe, @@ -379,6 +380,86 @@ describe("prepareWorktree — the setup command", () => { expect(existsSync(join(wt2.path, "ran-changed"))).toBe(true); }); + it("a base commit off the primary checkout's history runs no command (a branch, a PR ref) — files still apply", async () => { + await commitRecipe("copy: [.env]\ncommand: touch ran\n"); + const policy = await trusted(); + await g(["checkout", "-q", "-b", "feature"], repo); + writeFileSync(join(repo, "feature.txt"), "unreviewed\n"); + await g(["add", "feature.txt"], repo); + await g(["commit", "-qm", "unmerged"], repo); + await g(["checkout", "-q", "main"], repo); + + const wt = await newWorktree({ baseBranch: "feature" }); + const r = await prepareWorktree({ worktreePath: wt.path, policy }); + expect(r.commandSkippedCode).toBe("base-not-on-primary"); + expect(setupEvent(r)).toBe("worktree.setup.untrusted"); + expect(r.copied).toEqual([".env"]); + expect(existsSync(join(wt.path, "ran"))).toBe(false); + }); + + it("a fork of a codeoid branch with an agent's commit runs no command", async () => { + await commitRecipe("command: touch ran\n"); + const policy = await trusted(); + const parent = await newWorktree(); + writeFileSync(join(parent.path, "agent.txt"), "agent work\n"); + await g(["add", "agent.txt"], parent.path); + await g(["commit", "-qm", "agent commit"], parent.path); + const child = await createForkWorktree({ workdir: parent.path, label: "child", shortId: `t${++n}`, deferCarry: true }); + const r = await prepareWorktree({ worktreePath: child.path, policy }); + expect(r.commandSkippedCode).toBe("base-not-on-primary"); + expect(existsSync(join(child.path, "ran"))).toBe(false); + }); + + it("an earlier commit on the primary history runs it — unless that commit's recipe isn't the trusted one", async () => { + await commitRecipe("command: touch ran\n"); + const earlier = await g(["rev-parse", "HEAD"], repo); + writeFileSync(join(repo, "later.txt"), "x\n"); + await g(["add", "later.txt"], repo); + await g(["commit", "-qm", "later"], repo); + const policy = await trusted(); + const wt = await createForkWorktree({ workdir: repo, label: "earlier", shortId: `t${++n}`, baseCommit: earlier }); + const r = await prepareWorktree({ worktreePath: wt.path, policy }); + expect(r.command?.exitCode).toBe(0); + expect(existsSync(join(wt.path, "ran"))).toBe(true); + + // The recipe changed after `earlier`, and the new one is what's trusted: + // `earlier` carries a recipe nobody trusted, so nothing runs there. + await commitRecipe("command: touch ran2\n"); + const wt2 = await createForkWorktree({ workdir: repo, label: "earlier", shortId: `t${++n}`, baseCommit: earlier }); + const r2 = await prepareWorktree({ worktreePath: wt2.path, policy: await trusted() }); + expect(r2.commandSkippedCode).toBe("base-not-on-primary"); + }); + + it("a trust entry holds only for its owner's worktrees", async () => { + await commitRecipe("command: touch ran\n"); + const recipeHash = (await readSetupRecipe(repo))!.recipeHash; + const policy: WorktreeSetupPolicy = { runCommands: "trusted-repos", trustedRepos: [{ path: repo, recipeHash, trustedBy: "alice" }] }; + const wt = await newWorktree(); + expect((await prepareWorktree({ worktreePath: wt.path, policy, ownerSub: "bob" })).commandSkippedCode).toBe("untrusted"); + expect(existsSync(join(wt.path, "ran"))).toBe(false); + const wt2 = await newWorktree(); + expect((await prepareWorktree({ worktreePath: wt2.path, policy, ownerSub: "alice" })).command?.exitCode).toBe(0); + }); + + it("a carry that fails leaves the worktree at its last commit, not half-applied", async () => { + writeFileSync(join(repo, "file.txt"), "committed\nedit\n"); + const wt = await createForkWorktree({ workdir: repo, label: "carry", shortId: `t${++n}`, deferCarry: true }); + writeFileSync(join(wt.path, "file.txt"), "half\n"); // as if a restore had got part-way + await expect(applyCarriedState(wt.path, { ...wt.carry!, snapshot: "f".repeat(40) })).rejects.toThrow(/put back to its last commit/); + expect(readFileSync(join(wt.path, "file.txt"), "utf8")).toBe("committed\n"); + expect(await g(["status", "--porcelain"], wt.path)).toBe(""); + }); + + it("brokenShares names shared entries that are no longer the recipe's links", async () => { + await commitRecipe("share: [node_modules]\n"); + const wt = await newWorktree(); + const r = await prepareWorktree({ worktreePath: wt.path, policy: NEVER }); + expect(brokenShares(wt.path, r.shared)).toEqual([]); + rmSync(join(wt.path, "node_modules")); + mkdirSync(join(wt.path, "node_modules")); + expect(brokenShares(wt.path, r.shared)).toEqual(["node_modules"]); + }); + it("kills what the command left running when it exits, and redacts secrets in its output", async () => { await commitRecipe("command: '(sleep 1; touch late) & echo api_key=sk-abcdef0123456789abcdef0123'\n"); const wt = await newWorktree();