faberun 0.27.0 → 0.28.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.
Files changed (73) hide show
  1. package/README.md +9 -3
  2. package/package.json +1 -1
  3. package/skills/faberun/SKILL.md +3 -3
  4. package/skills/faberun/references/contract.md +9 -9
  5. package/skills/faberun/references/local-env.md +11 -0
  6. package/skills/faberun/references/operations.md +26 -33
  7. package/src/campaign/brief.mjs +41 -9
  8. package/src/campaign/campaign-brief.mjs +18 -3
  9. package/src/campaign/index.mjs +22 -9
  10. package/src/campaign/ledger.mjs +72 -4
  11. package/src/campaign/metrics.mjs +29 -2
  12. package/src/campaign/record.mjs +34 -0
  13. package/src/cli/campaign.mjs +35 -12
  14. package/src/cli/contract.mjs +11 -1
  15. package/src/cli/launch.mjs +12 -2
  16. package/src/cli/manual.mjs +24 -8
  17. package/src/cli/setup.mjs +5 -0
  18. package/src/cli/skills.mjs +18 -7
  19. package/src/cli/uninstall.mjs +542 -0
  20. package/src/cli/walkthrough.mjs +462 -0
  21. package/src/cli.mjs +67 -7
  22. package/src/contract/articles.mjs +30 -0
  23. package/src/contract/definition-of-done.mjs +109 -0
  24. package/src/contract/index.mjs +7 -4
  25. package/src/contract/judge-independence.mjs +18 -0
  26. package/src/contract/runtime.mjs +5 -1
  27. package/src/contract/scope-findings.mjs +120 -0
  28. package/src/contract/snapshot.mjs +19 -5
  29. package/src/contract/task-packet.mjs +49 -1
  30. package/src/contract/worker-result.mjs +10 -0
  31. package/src/engine/assignment.mjs +45 -0
  32. package/src/engine/detach.mjs +18 -0
  33. package/src/engine/gate.mjs +10 -2
  34. package/src/engine/judge-gate.mjs +24 -9
  35. package/src/engine/live-preflight.mjs +114 -18
  36. package/src/engine/prompts.mjs +41 -3
  37. package/src/engine/resume.mjs +119 -12
  38. package/src/engine/retry.mjs +11 -11
  39. package/src/engine/run-command.mjs +80 -4
  40. package/src/engine/run-identity.mjs +27 -14
  41. package/src/engine/runtime-discovery.mjs +15 -9
  42. package/src/engine/worker-env.mjs +179 -0
  43. package/src/harnesses/agy/index.mjs +11 -0
  44. package/src/harnesses/catalogue.mjs +29 -0
  45. package/src/harnesses/claude/index.mjs +98 -1
  46. package/src/harnesses/codex/index.mjs +15 -0
  47. package/src/harnesses/dsh/index.mjs +14 -0
  48. package/src/harnesses/exec-jsonl/index.mjs +12 -0
  49. package/src/harnesses/fx/index.mjs +13 -0
  50. package/src/harnesses/index.mjs +21 -10
  51. package/src/harnesses/replay/index.mjs +12 -0
  52. package/src/harnesses/zcode/index.mjs +19 -0
  53. package/src/host/config.mjs +8 -2
  54. package/src/host/environment-report.mjs +85 -0
  55. package/src/host/home.mjs +27 -0
  56. package/src/host/install-registry.mjs +511 -0
  57. package/src/host/preflight.mjs +32 -9
  58. package/src/host/uninstall-roots.mjs +196 -0
  59. package/src/plan/proof-check.mjs +15 -0
  60. package/src/plan/repo-facts.mjs +95 -11
  61. package/src/plan/rounds.mjs +27 -0
  62. package/src/repo/declared-paths.mjs +74 -6
  63. package/src/repo/integrate.mjs +1 -1
  64. package/src/repo/scope-closure.mjs +59 -0
  65. package/src/repo/signal-block.mjs +26 -0
  66. package/src/repo/signal.mjs +9 -24
  67. package/src/repo/workspace.mjs +189 -14
  68. package/src/report/campaign-brief.mjs +4 -0
  69. package/src/report/message.mjs +37 -6
  70. package/src/report/progress.mjs +13 -2
  71. package/src/run/paths.mjs +14 -1
  72. package/src/run/usage.mjs +66 -1
  73. package/src/util.mjs +18 -2
@@ -16,10 +16,35 @@ import { mkdirSync, writeFileSync } from "node:fs";
16
16
  import { processStartToken } from "../run/lock.mjs";
17
17
  import { randomUUID } from "node:crypto";
18
18
  import { runMutation } from "./mutation.mjs";
19
+ import { SANDBOX_BLOCKED_WRITE } from "../contract/worker-result.mjs";
20
+ import { isAbsolute, resolve } from "node:path";
21
+ import { isContained } from "../util.mjs";
19
22
  import { spawn } from "node:child_process";
20
23
  import { killTarget, spawnInvocation } from "../host/platform.mjs";
21
24
  /** @typedef {import("../contract/verification.mjs").VerificationOptions} VerificationOptions */
22
25
 
