faberun 0.26.3 → 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 (88) hide show
  1. package/README.md +11 -4
  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 +6 -1
  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 +68 -8
  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 +6 -2
  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 +61 -13
  42. package/src/engine/worker-env.mjs +179 -0
  43. package/src/harnesses/agy/index.mjs +11 -0
  44. package/src/harnesses/catalogue.mjs +54 -2
  45. package/src/harnesses/claude/index.mjs +177 -2
  46. package/src/harnesses/codex/index.mjs +15 -0
  47. package/src/harnesses/dsh/index.mjs +18 -116
  48. package/src/harnesses/dsh/runner.mjs +1 -17
  49. package/src/harnesses/exec-jsonl/index.mjs +12 -0
  50. package/src/harnesses/fx/home.mjs +57 -0
  51. package/src/harnesses/fx/index.mjs +137 -0
  52. package/src/harnesses/fx/native/build.zig +20 -0
  53. package/src/harnesses/fx/native/build.zig.zon +7 -0
  54. package/src/harnesses/fx/native/home.zig +85 -0
  55. package/src/harnesses/fx/native/main.zig +503 -0
  56. package/src/harnesses/fx/native/permissions.zig +51 -0
  57. package/src/harnesses/fx/native/relay.zig +253 -0
  58. package/src/harnesses/fx/native/usage.zig +112 -0
  59. package/src/harnesses/fx/permissions.mjs +35 -0
  60. package/src/harnesses/fx/runner.mjs +350 -0
  61. package/src/harnesses/fx/usage-proxy.mjs +176 -0
  62. package/src/harnesses/index.mjs +29 -14
  63. package/src/harnesses/replay/index.mjs +12 -0
  64. package/src/harnesses/runner-io.mjs +25 -0
  65. package/src/harnesses/runner-transcript.mjs +127 -0
  66. package/src/harnesses/session-metrics.mjs +38 -13
  67. package/src/harnesses/zcode/index.mjs +19 -0
  68. package/src/host/config.mjs +8 -2
  69. package/src/host/environment-report.mjs +85 -0
  70. package/src/host/home.mjs +27 -0
  71. package/src/host/install-registry.mjs +511 -0
  72. package/src/host/preflight.mjs +34 -10
  73. package/src/host/uninstall-roots.mjs +196 -0
  74. package/src/plan/proof-check.mjs +15 -0
  75. package/src/plan/repo-facts.mjs +95 -11
  76. package/src/plan/rounds.mjs +27 -0
  77. package/src/repo/declared-paths.mjs +74 -6
  78. package/src/repo/integrate.mjs +1 -1
  79. package/src/repo/scope-closure.mjs +59 -0
  80. package/src/repo/signal-block.mjs +26 -0
  81. package/src/repo/signal.mjs +9 -24
  82. package/src/repo/workspace.mjs +189 -14
  83. package/src/report/campaign-brief.mjs +4 -0
  84. package/src/report/message.mjs +37 -6
  85. package/src/report/progress.mjs +13 -2
  86. package/src/run/paths.mjs +14 -1
  87. package/src/run/usage.mjs +66 -1
  88. 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
@@ -56,6 +56,18 @@ export { exhaustedUntilOf, normalizeProviderAvailability } from "../harnesses/in
56
56
  * @type {Readonly<Record<string, DiscoveryRuntime>>}
57
57
  */
