vigiles 35.0.0 → 37.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -7,6 +7,9 @@ import type { HarnessTestDriver, HarnessDriverContext, ParsedRun } from "../../c
7
7
  * `--dangerously-bypass-approvals-and-sandbox` make it run unattended;
8
8
  * `--skip-git-repo-check` lets it run in a bare temp dir; `--ignore-user-config`
9
9
  * keeps the host's `~/.codex` out. The prompt is the trailing positional.
10
+ * `ctx.allowedTools` is ignored: `codex exec` runs with approvals bypassed, so
11
+ * everything is already approved. `ctx.tools` is refused (throws): Codex has no
12
+ * per-run tool availability.
10
13
  * Pure, so the arg shape is unit-tested.
11
14
  */
12
15
  export declare function buildCodexArgs(ctx: HarnessDriverContext): string[];
@@ -26,9 +26,15 @@ const mock_model_js_1 = require("./mock-model.js");
26
26
  * `--dangerously-bypass-approvals-and-sandbox` make it run unattended;
27
27
  * `--skip-git-repo-check` lets it run in a bare temp dir; `--ignore-user-config`
28
28
  * keeps the host's `~/.codex` out. The prompt is the trailing positional.
29
+ * `ctx.allowedTools` is ignored: `codex exec` runs with approvals bypassed, so
30
+ * everything is already approved. `ctx.tools` is refused (throws): Codex has no
31
+ * per-run tool availability.
29
32
  * Pure, so the arg shape is unit-tested.
30
33
  */