26
+ /**
27
+ * The run-level options plus the sandbox mode the caller knows. The mode is not
28
+ * part of the shared verification contract: it is controller environment, read
29
+ * here only to name a refusal the command itself cannot.
30
+ *
31
+ * @typedef {VerificationOptions & {sandboxMode?: string|null}} RunVerificationOptions
32
+ */
33
+
34
+ /**
35
+ * The named classification of a `workspace-write` refusal outside the worktree,
36
+ * carrying the mode that caused it and the path that was denied.
37
+ *
38
+ * @typedef {{classification: "sandbox_blocked_write", mode: string, path: string}} SandboxBlockedWrite
39
+ */
40
+
41
+ /**
42
+ * A bounded attempt result plus the controller's sandbox classification, which
43
+ * rides beside the captured output only when a refusal was recognized.
44
+ *
45
+ * @typedef {VerificationAttemptResult & {attempt: number, sandboxBlockedWrite?: SandboxBlockedWrite}} SandboxAwareAttemptResult
46
+ */
47
+
23
48
  /** @typedef {import("node:child_process").ChildProcess} ChildProcess */
24
49
  /** @typedef {import("../contract/verification.mjs").VerificationAttempt} VerificationAttempt */
25
50
  /** @typedef {import("../contract/verification.mjs").VerificationAttemptResult} VerificationAttemptResult */
@@ -62,12 +87,51 @@ function verificationEnv(command) {
62
87
  }
63
88
  return env;
64
89
  }