58
58
  export const DISCOVERY_RUNTIME_DEFINITIONS = Object.freeze({
59
+ // Every vendor this harness reaches is declared ahead of the older harness
60
+ // for the same models, so fx wins each tie-break. Measured on the
61
+ // parseDuration benchmark (2026-09-25): 22-27 MB per fx worker against
62
+ // 362-422 MB for dsh, with the same scope and hidden-test results.
63
+ "fx-deepseek": {
64
+ harness: "fx",
65
+ model: "deepseek-flash",
66
+ vendor: "deepseek",
67
+ config: { "api_key.env_key": "DEEPSEEK_API_KEY" },
68
+ tier: 1,
69
+ costRank: 1,
70
+ },
59
71
  // `dsh` defaults no vendor and no provider route, so both are declared here
60
72
  // or nothing can build a command from this entry.
61
73
  "dsh-deepseek": {
@@ -66,6 +78,32 @@ export const DISCOVERY_RUNTIME_DEFINITIONS = Object.freeze({
66
78
  tier: 1,
67
79
  costRank: 1,
68
80
  },
81
+ // GLM 5.3 on fx through the Z.ai Coding Plan's Chat Completions endpoint.
82
+ // The operator chose this default knowing the plan's usage policy lists
83
+ // Claude Code and ZCode, not fx, and restricts or bans an account for
84
+ // detected third-party use; `claude-glm` below is the supported route.
85
+ // Measured 2026-09-26: the tiny-text campaign with a GLM worker and a
86
+ // DeepSeek judge, both on fx, peaked at 283 MB.
87
+ "fx-glm": {
88
+ harness: "fx",
89
+ model: "glm-5.3",
90
+ vendor: "zhipu",
91
+ config: { base_url: "https://api.z.ai/api/coding/paas/v4", "api_key.env_key": "ZAI_API_KEY", context_window: 200_000 },
92
+ tier: 1,
93
+ costRank: 1,
94
+ },
95
+ // GLM through Claude Code, which the Z.ai Coding Plan lists as a supported
96
+ // tool. Declared before zcode-glm so it wins the tie-break: measured
97
+ // 2026-09-25 on one review prompt, 384-392 MB per turn against 0.9-1.0 GB
98
+ // for zcode, and the tiny-text campaign peaked at 865 MB against 1,575 MB.
99
+ "claude-glm": {
100
+ harness: "claude",
101
+ model: "glm-5.3",
102
+ vendor: "zhipu",
103
+ config: { base_url: "https://api.z.ai/api/anthropic", "auth_token.env_key": "ZAI_API_KEY" },
104
+ tier: 1,
105
+ costRank: 1,
106
+ },
69
107
  // The flash tier, not the unsuffixed pro: measured 2026-09-24 in the judge
70
108
  // canary, glm-5.3 recalled 0.79 against glm-5.3-flash's 0.85 at about nine
71
109
  // times the price, which the owner confirmed matches the public benchmarks.
@@ -78,15 +116,19 @@ export const DISCOVERY_RUNTIME_DEFINITIONS = Object.freeze({
78
116
  costRank: 1,
79
117
  },
80
118
  "agy-gemini": { harness: "agy", model: "gemini-3.8-flash-low", vendor: "google", tier: 1, costRank: 1 },
119
+ // GPT on fx through fx's own ChatGPT login, which fx-faberun reads from the
120
+ // operator's profile (`FX_AUTH_HOME`). Measured 2026-09-26: one review turn
121
+ // peaked at 35-47 MB for the runner and fx together.
122
+ "fx-gpt": { harness: "fx", model: "gpt-5.6-sol", vendor: "openai", config: { provider: "codex" }, tier: 2, costRank: 2 },
81
123
  // Three codex rows, because the harness declares three models and an
82
124
  // account is entitled to only some of them: measured 2026-09-21 on the
83
125
  // owner's ChatGPT account, plain `gpt-5.6` answers HTTP 400 while
84
126
  // `gpt-5.6-sol` answers normally. One row meant `setup` could offer only the
85
127
  // model that account cannot use, and the operator's only way out was to hand
86
- // every contract its own catalogue through `--runtimes`. Declaration order is
87
- // unchanged, so the composed default is still `codex-gpt`: which of the three
88
- // an account can reach is not something this file can know, and the live
89
- // preflight is what reports it per id.
128
+ // every contract its own catalogue through `--runtimes`. `fx-gpt` above is
129
+ // declared first, so it is the composed tier-2 default; among these three,
130
+ // which an account can reach is not something this file can know, and the
131
+ // live preflight is what reports it per id.
90
132
  "codex-gpt": { harness: "codex", model: "gpt-5.6", vendor: "openai", tier: 2, costRank: 2 },
91
133
  "codex-sol": { harness: "codex", model: "gpt-5.6-sol", vendor: "openai", tier: 2, costRank: 2 },
92
134
  "codex-luna": { harness: "codex", model: "gpt-5.6-luna", vendor: "openai", tier: 2, costRank: 2 },
@@ -134,10 +176,12 @@ export function availableCandidates(runtimes, availability = {}) {
134
176
  * copied exactly; callers persist the returned pair in run state.
135
177
  *
136
178
  * An optional `options.config` narrows the candidate set to the harnesses the
137
- * operator enabled and prefers its named worker and judge. An empty narrowed
138
- * set falls back to the unrestricted candidates and is reported through
139
- * `options.onWarning`; config never overrides an explicit node, gate or
140
- * 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.
141
185
  *
142
186
  * `options.listJudge` (R18) is asked for an omitted judge before the single
143
187
  * `config.judge` preference and the strongest-candidate default: it is the
@@ -152,7 +196,7 @@ export function availableCandidates(runtimes, availability = {}) {
152
196
  * @param {RuntimeContract} contract
153
197
  * @param {Record<string, RuntimeAvailability>} availability
154
198
  * @param {ComposeOptions} [options]
155
- * @returns {Record<string, {worker: string, judge: string}>}
199
+ * @returns {Record<string, {worker: string, judge: string, judgeIndependence?: "same-vendor"}>}
156
200
  */
157
201
  export function composeAssignments(contract, availability = {}, options = {}) {
158
202
  const allCandidates = availableCandidates(contract.runtimes, availability);
@@ -164,7 +208,7 @@ export function composeAssignments(contract, availability = {}, options = {}) {
164
208
  if (restricted.length) candidates = restricted;
165
209
  else if (allCandidates.length) options.onWarning?.(`config harnesses · none of ${config.harnesses.join(", ")} is available · using all available runtimes`);
166
210
  }
167
- /** @type {Record<string, {worker: string, judge: string}>} */
211
+ /** @type {Record<string, {worker: string, judge: string, judgeIndependence?: "same-vendor"}>} */
168
212
  const assignments = {};
169
213
  for (const node of contract.nodes ?? []) {
170
214
  const workerOmitted = node.runtime === undefined && contract.runtimeDefaults?.worker === undefined;
@@ -193,7 +237,11 @@ export function composeAssignments(contract, availability = {}, options = {}) {
193
237
  // R20: in same-vendor mode a judge of the worker's own provider is
194
238
  // admissible when it is another model at an equal or higher declared tier;
195
239
  // the strongest such candidate is the default only when no other vendor has one.
196
- 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;
197
245
  const judge = declaredJudge
198
246
  ?? judgeListPick?.chosen
199
247
  ?? (preferredJudge && effectiveProvider(preferredJudge.runtime) !== workerProvider ? preferredJudge.id : undefined)
@@ -209,7 +257,7 @@ export function composeAssignments(contract, availability = {}, options = {}) {
209
257
  const refusal = sameVendorTierRefusal(/** @type {{harness: string, model: string}} */ (workerRuntime), /** @type {{harness: string, model: string}} */ (judgeRuntime));
210
258
  if (refusal) throw new Error(`runtime_assignment_judge_unavailable: ${refusal.message} for node ${node.id}`);
211
259
  }
212
- assignments[node.id] = { worker, judge: judge ?? worker };
260
+ assignments[node.id] = { worker, judge: judge ?? worker, ...(judgeIndependence === undefined ? {} : { judgeIndependence }) };
213
261
  }
214
262
  return assignments;
215
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
  */