Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,7 @@ codeoid mcp signin <server> [--callback-url <u>] # sign in to an OAuth re
codeoid evidence <id> [--full] # one recorded check: what ran, on which tree, what it found
codeoid routine add <name> --prompt … [--cron|--every|--at] [--check <cmd>] # scheduled agent runs
codeoid routine ls | show | runs | run [--wait] | edit | pause | resume | rm | cancel <runId> | 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
Expand Down
36 changes: 36 additions & 0 deletions docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,42 @@ 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, 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`.
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
Expand Down
1 change: 1 addition & 0 deletions packages/protocol/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
1 change: 1 addition & 0 deletions packages/protocol/src/schemas.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,7 @@ const samples: { [T in ClientTypes]: Extract<ClientMessage, { type: T }> } = {
"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" },
Expand Down
13 changes: 13 additions & 0 deletions packages/protocol/src/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down Expand Up @@ -1131,6 +1143,7 @@ export const clientMessageSchema = z.discriminatedUnion("type", [
backendLoginCancelSchema,
backendLoginWaitSchema,
skillGrantSchema,
worktreeTrustSchema,
sessionTurnsSchema,
sessionApprovalsSchema,
claudeAccountsListSchema,
Expand Down
10 changes: 10 additions & 0 deletions packages/protocol/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -563,6 +564,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[] };
}

/**
Expand Down Expand Up @@ -1146,6 +1154,7 @@ export type ClientMessage =
| BackendLoginCancelMsg
| BackendLoginWaitMsg
| SkillGrantMsg
| WorktreeTrustMsg
| SessionTurnsMsg
| SessionApprovalsMsg
| ClaudeAccountsListMsg
Expand Down Expand Up @@ -2970,6 +2979,7 @@ export type DaemonMessage =
| BackendLoginCancelResultMsg
| BackendLoginWaitResultMsg
| SkillGrantResultMsg
| WorktreeTrustResultMsg
| SessionTurnsResultMsg
| SessionApprovalsResultMsg
| ClaudeAccountsResultMsg
Expand Down
45 changes: 45 additions & 0 deletions packages/protocol/src/worktree-trust.ts
Original file line number Diff line number Diff line change
@@ -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";
}
87 changes: 87 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean> {
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<string>((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
Expand Down
46 changes: 46 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -1165,6 +1166,40 @@ 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 only while the recipe is
* the one trusted in `trustedRepos`. Reading a recipe executes nothing. */
runCommands: z.enum(["never", "trusted-repos"]).default("never"),
/** 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"),
/** Who trusted it: honoured only for that owner's sessions. Absent = every owner. */
trustedBy: z.string().min(1).max(512).optional(),
})
.strict(),
]),
)
.max(256)
.default([]),
})
.default({ runCommands: "never", trustedRepos: [] }),
})
.default({ setup: { runCommands: "never", trustedRepos: [] } }),
});

type ParsedConfig = z.infer<typeof RootSchema>;
Expand Down Expand Up @@ -1322,6 +1357,16 @@ 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 under
* `runCommands: "trusted-repos"` for a recipe pinned in `trustedRepos`.
* 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
Expand Down Expand Up @@ -1788,6 +1833,7 @@ export function loadConfig(opts: LoadOptions = {}): CodeoidConfig {
embed: parsed.embed,
push: parsed.push,
fork: parsed.fork,
worktree: parsed.worktree,
};
}

Expand Down
Loading
Loading