31
34
  function buildCodexArgs(ctx) {
35
+ if (ctx.tools !== undefined) {
36
+ throw new Error("tools is not supported for codex: it has no per-run tool availability, and a list that withheld nothing would let a test pass over a run where nothing was withheld. Drop tools, or assert on what the agent did.");
37
+ }
32
38
  return [
33
39
  "exec",
34
40
  "--ignore-user-config",
@@ -13,7 +13,7 @@
13
13
  /** Human-facing verbs (printed in help; typed by a human/agent/CI). */
14
14
  export declare const VERBS: readonly ["init", "compile", "eject", "lint", "test", "eval", "audit", "generate", "hook-runtime"];
15
15
  /** Runtime entrypoint kinds under `vigiles hook-runtime <kind>` (emitted, not typed). */
16
- export declare const HOOK_RUNTIME_KINDS: readonly ["run-program", "agent", "agent-start", "agent-done", "skill", "skill-tool", "skill-start", "skill-done", "run-skill", "intercept-tool", "guard", "action", "refs", "eval-lock-nudge", "effect-enter", "effect-exit"];
16
+ export declare const HOOK_RUNTIME_KINDS: readonly ["run-program", "agent", "agent-start", "agent-done", "skill", "skill-tool", "skill-start", "skill-done", "run-skill", "intercept-tool", "stub", "guard", "action", "refs", "eval-lock-nudge", "effect-enter", "effect-exit"];
17
17
  export type Verb = (typeof VERBS)[number];
18
18
  export type HookRuntimeKind = (typeof HOOK_RUNTIME_KINDS)[number];
19
19
  //# sourceMappingURL=cli-commands.d.ts.map
@@ -37,6 +37,7 @@ exports.HOOK_RUNTIME_KINDS = [
37
37
  "skill-done",
38
38
  "run-skill",
39
39
  "intercept-tool",
40
+ "stub",
40
41
  "guard",
41
42
  "action",
42
43
  "refs",
package/dist/cli-main.js CHANGED
@@ -27,6 +27,7 @@ const generate_harness_js_1 = require("./core/generate-harness.js");
27
27
  const capability_diff_js_1 = require("./core/capability-diff.js");
28
28
  const validate_js_1 = require("./core/validate.js");
29
29
  const eval_lock_js_1 = require("./eval-lock.js");
30
+ const eval_entry_js_1 = require("./eval-entry.js");
30
31
  const zod_1 = require("zod");
31
32
  const cli_flags_js_1 = require("./cli-flags.js");
32
33
  const cli_commands_js_1 = require("./cli-commands.js");
@@ -5572,6 +5573,18 @@ async function handleRunScripts(kind, args, restArgs, excludes) {
5572
5573
  console.error(`\n✗ vigiles ${kind}: --min=${String(minRequired)} but only ${String(loadedCount)} of ` +
5573
5574
  `${String(files.length)} matched ${kind} file(s) loaded.`);
5574
5575
  }
5576
+ // An eval whose stubs left a call unanswered measured an answer nobody wrote.
5577
+ // That is not a failed assertion but a non-measurement, and it must fail the
5578
+ // command even when the file's own `assert` never looks (eval-entry.ts). The
5579
+ // file says so with its own exit code, so this names the cause from that code
5580
+ // and not from the verb's usage-error 2.
5581
+ const unanswered = results.filter((r) => r.code === eval_entry_js_1.UNANSWERED_STUB_EXIT_CODE);
5582
+ if (kind === "eval" && unanswered.length > 0) {
5583
+ console.error(`\n✗ vigiles eval: unanswered stub call(s) in ${unanswered.map((r) => r.file).join(", ")} — not a usage error. ` +
5584
+ `The calls are listed above; add a rule for each, and no lock was written. ` +
5585
+ `(Exit ${String(eval_entry_js_1.UNANSWERED_STUB_VERB_EXIT)}; the file itself exited ${String(eval_entry_js_1.UNANSWERED_STUB_EXIT_CODE)}.)`);
5586
+ process.exit(eval_entry_js_1.UNANSWERED_STUB_VERB_EXIT);
5587
+ }
5575
5588
  if ((0, run_scripts_js_1.anyFailed)(results))
5576
5589
  process.exit(1);
5577
5590
  // 🔴 A SKIP THE AUTHOR NEVER DECLARED IS NOT A SKIP — and the discriminator was
package/dist/cli.js CHANGED
@@ -65,6 +65,53 @@ function die(e) {
65
65
  }
66
66
  process.exit(2);
67
67
  }
68
+ /**
69
+ * Any failure on the stub path — a log the agent's shell cannot append to, a
70
+ * rules file that does not parse — is a MISS to the caller: the same neutral
71
+ * line and code as a call no rule answers. A vigiles error line or stack would
72
+ * tell the model it is in a test, and a call that could not be logged must never
73
+ * read as answered. `VIGILES_DEBUG=1` shows the cause.
74
+ */
75
+ async function stubAnswer(root, name, argv) {
76
+ const { runStub, unsupportedLine, UNANSWERED_EXIT_CODE } = await import("./core/stub-rules.js");
77
+ const { readFileSync, appendFileSync, existsSync } = await import("node:fs");
78
+ try {
79
+ return runStub(root, name, argv, {
80
+ readFile: (p) => (existsSync(p) ? readFileSync(p, "utf8") : null),
81
+ appendFile: (p, text) => {
82
+ appendFileSync(p, text);
83
+ },
84
+ });
85
+ }
86
+ catch (e) {
87
+ if (process.env.VIGILES_DEBUG)
88
+ console.error(e instanceof Error && e.stack ? e.stack : String(e));
89
+ return {
90
+ stdout: "",
91
+ stderr: unsupportedLine(name),
92
+ exitCode: UNANSWERED_EXIT_CODE,
93
+ };
94
+ }
95
+ }
96
+ /**
97
+ * A TOOL STUB's invocation — the eval tier writes a `gh`/`git` shim onto PATH
98
+ * that execs `hook-runtime stub <root> <name> <argv…>`, once per call the agent
99
+ * (or a script it runs) makes. Same cost argument as `run-program`, so the same
100
+ * kind of fast path: the pure decision (`core/stub-rules.js`) and `node:fs`,
101
+ * nothing else — no barrel, no zod. The argv is taken RAW: the stubbed tool's
102
+ * own `--flags` are its argv. It never reads stdin: a caller that leaves the
103
+ * pipe open (Node's `execFile` does) would hang until its own timeout.
104
+ */
105
+ async function runStubCommand(raw) {
106
+ const [root, name, ...argv] = raw;
107
+ if (root === undefined || name === undefined) {
108
+ console.error("vigiles hook-runtime stub: emitted into a tool-stub shim by the eval tier, never typed by hand.");
109
+ process.exit(2);
110
+ }
111
+ const r = await stubAnswer(root, name, argv);
112
+ // Exit only once both streams have flushed: a pipe write is async on macOS.
113
+ process.stdout.write(r.stdout, () => process.stderr.write(r.stderr, () => process.exit(r.exitCode)));
114
+ }
68
115
  async function dispatch() {
69
116
  // Parsed EXACTLY as `main()` parses it — `args.slice(1)` with `--flags`
70
117
  // dropped — so the fast path and the barrel agree on which argument is the
@@ -86,6 +133,10 @@ async function dispatch() {
86
133
  await runHookProgramCommand(rest[1]);
87
134
  return;
88
135
  }
136
+ if (command === "hook-runtime" && kind === "stub") {
137
+ await runStubCommand(args.slice(2));
138
+ return;
139
+ }
89
140
  const { main } = require("./cli-main.js");
90
141
  await main();
91
142
  }
@@ -18,6 +18,7 @@
18
18
  * runners re-export these names, so every existing import keeps resolving.
19
19
  */
20
20
  import type { ToolCall, HookFire, ModelRequest } from "./harness-driver.js";
21
+ import type { StubCall } from "./stub-rules.js";
21
22
  /** Per-run resource use, parsed from the terminal `result` event (0 when absent). */
22
23
  export interface EvalUsage {
23
24
  /** `total_cost_usd` reported by the harness binary. */
@@ -99,6 +100,13 @@ export interface Trace {
99
100
  * Empty unless the stream was captured / the harness emits subagent events.
100
101
  */
101
102
  readonly subagents?: readonly SubagentTrace[];
103
+ /**
104
+ * Every invocation of a tool stub during the run, in order — including calls a
105
+ * script the agent ran made itself (`execFile(gh, …)`), which never appear as
106
+ * a tool call. Each says which rule answered it, or that none did. Absent when
107
+ * the run had no stubs (eval tier: `stubs` on the spec).
108
+ */
109
+ readonly stubCalls?: readonly StubCall[];
102
110
  /** Final contents of a file under the working dir, or null if absent. */
103
111
  file(path: string): string | null;
104
112
  }
@@ -137,7 +145,7 @@ export interface AgentRunArgs {
137
145
  * When true, `env` is the COMPLETE spawn environment (an ephemeral run env from
138
146
  * `ephemeralRunEnv`) — the runner does NOT prepend `process.env`, so the
139
147
  * real `$HOME` / secrets are scrubbed. Default false: `env` is an overlay over
140
- * `process.env` (the byte-identical-to-today path). Set only by `ephemeralEnv`.
148
+ * `process.env` (the byte-identical-to-today path). Set only by an ephemeral `env`.
141
149
  */
142
150
  readonly replaceEnv?: boolean;
143
151
  }
@@ -0,0 +1,14 @@
1
+ import { type ToolStub } from "./stub-rules.js";
2
+ import { type RunEnv } from "./run-env.js";
3
+ /**
4
+ * Parse an eval spec's `stubs`. Absent → `[]`. Throws, naming the field, on the
5
+ * old shape, a malformed rule, a stateful RegExp, or two stubs of one name.
6
+ */
7
+ export declare function parseToolStubs(raw: unknown, caller: string): readonly ToolStub[];
8
+ /**
9
+ * Parse an eval spec's `env`, including its seeded HOME: every seed path is
10
+ * HOME-relative, stays inside HOME, and does not overwrite one of the harness's
11
+ * own auth files (`keepHomeFiles`). Throws before any trial on a bad value.
12
+ */
13
+ export declare function parseRunEnv(raw: unknown, caller: string, keepHomeFiles: readonly string[]): RunEnv;
14
+ //# sourceMappingURL=eval-spec-parse.d.ts.map
@@ -0,0 +1,168 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.parseToolStubs = parseToolStubs;
4
+ exports.parseRunEnv = parseRunEnv;
5
+ /**
6
+ * The eval spec boundary for `stubs` and `env` — parsed ONCE, before anything is
7
+ * packaged or spent, into the plain data the runner then trusts.
8
+ *
9
+ * An eval file is often plain JavaScript, so no type checks its literal. A shape
10
+ * the runner would silently mis-read must fail here, naming the field and the
11
+ * rewrite. Two such shapes are the reason this module exists:
12
+ *
13
+ * - the old argv-blind stub `{ name, stdout }`, which printed one answer for
14
+ * every invocation (a fabricated observation for every command nobody
15
+ * anticipated);
16
+ * - a spec with no `env`, which used to inherit your HOME and environment by
17
+ * default — so a comparison measured the author's machine.
18
+ *
19
+ * zod lives HERE and not in `stub-rules.ts`: the stub process reads its rules
20
+ * once per `gh` call and must not pay for a schema library; the rules file it
21
+ * reads is this parse's output, written by vigiles.
22
+ */
23
+ const zod_1 = require("zod");
24
+ const stub_rules_js_1 = require("./stub-rules.js");
25
+ const run_env_js_1 = require("./run-env.js");
26
+ /** `stubs[0].rules[1].argv` from a zod issue path. */
27
+ function pathText(root, path) {
28
+ return path.reduce((acc, k) => typeof k === "number" ? `${acc}[${String(k)}]` : `${acc}.${String(k)}`, root);
29
+ }
30
+ function refuse(caller, root, error) {
31
+ const lines = error.issues.map((i) => `${caller}: ${pathText(root, i.path)}: ${i.message}`);
32
+ throw new Error(lines.join("\n"));
33
+ }
34
+ // --- stubs ---------------------------------------------------------------------
35
+ const regexToken = zod_1.z.instanceof(RegExp).refine((r) => !r.global && !r.sticky, {
36
+ message: "a RegExp token may not carry the flags g and y — test() would carry lastIndex from one call to the next",
37
+ });
38
+ const token = zod_1.z.union([zod_1.z.string(), regexToken]);
39
+ const restToken = zod_1.z
40
+ .strictObject({ kind: zod_1.z.literal("rest") })
41
+ .transform(() => stub_rules_js_1.ARGV_REST);
42
+ /** A plain object (not an array, not a RegExp), readable by key. */
43
+ const isObj = (v) => typeof v === "object" &&
44
+ v !== null &&
45
+ !Array.isArray(v) &&
46
+ !(v instanceof RegExp);
47
+ const isRestToken = (t) => isObj(t) && t.kind === "rest";
48
+ const argvPattern = zod_1.z
49
+ .array(zod_1.z.union([token, restToken]))
50
+ .refine((p) => p.slice(0, -1).every((t) => !isRestToken(t)), {
51
+ message: "experimental_stub.rest may appear only as the last token",
52
+ });
53
+ const answerFields = {
54
+ stdout: zod_1.z.string().optional(),
55
+ stderr: zod_1.z.string().optional(),
56
+ exitCode: zod_1.z.number().int().min(0).max(255).optional(),
57
+ };
58
+ const reply = zod_1.z.discriminatedUnion("kind", [
59
+ zod_1.z.strictObject({ kind: zod_1.z.literal("always"), ...answerFields }),
60
+ zod_1.z.strictObject({
61
+ kind: zod_1.z.literal("inOrder"),
62
+ answers: zod_1.z
63
+ .array(zod_1.z.strictObject(answerFields))
64
+ .min(1, { message: "an inOrder reply needs at least one answer" }),
65
+ }),
66
+ ]);
67
+ const rule = zod_1.z
68
+ .strictObject({
69
+ argv: argvPattern,
70
+ contains: zod_1.z.array(token).optional(),
71
+ reply,
72
+ })
73
+ .refine((r) => r.contains === undefined || isRestToken(r.argv.at(-1)), {
74
+ message: "`contains` matches tokens after the positional prefix, so it needs a trailing experimental_stub.rest in `argv`",
75
+ path: ["contains"],
76
+ });
77
+ const stub = zod_1.z.strictObject({
78
+ name: zod_1.z
79
+ .string()
80
+ .min(1)
81
+ .refine((n) => !/[/\\]/.test(n) && n !== "." && n !== "..", {
82
+ message: "a bare binary name, as it is looked up on PATH (no path separator)",
83
+ }),
84
+ rules: zod_1.z.array(rule).min(1, {
85
+ message: "a stub needs at least one rule — one per command the agent runs",
86
+ }),
87
+ });
88
+ /** Is this the removed `{ name, stdout?, stderr?, exitCode? }` shape? */
89
+ const isOldShape = (s) => isObj(s) &&
90
+ !("rules" in s) &&
91
+ ["stdout", "stderr", "exitCode"].some((k) => k in s);
92
+ function oldShapeMessage(caller, i, s) {
93
+ const name = typeof s.name === "string" ? s.name : "<tool>";
94
+ const stdout = typeof s.stdout === "string" ? JSON.stringify(s.stdout) : '"…"';
95
+ return (`${caller}: stubs[${String(i)}] (${JSON.stringify(name)}) is the old shape \`{ name, stdout }\`, which printed one answer for EVERY invocation — ` +
96
+ `a command nobody scripted got that answer as if the tool had said it. A stub is now a list of rules, one per command the agent runs:\n` +
97
+ ` experimental_stub(${JSON.stringify(name)}, [\n` +
98
+ ` { argv: ["<command>", experimental_stub.rest], reply: { kind: "always", stdout: ${stdout} } },\n` +
99
+ ` // one rule per further command; an invocation no rule answers fails the run\n` +
100
+ ` ])\n` +
101
+ ` The argv a run used is printed when it goes unanswered, so the first run tells you which rules to add.`);
102
+ }
103
+ /**
104
+ * Parse an eval spec's `stubs`. Absent → `[]`. Throws, naming the field, on the
105
+ * old shape, a malformed rule, a stateful RegExp, or two stubs of one name.
106
+ */
107
+ function parseToolStubs(raw, caller) {
108
+ if (raw === undefined)
109
+ return [];
110
+ const items = Array.isArray(raw) ? raw : [];
111
+ const old = items.findIndex(isOldShape);
112
+ const oldItem = items[old];
113
+ if (isOldShape(oldItem))
114
+ throw new Error(oldShapeMessage(caller, old, oldItem));
115
+ const parsed = zod_1.z.array(stub).safeParse(raw);
116
+ if (!parsed.success)
117
+ refuse(caller, "stubs", parsed.error);
118
+ const names = parsed.data.map((s) => s.name);
119
+ const dup = names.find((n, i) => names.indexOf(n) !== i);
120
+ if (dup !== undefined)
121
+ throw new Error(`${caller}: two stubs named ${JSON.stringify(dup)} — one binary has one stub; put both rule lists in it.`);
122
+ // The zod output has the shape; `toToolStub` gives it the type, without a cast.
123
+ return parsed.data.map(stub_rules_js_1.toToolStub);
124
+ }
125
+ // --- env -----------------------------------------------------------------------
126
+ const homeSeed = zod_1.z.strictObject({
127
+ kind: zod_1.z.literal("files"),
128
+ files: zod_1.z.record(zod_1.z.string(), zod_1.z.string()),
129
+ });
130
+ const runEnv = zod_1.z.discriminatedUnion("kind", [
131
+ zod_1.z.strictObject({ kind: zod_1.z.literal("ephemeral"), home: homeSeed.optional() }),
132
+ zod_1.z.strictObject({
133
+ kind: zod_1.z.literal("inherit"),
134
+ reason: zod_1.z.string().refine((r) => r.trim() !== "", {
135
+ message: "say why this run must see your real HOME and environment",
136
+ }),
137
+ }),
138
+ ]);
139
+ function envRequiredMessage(caller) {
140
+ return (`${caller}: \`env\` is required — say where each trial runs:\n` +
141
+ ` env: { kind: "ephemeral" } a throwaway HOME and a scrubbed environment; only the harness's own auth passes\n` +
142
+ ` env: { kind: "inherit", reason: "…" } your real HOME and environment; the reason is printed with the report\n` +
143
+ ` There is no default. Inheriting measures your machine (your config, your tokens), and an ephemeral run\n` +
144
+ ` fails on its first trial if your auth is not where the harness expects it — so the choice is yours to make.`);
145
+ }
146
+ /**
147
+ * Parse an eval spec's `env`, including its seeded HOME: every seed path is
148
+ * HOME-relative, stays inside HOME, and does not overwrite one of the harness's
149
+ * own auth files (`keepHomeFiles`). Throws before any trial on a bad value.
150
+ */
151
+ function parseRunEnv(raw, caller, keepHomeFiles) {
152
+ if (raw === undefined)
153
+ throw new Error(envRequiredMessage(caller));
154
+ if (isObj(raw) && raw.kind === "inherit" && "home" in raw)
155
+ throw new Error(`${caller}: env.home: \`home\` exists only on an ephemeral env: seeding would write into your real HOME. ` +
156
+ `Use env: { kind: "ephemeral", home: … }.`);
157
+ const parsed = runEnv.safeParse(raw);
158
+ if (!parsed.success)
159
+ refuse(caller, "env", parsed.error);
160
+ const env = parsed.data;
161
+ if (env.kind === "ephemeral" && env.home !== undefined) {
162
+ const plan = (0, run_env_js_1.planHome)(env.home, keepHomeFiles);
163
+ if (plan.kind === "refused")
164
+ throw new Error(`${caller}: env.home.files: ${plan.reason}`);
165
+ }
166
+ return env;
167
+ }
168
+ //# sourceMappingURL=eval-spec-parse.js.map
@@ -108,10 +108,17 @@ export interface HarnessDriverContext {
108
108
  /** Whether a settings file was written (CC: pass `--settings`). */
109
109
  readonly hasSettings: boolean;
110
110
  /**
111
- * The ONLY tools the agent has, when the spec lists them; undefined = no
112
- * restriction (every tool is offered, the default four are pre-approved).
111
+ * Which tools EXIST in the session, when the spec lists them (CC: `--tools`);
112
+ * undefined = every tool exists. A driver whose harness has no such concept
113
+ * (Codex) throws on one rather than ignore it.
113
114
  */
114
115
  readonly tools?: readonly string[];
116
+ /**
117
+ * Which tools are PRE-APPROVED (CC: `--allowedTools`); undefined = the
118
+ * default four. It never removes a tool. A driver with no such concept
119
+ * ignores it.
120
+ */
121
+ readonly allowedTools?: readonly string[];
115
122
  /** Capture the full event transcript instead of just the final result. */
116
123
  readonly transcript: boolean;
117
124
  /** Path to a plugin dir to install natively, if any. */
@@ -6,7 +6,7 @@
6
6
  *
7
7
  * - SCRUBBED (`scrubbedRunEnv`): a throwaway HOME/TMPDIR, the OS essentials, the
8
8
  * harness's declared auth, and the caller's own named extras. Everything else
9
- * is dropped. The eval tier's `ephemeralEnv` and the harness-tier preflight of
9
+ * is dropped. The eval tier's `env: { kind: "ephemeral" }` and the harness-tier preflight of
10
10
  * `experimental_outputStyleArms` use it.
11
11
  * - INHERITED (`withoutSessionIdentity`): the caller's env as-is, minus the
12
12
  * harness's declared session identity. The default spawn of both tiers.
@@ -46,5 +46,50 @@ export declare function scrubbedRunEnv(base: EnvLike, opts: {
46
46
  * a plain spawn env. Without a policy nothing is removed.
47
47
  */
48
48
  export declare function withoutSessionIdentity(base: EnvLike, policy?: RunEnvPolicy): Readonly<Record<string, string>>;
49
+ /** Files to write under a throwaway HOME: HOME-relative POSIX path → contents. */
50
+ export type HomeFiles = Readonly<Record<string, string>>;
51
+ /**
52
+ * Where a throwaway HOME's starting contents come from. A FILE map, so an empty
53
+ * directory cannot be expressed — Claude Code's startup housekeeping removes
54
+ * empty task directories, and a seed that could hold one would lose it.
55
+ */
56
+ export interface HomeSeed {
57
+ readonly kind: "files";
58
+ readonly files: HomeFiles;
59
+ }
60
+ /**
61
+ * Where a run executes. Two cases, and `home` exists only on `ephemeral`: there
62
+ * is no way to write "seed my real HOME".
63
+ *
64
+ * - `ephemeral` — a throwaway HOME (seeded from `home`, then given the
65
+ * harness's own auth files) and a scrubbed environment.
66
+ * - `inherit` — your real HOME and environment, minus the parent session's
67
+ * identity. `reason` says why, and is printed with the report.
68
+ */
69
+ export type RunEnv = {
70
+ readonly kind: "ephemeral";
71
+ readonly home?: HomeSeed;
72
+ } | {
73
+ readonly kind: "inherit";
74
+ readonly reason: string;
75
+ };
76
+ /** One file a seed writes, HOME-relative. */
77
+ export interface HomeFile {
78
+ readonly path: string;
79
+ readonly contents: string;
80
+ }
81
+ /** The files a seed writes, sorted by path — or why the seed is refused. */
82
+ export type HomePlan = {
83
+ readonly kind: "ok";
84
+ readonly files: readonly HomeFile[];
85
+ } | {
86
+ readonly kind: "refused";
87
+ readonly reason: string;
88
+ };
89
+ /**
90
+ * Validate a seed and list the files it writes. Pure. `keepHomeFiles` are the
91
+ * harness's auth files (`RunEnvPolicy.keepHomeFiles`), copied in after the seed.
92
+ */
93
+ export declare function planHome(seed: HomeSeed | undefined, keepHomeFiles: readonly string[]): HomePlan;
49
94
  export {};
50
95
  //# sourceMappingURL=run-env.d.ts.map
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.OS_ESSENTIAL_ENV = void 0;
4
4
  exports.scrubbedRunEnv = scrubbedRunEnv;
5
5
  exports.withoutSessionIdentity = withoutSessionIdentity;
6
+ exports.planHome = planHome;
6
7
  /**
7
8
  * What ANY spawned process needs to function, whatever the harness: resolve
8
9
  * binaries (`PATH`) and keep the locale and terminal (`LANG`, `LC_*`, `TERM`).
@@ -48,4 +49,62 @@ function withoutSessionIdentity(base, policy) {
48
49
  function defined(base, keep) {
49
50
  return Object.fromEntries(Object.entries(base).filter((e) => e[1] !== undefined && keep(e[0])));
50
51
  }
52
+ /** Why `path` cannot be a HOME-relative seed path, or undefined when it can. */
53
+ function seedPathProblem(path, keepHomeFiles) {
54
+ if (path === "")
55
+ return "an empty path";
56
+ if (path.startsWith("/") || path.includes("\\"))
57
+ return `${JSON.stringify(path)} is not a HOME-relative POSIX path`;
58
+ const parts = path.split("/");
59
+ if (parts.some((p) => p === "" || p === "." || p === ".."))
60
+ return `${JSON.stringify(path)} must stay inside HOME and be spelled plainly (no "", ".", or ".." segments)`;
61
+ if (keepHomeFiles.includes(path))
62
+ return `${JSON.stringify(path)} is the harness's own auth file, which the run copies in itself; a seed may not replace it`;
63
+ return undefined;
64
+ }
65
+ /**
66
+ * The pairs that cannot both exist: one path written as a FILE while another
67
+ * needs it to be a DIRECTORY (`a` and `a/b`). Over the seed's paths and the
68
+ * harness's auth files, as a seed file standing on an auth file's parent
69
+ * (`.claude`) would fail the same way. Auth files among themselves are the
70
+ * harness's own business. Without this the run fails mid-trial with a raw
71
+ * `EEXIST`/`ENOTDIR`, after the trial's directories were built.
72
+ */
73
+ function seedConflicts(paths, keepHomeFiles) {
74
+ const isSeed = (p) => paths.includes(p);
75
+ const label = (p) => isSeed(p)
76
+ ? JSON.stringify(p)
77
+ : `${JSON.stringify(p)} (the harness's own auth file)`;
78
+ const all = [...paths, ...keepHomeFiles];
79
+ return all.flatMap((file) => all
80
+ .filter((under) => under.startsWith(`${file}/`))
81
+ .filter((under) => isSeed(file) || isSeed(under))
82
+ .map((under) => `${label(file)} is written as a file but ${label(under)} needs it to be a directory`));
83
+ }
84
+ /**
85
+ * Validate a seed and list the files it writes. Pure. `keepHomeFiles` are the
86
+ * harness's auth files (`RunEnvPolicy.keepHomeFiles`), copied in after the seed.
87
+ */
88
+ function planHome(seed, keepHomeFiles) {
89
+ if (seed === undefined)
90
+ return { kind: "ok", files: [] };
91
+ const paths = Object.keys(seed.files).sort();
92
+ if (paths.length === 0)
93
+ return {
94
+ kind: "refused",
95
+ reason: "a files seed with no files — omit `home` for an empty HOME",
96
+ };
97
+ const problems = paths.flatMap((p) => {
98
+ const why = seedPathProblem(p, keepHomeFiles);
99
+ return why === undefined ? [] : [why];
100
+ });
101
+ // Conflicts are read over well-formed paths only: a "" or "/x" would pair with everything.
102
+ const refusals = problems.length > 0 ? problems : seedConflicts(paths, keepHomeFiles);
103
+ if (refusals.length > 0)
104
+ return { kind: "refused", reason: refusals.join("; ") };
105
+ return {
106
+ kind: "ok",
107
+ files: paths.map((path) => ({ path, contents: seed.files[path] ?? "" })),
108
+ };
109
+ }
51
110
  //# sourceMappingURL=run-env.js.map