90
+
91
+ // `workspace-write` denies a toolchain's cache in `$HOME` and the tool reports
92
+ // a read-only filesystem: observed 2026-09-22 with Zig under `dsh`,
93
+ // `manifest_create ReadOnlyFileSystem` before its first source file. The
94
+ // sandbox, not the command, is the cause, so the controller names it. `EROFS`
95
+ // and the POSIX message cover the same refusal from other runners.
96
+ const SANDBOX_READ_ONLY_PATTERN = /ReadOnlyFileSystem|EROFS|read-only file system/iu;
97
+ // A denied path is usually quoted (`unable to load '/path': ReadOnlyFileSystem`)
98
+ // or bare after the operation name; either way it is absolute, since only an
99
+ // effect outside the worktree gets refused.
100
+ const ABSOLUTE_PATH_PATTERNS = Object.freeze([/['"]((?:[A-Za-z]:[\\/]|\/)[^'"]+)['"]/gu, /((?:[A-Za-z]:[\\/]|\/)[^\s'",;:)\]]+)/gu]);
101
+
102
+ /**
103
+ * Classify a failed command whose output names a read-only-filesystem refusal
104
+ * outside the worktree, under `workspace-write`. Returns null for any other
105
+ * mode, a refusal inside the worktree, or output with no path to name.
106
+ *
107
+ * @param {{text?: string, workspace: string, mode?: string|null}} args
108
+ * @returns {SandboxBlockedWrite|null}
109
+ */
110
+ export function classifySandboxBlockedWrite({ text, workspace, mode }) {
111
+ if (mode !== "workspace-write" || typeof text !== "string") return null;
112
+ const lines = text.split(/\r?\n/u);
113
+ const markerLines = lines.filter((line) => SANDBOX_READ_ONLY_PATTERN.test(line));
114
+ if (markerLines.length === 0) return null;
115
+ // Prefer a path the refusal line itself names; fall back to any path in the
116
+ // output, since `manifest_create ReadOnlyFileSystem` can put the path on a
117
+ // neighbouring line.
118
+ for (const line of [...markerLines, ...lines]) {
119
+ for (const pattern of ABSOLUTE_PATH_PATTERNS) {
120
+ for (const match of line.matchAll(pattern)) {
121
+ const candidate = match[1];
122
+ const path = isAbsolute(candidate) ? candidate : resolve(workspace, candidate);
123
+ if (!isContained(workspace, path)) return { classification: SANDBOX_BLOCKED_WRITE, mode, path };
124
+ }
125
+ }
126
+ }
127
+ return null;
128
+ }
65
129
  /**
66
130
  * Run every declared command `repeat` times inside the workspace.
67
131
  *
68
132
  * @param {unknown} commands
69
133
  * @param {string} baseCwd
70
- * @param {VerificationOptions} options
134
+ * @param {RunVerificationOptions} options
71
135
  * @returns {Promise<VerificationResult>}
72
136
  */
73
137
  export async function runVerification(commands, baseCwd, options = {}) {
@@ -92,7 +156,7 @@ export async function runVerification(commands, baseCwd, options = {}) {
92
156
  * @param {VerificationCommand} command
93
157
  * @param {string} baseCwd
94
158
  * @param {number} commandIndex
95
- * @param {VerificationOptions} options
159
+ * @param {RunVerificationOptions} options
96
160
  * @returns {Promise<VerificationCommandResult>}
97
161
  */
98
162
  async function runRepeatedCommand(command, baseCwd, commandIndex, options) {
@@ -132,7 +196,7 @@ function signalDeathRetry(result, signal) {
132
196
  * @param {VerificationCommand} command
133
197
  * @param {string} baseCwd
134
198
  * @param {number} commandIndex
135
- * @param {VerificationOptions} options
199
+ * @param {RunVerificationOptions} options
136
200
  * @returns {Promise<VerificationCommandResult>}
137
201
  */
138
202
  async function runMutationCommand(command, baseCwd, commandIndex, options) {
@@ -149,7 +213,7 @@ async function runMutationCommand(command, baseCwd, commandIndex, options) {
149
213
  * @param {string} commandCwd
150
214
  * @param {number} attempt
151
215
  * @param {AbortSignal|undefined} signal
152
- * @param {VerificationOptions} options
216
+ * @param {RunVerificationOptions} options
153
217
  * @param {number} commandIndex
154
218
  * @returns {Promise<VerificationAttemptResult>}
155
219
  */
@@ -181,6 +245,7 @@ function runCommand(command, baseCwd, commandCwd, attempt, signal, options, comm
181
245
  if (abortHandler) signal?.removeEventListener("abort", abortHandler);
182
246
  if (child?.pid && !error && !signalName && !timedOut) terminateGroup(child);
183
247
  const durationMs = Number(process.hrtime.bigint() - started) / 1e6;
248
+ /** @type {SandboxAwareAttemptResult} */
184
249
  const result = {
185
250
  attempt,
186
251
  stdout: stdout.value(),
@@ -192,6 +257,17 @@ function runCommand(command, baseCwd, commandCwd, attempt, signal, options, comm
192
257
  error: error ? String(error.message ?? error) : null,
193
258
  passed: !error && !timedOut && exitCode === 0 && !signalName,
194
259
  };
260
+ // The controller's own sandbox mode bounds where a toolchain may write;
261
+ // a caller may name it, and a controller launched under `dsh` inherits it
262
+ // in `DSH_PERMISSION_MODE`. Only `workspace-write` classifies.
263
+ if (!result.passed) {
264
+ const sandboxBlockedWrite = classifySandboxBlockedWrite({
265
+ text: `${result.stdout}\n${result.stderr}`,
266
+ workspace: resolve(baseCwd, commandCwd),
267
+ mode: options?.sandboxMode ?? process.env.DSH_PERMISSION_MODE ?? null,
268
+ });
269
+ if (sandboxBlockedWrite) result.sandboxBlockedWrite = sandboxBlockedWrite;
270
+ }
195
271
  if (!completionReported && identity) {
196
272
  completionReported = true;
197
273
  try { options?.onAttemptComplete?.({ ...identity, status: result.passed ? "closed" : "failed", completedAt: new Date().toISOString(), result }); } catch {
@@ -21,7 +21,7 @@ import { createHash } from "node:crypto";
21
21
  import { lstatSync, readFileSync, readdirSync } from "node:fs";
22
22
  import { join, relative, sep } from "node:path";
23
23
  import { fileURLToPath } from "node:url";
24
- import { stableJson } from "../util.mjs";
24
+ import { stableJson, errorCode } from "../util.mjs";
25
25
  import { validateRunMetadata } from "../contract/snapshot.mjs";
26
26
  import { contractDigest } from "../contract/index.mjs";
27
27
  import { RUNS_DIR_NAME, runDirectory } from "../run/paths.mjs";
@@ -97,6 +97,26 @@ function runDirFor(sourceIdentity) {
97
97
  return runDirectory(sourceIdentity.cwd, sourceIdentity.contractId);
98
98
  }
99
99
 
100
+ /**
101
+ * Whether a run directory never materialized a node. The directory, its
102
+ * frozen contract and its empty `nodes/` folder are written before runtime
103
+ * assignment and identity capture; node states are written after. A launch
104
+ * refused in that window leaves a directory that never held a node, so
105
+ * nothing can resume it and it would refuse the next attempt as an existing
106
+ * run.
107
+ *
108
+ * @param {string} runDir
109
+ * @returns {boolean}
110
+ */
111
+ export function runNeverHeldNode(runDir) {
112
+ try {
113
+ return readdirSync(join(runDir, "nodes")).every((name) => !name.endsWith(".json"));
114
+ } catch (error) {
115
+ if (errorCode(error) === "ENOENT") return true;
116
+ throw error;
117
+ }
118
+ }
119
+
100
120
  /**
101
121
  * The records a prior run.json already froze, when one exists. On creation
102
122
  * this is empty; on every later metadata rewrite it is the source of truth.
@@ -506,14 +526,10 @@ export async function assertEnvironmentReady(contract, runDir, sourceIdentity) {
506
526
  // state reads done, this launch replays an accepted transaction, starts no
507
527
  // worker and no judge, and can spend no availability.
508
528
  if (blocking === null && launchMayDispatch(runDir)) {
509
- // measured 2026-09-22: asking four routed runtimes in parallel took about
510
- // 18s, so no runtime is given less than 60s here; a runtime at high
511
- // reasoning effort gets its longer default (defaultLivePreflightTimeout).
512
- // FABERUN_PREFLIGHT_TIMEOUT_SEC stays the operator override for every
513
- // runtime; preflightContract validates it, so only a valid number is lifted.
514
- const override = Number(process.env.FABERUN_PREFLIGHT_TIMEOUT_SEC);
515
- const timeoutSec = process.env.FABERUN_PREFLIGHT_TIMEOUT_SEC !== undefined && Number.isFinite(override) && override > 0 ? override : undefined;
516
- const probes = await livePreflightProbes(contract, runDir, sourceIdentity, timeoutSec);
529
+ // The live budget is `live-preflight.mjs`'s measured default (60s, 180s at xhigh;
530
+ // `FABERUN_PREFLIGHT_TIMEOUT_SEC` the operator override). It is not
531
+ // restated here: a second copy is a second default to keep honest.
532
+ const probes = await livePreflightProbes(contract, runDir, sourceIdentity);
517
533
  const silent = probes.filter((probe) => liveSilenceCause(probe) !== null);
518
534
  writeJsonAtomic(join(runDir, "env-preflight.json"), evidence(silent.length === 0, probes));
519
535
  if (silent.length > 0) {
@@ -537,8 +553,6 @@ export async function assertEnvironmentReady(contract, runDir, sourceIdentity) {
537
553
  throw Object.assign(new Error(`env_preflight_failed: ${blocking} · the run stays resumable: fix the environment and resume ${runDir}`), { code: "env_preflight_failed" });
538
554
  }
539
555
 
540
- /** No routed runtime is given less than this by the dispatch gate. */
541
- const GATE_MINIMUM_PREFLIGHT_SEC = 60;
542
556
  /**
543
557
  * The live half of the gate for one launch. Every routed runtime either holds
544
558
  * a verdict this machine recorded inside its freshness window -- reused, the
@@ -555,10 +569,9 @@ const GATE_MINIMUM_PREFLIGHT_SEC = 60;
555
569
  * @param {ValidatedContract} contract
556
570
  * @param {string} runDir
557
571
  * @param {SourceIdentity|undefined} sourceIdentity
558
- * @param {number|undefined} timeoutSec
559
572
  * @returns {Promise<ProbeResult[]>}
560
573
  */
561
- async function livePreflightProbes(contract, runDir, sourceIdentity, timeoutSec) {
574
+ async function livePreflightProbes(contract, runDir, sourceIdentity) {
562
575
  const routed = reachableRuntimes(contract);
563
576
  /** @type {Map<string, RuntimeAvailability>} */
564
577
  const fresh = new Map();
@@ -631,7 +644,7 @@ async function livePreflightProbes(contract, runDir, sourceIdentity, timeoutSec)
631
644
  };
632
645
  });
633
646
  }
634
- const probes = await preflightContract(join(runDir, "contract.json"), { ...(timeoutSec === undefined ? {} : { liveTimeoutSec: timeoutSec }), minimumTimeoutSec: GATE_MINIMUM_PREFLIGHT_SEC, persisted: true });
647
+ const probes = await preflightContract(join(runDir, "contract.json"), { persisted: true });
635
648
  // What this launch bought is durable from here on: every ask that reached a
636
649
  // provider -- a refusal included, an answer being an answer -- is recorded
637
650
  // under the provider's own identity. Silence and a command that never
@@ -1,6 +1,6 @@
1
1
  import { normalizeProviderAvailability, probeRuntime } from "../harnesses/index.mjs";
2
2
  import { effectiveProvider } from "../contract/provider.mjs";
3
- import { SAME_VENDOR_REVIEW_MODE, sameVendorTierRefusal } from "../contract/judge-independence.mjs";
3
+ import { SAME_VENDOR_REVIEW_MODE, effectiveJudgeIndependence, sameVendorTierRefusal } from "../contract/judge-independence.mjs";
4
4
 
5
5
  // Availability normalization belongs to the adapter registry, which is where
6
6
  // each provider's own exhaustion, balance, and authentication wording is
@@ -176,10 +176,12 @@ export function availableCandidates(runtimes, availability = {}) {
176
176
  * copied exactly; callers persist the returned pair in run state.
177
177
  *
178
178
  * An optional `options.config` narrows the candidate set to the harnesses the
179
- * operator enabled and prefers its named worker and judge. An empty narrowed
180
- * set falls back to the unrestricted candidates and is reported through
181
- * `options.onWarning`; config never overrides an explicit node, gate or
182
- * runtime-default declaration, and the cross-vendor judge rule still applies.
179
+ * operator enabled, prefers its named worker and judge, and -- when the
180
+ * contract declares none (R36) -- supplies the machine-wide
181
+ * `judgeIndependence` opt-in. An empty narrowed set falls back to the
182
+ * unrestricted candidates and is reported through `options.onWarning`; config
183
+ * never overrides an explicit node, gate or runtime-default declaration, and
184
+ * the cross-vendor judge rule still applies.
183
185
  *
184
186
  * `options.listJudge` (R18) is asked for an omitted judge before the single
185
187
  * `config.judge` preference and the strongest-candidate default: it is the
@@ -194,7 +196,7 @@ export function availableCandidates(runtimes, availability = {}) {
194
196
  * @param {RuntimeContract} contract
195
197
  * @param {Record<string, RuntimeAvailability>} availability
196
198
  * @param {ComposeOptions} [options]
197
- * @returns {Record<string, {worker: string, judge: string}>}
199
+ * @returns {Record<string, {worker: string, judge: string, judgeIndependence?: "same-vendor"}>}
198
200
  */
199
201
  export function composeAssignments(contract, availability = {}, options = {}) {
200
202
  const allCandidates = availableCandidates(contract.runtimes, availability);
@@ -206,7 +208,7 @@ export function composeAssignments(contract, availability = {}, options = {}) {
206
208
  if (restricted.length) candidates = restricted;
207
209
  else if (allCandidates.length) options.onWarning?.(`config harnesses · none of ${config.harnesses.join(", ")} is available · using all available runtimes`);
208
210
  }
209
- /** @type {Record<string, {worker: string, judge: string}>} */
211
+ /** @type {Record<string, {worker: string, judge: string, judgeIndependence?: "same-vendor"}>} */
210
212
  const assignments = {};
211
213
  for (const node of contract.nodes ?? []) {
212
214
  const workerOmitted = node.runtime === undefined && contract.runtimeDefaults?.worker === undefined;
@@ -235,7 +237,11 @@ export function composeAssignments(contract, availability = {}, options = {}) {
235
237
  // R20: in same-vendor mode a judge of the worker's own provider is
236
238
  // admissible when it is another model at an equal or higher declared tier;
237
239
  // the strongest such candidate is the default only when no other vendor has one.
238
- const sameVendorMode = contract.judgeIndependence === SAME_VENDOR_REVIEW_MODE;
240
+ // R36: a contract that declares no mode inherits the machine config's, and
241
+ // the effective mode is recorded on the assignment so the launch decision
242
+ // survives in the run snapshot.
243
+ const judgeIndependence = effectiveJudgeIndependence(contract, config);
244
+ const sameVendorMode = judgeIndependence === SAME_VENDOR_REVIEW_MODE;
239
245
  const judge = declaredJudge
240
246
  ?? judgeListPick?.chosen
241
247
  ?? (preferredJudge && effectiveProvider(preferredJudge.runtime) !== workerProvider ? preferredJudge.id : undefined)
@@ -251,7 +257,7 @@ export function composeAssignments(contract, availability = {}, options = {}) {
251
257
  const refusal = sameVendorTierRefusal(/** @type {{harness: string, model: string}} */ (workerRuntime), /** @type {{harness: string, model: string}} */ (judgeRuntime));
252
258
  if (refusal) throw new Error(`runtime_assignment_judge_unavailable: ${refusal.message} for node ${node.id}`);
253
259
  }
254
- assignments[node.id] = { worker, judge: judge ?? worker };
260
+ assignments[node.id] = { worker, judge: judge ?? worker, ...(judgeIndependence === undefined ? {} : { judgeIndependence }) };
255
261
  }
256
262
  return assignments;
257
263
  }
@@ -0,0 +1,179 @@
1
+ /**
2
+ * The environment a worker, a judge, or an availability probe may receive.
3
+ *
4
+ * A controller variable reaches a child only when something named it. The
5
+ * names a child may see are the union of:
6
+ *
7
+ * - the base operating-system names below,
8
+ * - the names the selected adapter declares in its `declaredEnvironment`,
9
+ * - the runtime's `*.env_key` names, and
10
+ * - the explicit `envPassthrough` names on the runtime or the contract.
11
+ *
12
+ * Everything else the controller holds -- an `AWS_*`, a `GITHUB_TOKEN`, a
13
+ * `DATABASE_URL` exported in a shell profile -- is dropped. Verification
14
+ * commands are not workers and keep the full controller environment
15
+ * (`judge-gate.mjs`); only worker, judge, and availability-probe spawns route
16
+ * through here.
17
+ *
18
+ * The adapter lists are imported from each adapter directly instead of through
19
+ * `harnesses/catalogue.mjs`: the registry imports this module for its probe, so
20
+ * reaching the catalogue from here would close a runtime import cycle the repo
21
+ * forbids. The catalogue keeps its own copy of the same names for R2, and both
22
+ * read the same adapter exports.
23
+ */
24
+ import { declaredEnvironment as claudeEnvironment } from "../harnesses/claude/index.mjs";
25
+ import { declaredEnvironment as codexEnvironment } from "../harnesses/codex/index.mjs";
26
+ import { declaredEnvironment as agyEnvironment } from "../harnesses/agy/index.mjs";
27
+ import { declaredEnvironment as dshEnvironment } from "../harnesses/dsh/index.mjs";
28
+ import { declaredEnvironment as fxEnvironment } from "../harnesses/fx/index.mjs";
29
+ import { declaredEnvironment as zcodeEnvironment } from "../harnesses/zcode/index.mjs";
30
+ import { declaredEnvironment as execJsonlEnvironment } from "../harnesses/exec-jsonl/index.mjs";
31
+ import { declaredEnvironment as replayEnvironment } from "../harnesses/replay/index.mjs";
32
+ import { NOTIFY_ENV_NAMES } from "../notify/index.mjs";
33
+
34
+ /**
35
+ * The base operating-system names every child needs to resolve a binary, find
36
+ * its home, and speak the locale. `LC_*` is a family, matched by prefix; the
37
+ * Windows entries are listed on every platform because the machine that reads
38
+ * them is the machine that has them.
39
+ *
40
+ * @type {readonly string[]}
41
+ */
42
+ export const BASE_ENV_NAMES = Object.freeze([
43
+ "PATH",
44
+ "HOME",
45
+ "USER",
46
+ "LOGNAME",
47
+ "SHELL",
48
+ "LANG",
49
+ "TERM",
50
+ "TMPDIR",
51
+ "TZ",
52
+ "SYSTEMROOT",
53
+ "USERPROFILE",
54
+ "APPDATA",
55
+ "LOCALAPPDATA",
56
+ "COMSPEC",
57
+ "PATHEXT",
58
+ ]);
59
+
60
+ /** @type {ReadonlySet<string>} */
61
+ const BASE_NAME_SET = new Set(BASE_ENV_NAMES);
62
+
63
+ /**
64
+ * The adapter-declared names, keyed by harness. Values never travel here.
65
+ *
66
+ * A `Map` rather than the catalogue's frozen object on purpose: the repo
67
+ * forbids the same top-level body defined twice, and the registry owns the
68
+ * catalogue copy.
69
+ *
70
+ * @type {Map<string, readonly string[]>}
71
+ */
72
+ const ADAPTER_ENVIRONMENTS = new Map([
73
+ ["claude", claudeEnvironment],
74
+ ["codex", codexEnvironment],
75
+ ["agy", agyEnvironment],
76
+ ["dsh", dshEnvironment],
77
+ ["fx", fxEnvironment],
78
+ ["zcode", zcodeEnvironment],
79
+ ["exec-jsonl", execJsonlEnvironment],
80
+ ["replay", replayEnvironment],
81
+ ]);
82
+
83
+ /**
84
+ * @param {string} name
85
+ * @returns {boolean}
86
+ */
87
+ function isBaseName(name) {
88
+ return BASE_NAME_SET.has(name) || name.startsWith("LC_");
89
+ }
90
+
91
+ /**
92
+ * The `*.env_key` names a runtime's config declares. A config value there is
93
+ * the environment-variable name, never the value, so this reads names alone.
94
+ *
95
+ * @param {{config?: Record<string, unknown>}|null|undefined} runtime
96
+ * @returns {string[]}
97
+ */
98
+ function envKeyNames(runtime) {
99
+ /** @type {string[]} */
100
+ const names = [];
101
+ for (const [key, value] of Object.entries(runtime?.config ?? {})) {
102
+ if (key.endsWith(".env_key") && typeof value === "string" && value.length > 0) names.push(value);
103
+ }
104
+ return names;
105
+ }
106
+
107
+ /**
108
+ * The non-base names one runtime may carry: what its adapter declares, what
109
+ * its `*.env_key` config entries name, and what the runtime or the contract
110
+ * explicitly passes through. The notification transport is never one of them,
111
+ * whatever an author writes.
112
+ *
113
+ * @param {{harness?: string, config?: Record<string, unknown>, envPassthrough?: string[]}|null|undefined} runtime
114
+ * @param {{envPassthrough?: string[]}|null|undefined} [contract]
115
+ * @returns {string[]}
116
+ */
117
+ export function declaredEnvironmentNames(runtime, contract) {
118
+ const names = new Set(ADAPTER_ENVIRONMENTS.get(runtime?.harness ?? "") ?? []);
119
+ for (const name of envKeyNames(runtime)) names.add(name);
120
+ for (const name of Array.isArray(runtime?.envPassthrough) ? runtime.envPassthrough : []) names.add(name);
121
+ for (const name of Array.isArray(contract?.envPassthrough) ? contract.envPassthrough : []) names.add(name);
122
+ for (const name of NOTIFY_ENV_NAMES) names.delete(name);
123
+ return [...names];
124
+ }
125
+
126
+ /**
127
+ * The base names present in `source`, including every `LC_*` member.
128
+ *
129
+ * @param {Record<string, string|undefined>} [source]
130
+ * @returns {Record<string, string>}
131
+ */
132
+ export function baseEnvironment(source = process.env) {
133
+ /** @type {Record<string, string>} */
134
+ const env = {};
135
+ // Look the base names up rather than filtering source keys: Windows exposes
136
+ // `Path`, not `PATH`, and `source[name]` is the case-insensitive read there.
137
+ for (const name of BASE_ENV_NAMES) {
138
+ const value = source[name];
139
+ if (value !== undefined) env[name] = value;
140
+ }
141
+ for (const [name, value] of Object.entries(source)) {
142
+ if (value !== undefined && name.startsWith("LC_")) env[name] = value;
143
+ }
144
+ return env;
145
+ }
146
+
147
+ /**
148
+ * The non-base allowlisted names present in `source`, with their controller
149
+ * values. This is the overlay a caller hands the gate: the gate already holds
150
+ * the base set in its own environment, so it only needs what the runtime adds
151
+ * on top.
152
+ *
153
+ * @param {{harness?: string, config?: Record<string, unknown>, envPassthrough?: string[]}|null|undefined} runtime
154
+ * @param {{envPassthrough?: string[]}|null|undefined} [contract]
155
+ * @param {Record<string, string|undefined>} [source]
156
+ * @returns {Record<string, string>}
157
+ */
158
+ export function passthroughEnvironment(runtime, contract, source = process.env) {
159
+ /** @type {Record<string, string>} */
160
+ const env = {};
161
+ for (const name of declaredEnvironmentNames(runtime, contract)) {
162
+ if (isBaseName(name)) continue;
163
+ const value = source[name];
164
+ if (value !== undefined) env[name] = value;
165
+ }
166
+ return env;
167
+ }
168
+
169
+ /**
170
+ * The complete environment a worker, judge, or availability probe receives.
171
+ *
172
+ * @param {{harness?: string, config?: Record<string, unknown>, envPassthrough?: string[]}|null|undefined} runtime
173
+ * @param {{envPassthrough?: string[]}|null|undefined} [contract]
174
+ * @param {Record<string, string|undefined>} [source]
175
+ * @returns {Record<string, string>}
176
+ */
177
+ export function workerEnvironment(runtime, contract, source = process.env) {
178
+ return { ...baseEnvironment(source), ...passthroughEnvironment(runtime, contract, source) };
179
+ }
@@ -1,5 +1,16 @@
1
1
  import { normalizeAgyResult, parseVersion } from "../protocol.mjs";
2
2
 
3
+ /**
4
+ * The environment names the agy adapter reads to authenticate and configure the
5
+ * CLI. agy signs in through its own CLI configuration, so the only name read
6
+ * here is the binary override `executable()` honours. Values never travel here.
7
+ *
8
+ * @type {readonly string[]}
9
+ */
10
+ export const declaredEnvironment = Object.freeze([
11
+ "FABERUN_AGY_BIN",
12
+ ]);
13
+
3
14
  /**
4
15
  * @type {import("../index.mjs").HarnessAdapter}
5
16
  */
@@ -5,6 +5,14 @@ import { canonicalProvider } from "../contract/provider.mjs";
5
5
  import { errorMessage } from "../util.mjs";
6
6
  import { ANTHROPIC_MODEL_TIERS } from "./model-tiers.mjs";
7
7
  import { spawnInvocation } from "../host/platform.mjs";
8
+ import { declaredEnvironment as claudeEnvironment } from "./claude/index.mjs";
9
+ import { declaredEnvironment as codexEnvironment } from "./codex/index.mjs";
10
+ import { declaredEnvironment as agyEnvironment } from "./agy/index.mjs";
11
+ import { declaredEnvironment as dshEnvironment } from "./dsh/index.mjs";
12
+ import { declaredEnvironment as fxEnvironment } from "./fx/index.mjs";
13
+ import { declaredEnvironment as zcodeEnvironment } from "./zcode/index.mjs";
14
+ import { declaredEnvironment as execJsonlEnvironment } from "./exec-jsonl/index.mjs";
15
+ import { declaredEnvironment as replayEnvironment } from "./replay/index.mjs";
8
16
 
9
17
  /**
10
18
  * Model catalogue report: which models each registered harness can run, the
@@ -38,6 +46,27 @@ export const MODEL_HARNESS_ORDER = Object.freeze([
38
46
  "replay",
39
47
  ]);
40
48
 
49
+ /**
50
+ * The environment names each registered adapter reads to authenticate and
51
+ * configure itself, keyed by harness in `MODEL_HARNESS_ORDER`. Each list is the
52
+ * adapter's own frozen `declaredEnvironment` export, and only bare names ever
53
+ * travel here, never values. The worker/judge environment builder unions these
54
+ * names with the base operating-system set and the runtime's env keys, so a
55
+ * name missing from this catalogue is a name the child process never receives.
56
+ *
57
+ * @type {Readonly<Record<string, readonly string[]>>}
58
+ */
59
+ export const DECLARED_HARNESS_ENVIRONMENTS = Object.freeze({
60
+ claude: claudeEnvironment,
61
+ codex: codexEnvironment,
62
+ agy: agyEnvironment,
63
+ dsh: dshEnvironment,
64
+ fx: fxEnvironment,
65
+ zcode: zcodeEnvironment,
66
+ "exec-jsonl": execJsonlEnvironment,
67
+ replay: replayEnvironment,
68
+ });
69
+
41
70
  const CLAUDE_EFFORTS = Object.freeze(["low", "medium", "high", "max"]);
42
71
  const CODEX_EFFORTS = Object.freeze(["low", "medium", "high", "xhigh"]);
43
72
  const AGY_EFFORTS = Object.freeze(["low", "medium", "high"]);
@@ -1,6 +1,18 @@
1
1
  import { normalizeClaudeResult, parseVersion } from "../protocol.mjs";
2
2
  import { hookSettings } from "../../host/tool-policy-hook.mjs";
3
3
 
4
+ /**
5
+ * The environment names the Claude adapter reads to authenticate and configure
6
+ * the CLI: the provider credential the CLI resolves for API-key sign-in and the
7
+ * binary override `executable()` honours. Values never travel here.
8
+ *
9
+ * @type {readonly string[]}
10
+ */
11
+ export const declaredEnvironment = Object.freeze([
12
+ "ANTHROPIC_API_KEY",
13
+ "FABERUN_CLAUDE_BIN",
14
+ ]);
15
+
4
16
  /** Built-in tools a closed-packet worker needs; every other tool is preamble. */
5
17
  export const DEFAULT_CLAUDE_TOOLS = ["Read", "Edit", "Write", "Bash", "Glob", "Grep"];
6
18
 
@@ -26,6 +38,88 @@ function claudePreambleArgs(runtime) {
26
38
  ];
27
39
  }
28
40
 
41
+ /**
42
+ * The envelope this adapter returns: the shared provider envelope plus the
43
+ * top-level reset instant a session limit names. The field has to live here
44
+ * rather than in `error` so that `exhaustedUntilOf` keeps parsing the sentence
45
+ * against the caller's clock.
46
+ *
47
+ * @typedef {import("../index.mjs").ProviderEnvelope & {resetAt?: string}} ClaudeEnvelope
48
+ */
49
+
50
+ /**
51
+ * The reset sentence a Claude quota stop writes into its error message, e.g.
52
+ * "You've hit your session limit · resets 6:40pm (America/Sao_Paulo)". The
53
+ * wall-clock form names a time and a zone but no date; an absolute form names
54
+ * the instant directly ("Your limit will reset at 2026-09-08 20:30:00").
55
+ */
56
+ const SESSION_RESET_SENTENCE = /resets?(?:\s+at)?\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\s*\(([A-Za-z_]+(?:\/[A-Za-z0-9_+-]+)+|UTC|GMT)\)/iu;
57
+ const ABSOLUTE_RESET_SENTENCE = /reset(?:s| at| on)?\s+(\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}(?:Z|[+-]\d{2}:?\d{2})?)/iu;
58
+
59
+ /**
60
+ * The instant a Claude quota stop names, or null when its message names none.
61
+ *
62
+ * The generic availability classifier (`harnesses/index.mjs`'s
63
+ * `exhaustedUntilOf`) parses the same sentence for the durable refusal store,
64
+ * but the node's own wait decision (`engine/backoff.mjs`'s
65
+ * `quotaResetSchedule`) reads only a structured `resetAt`. This adapter is the
66
+ * protocol boundary and cannot import `harnesses/index.mjs` -- that module
67
+ * imports this one at load -- so the sentence is parsed here. A zone the
68
+ * runtime does not know names no instant, and the failover edge is taken as
69
+ * before.
70
+ *
71
+ * @param {string} message
72
+ * @param {number} [now] the instant a wall-clock sentence is read against
73
+ * @returns {string|null}
74
+ */
75
+ function sessionResetAt(message, now = Date.now()) {
76
+ const absolute = ABSOLUTE_RESET_SENTENCE.exec(message);
77
+ if (absolute) {
78
+ const value = absolute[1];
79
+ const zoned = value.includes("T") || /(?:Z|[+-]\d{2}:?\d{2})$/u.test(value) ? value : `${value.replace(" ", "T")}Z`;
80
+ const parsed = Date.parse(zoned);
81
+ if (Number.isFinite(parsed)) return new Date(parsed).toISOString();
82
+ }
83
+ const match = SESSION_RESET_SENTENCE.exec(message);
84
+ if (!match) return null;
85
+ let hour = Number(match[1]);
86
+ const minute = Number(match[2] ?? "0");
87
+ const meridiem = match[3]?.toLowerCase();
88
+ if (meridiem === "pm" && hour < 12) hour += 12;
89
+ if (meridiem === "am" && hour === 12) hour = 0;
90
+ if (hour > 23 || minute > 59) return null;
91
+ /** @type {Intl.DateTimeFormatPart[]} */
92
+ let parts;
93
+ try {
94
+ parts = new Intl.DateTimeFormat("en-US", { timeZone: match[4], hourCycle: "h23", year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit" }).formatToParts(new Date(now));
95
+ } catch {
96
+ // An unknown zone name is not a reset time; the caller falls back to failover.
97
+ return null;
98
+ }
99
+ const at = (/** @type {Intl.DateTimeFormatPartTypes} */ type) => Number(parts.find((part) => part.type === type)?.value);
100
+ const offset = Date.UTC(at("year"), at("month") - 1, at("day"), at("hour") % 24, at("minute"), at("second")) - Math.floor(now / 1000) * 1000;
101
+ let target = Date.UTC(at("year"), at("month") - 1, at("day"), hour, minute) - offset;
102
+ if (target <= now) target += 86_400_000;
103
+ return new Date(target).toISOString();
104
+ }
105
+
106
+ /**
107
+ * Thread a session-limit reset into the envelope so the node can wait for it.
108
+ * The instant rides the envelope's top-level `resetAt`, the field the node's
109
+ * wait decision (`engine/backoff.mjs`'s `quotaResetSchedule`) reads. It is not
110
+ * written into `error.resetAt`/`exhaustedUntil` on purpose: `exhaustedUntilOf`
111
+ * must keep parsing the sentence against the clock its caller passes, so a test
112
+ * or a replayed envelope can re-read the same message at a chosen instant.
113
+ *
114
+ * @param {ClaudeEnvelope} envelope
115
+ * @returns {ClaudeEnvelope}
116
+ */
117
+ function withSessionReset(envelope) {
118
+ if (envelope.status !== "exhausted" || envelope.resetAt) return envelope;
119
+ const resetAt = sessionResetAt(String(envelope.error?.message ?? ""));
120
+ return resetAt ? { ...envelope, resetAt } : envelope;
121
+ }
122
+
29
123
  /**
30
124
  * The environment one runtime's CLI runs under when its contract points it at
31
125
  * an Anthropic-compatible endpoint other than Anthropic's own, e.g. GLM on
@@ -174,7 +268,10 @@ export const claudeHarness = {
174
268
  return declaredBaseUrl(runtime) === null;
175
269
  },
176
270
 
177
- normalize: normalizeClaudeResult,
271
+ /** @param {string} stdout @param {number|null} exitCode @param {string|null} signal @param {import("../index.mjs").NormalizeOptions} [options] @returns {import("../index.mjs").ProviderEnvelope} */
272
+ normalize(stdout, exitCode, signal, options = {}) {
273
+ return withSessionReset(normalizeClaudeResult(stdout, exitCode, signal, options));
274
+ },
178
275
  };
179
276
 
180
277
  export const harness = claudeHarness;
@@ -1,5 +1,20 @@
1
1
  import { normalizeCodexResult, parseVersion, toml } from "../protocol.mjs";
2
2
 
3
+ /**
4
+ * The environment names the Codex adapter reads to authenticate and configure
5
+ * the CLI: the provider credential for API-key sign-in, the config home
6
+ * `codexHome()` reads when it locates the session file its usage windows come
7
+ * from, and the binary override `executable()` honours. Values never travel
8
+ * here.
9
+ *
10
+ * @type {readonly string[]}
11
+ */
12
+ export const declaredEnvironment = Object.freeze([
13
+ "CODEX_HOME",
14
+ "FABERUN_CODEX_BIN",
15
+ "OPENAI_API_KEY",
16
+ ]);
17
+
3
18
  /**
4
19
  * Bound the Codex harness preamble: a closed-packet worker or a read-only judge
5
20
  * needs the shell and patch tools, not browser, computer-use, app or sub-agent