faberun 0.27.0 → 0.29.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 (83) 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 +31 -4
  16. package/src/cli/manual.mjs +24 -8
  17. package/src/cli/plan.mjs +56 -5
  18. package/src/cli/setup.mjs +5 -0
  19. package/src/cli/skills.mjs +18 -7
  20. package/src/cli/uninstall.mjs +542 -0
  21. package/src/cli/walkthrough.mjs +462 -0
  22. package/src/cli.mjs +72 -8
  23. package/src/contract/articles.mjs +30 -0
  24. package/src/contract/definition-of-done.mjs +164 -0
  25. package/src/contract/index.mjs +8 -5
  26. package/src/contract/judge-independence.mjs +18 -0
  27. package/src/contract/runtime.mjs +20 -6
  28. package/src/contract/scope-findings.mjs +120 -0
  29. package/src/contract/snapshot.mjs +19 -5
  30. package/src/contract/task-packet.mjs +49 -1
  31. package/src/contract/worker-result.mjs +37 -11
  32. package/src/engine/assignment.mjs +45 -0
  33. package/src/engine/detach.mjs +18 -0
  34. package/src/engine/gate.mjs +10 -2
  35. package/src/engine/judge-gate.mjs +24 -9
  36. package/src/engine/live-preflight.mjs +117 -18
  37. package/src/engine/prompts.mjs +58 -4
  38. package/src/engine/resume.mjs +119 -12
  39. package/src/engine/retry.mjs +11 -11
  40. package/src/engine/run-command.mjs +80 -4
  41. package/src/engine/run-identity.mjs +27 -14
  42. package/src/engine/runtime-discovery.mjs +15 -9
  43. package/src/engine/scheduler.mjs +8 -1
  44. package/src/engine/worker-env.mjs +179 -0
  45. package/src/harnesses/agy/index.mjs +11 -0
  46. package/src/harnesses/catalogue.mjs +29 -0
  47. package/src/harnesses/claude/index.mjs +98 -1
  48. package/src/harnesses/codex/index.mjs +15 -0
  49. package/src/harnesses/dsh/index.mjs +14 -0
  50. package/src/harnesses/exec-jsonl/index.mjs +12 -0
  51. package/src/harnesses/fx/index.mjs +13 -0
  52. package/src/harnesses/index.mjs +21 -10
  53. package/src/harnesses/replay/index.mjs +12 -0
  54. package/src/harnesses/zcode/index.mjs +19 -0
  55. package/src/host/config.mjs +8 -2
  56. package/src/host/environment-report.mjs +85 -0
  57. package/src/host/home.mjs +27 -0
  58. package/src/host/install-registry.mjs +511 -0
  59. package/src/host/preflight.mjs +32 -9
  60. package/src/host/uninstall-roots.mjs +196 -0
  61. package/src/plan/freeze.mjs +2 -0
  62. package/src/plan/pipeline.mjs +56 -4
  63. package/src/plan/progress.mjs +101 -0
  64. package/src/plan/proof-check.mjs +27 -0
  65. package/src/plan/proof-scope.mjs +1 -1
  66. package/src/plan/repo-facts.mjs +129 -14
  67. package/src/plan/rounds.mjs +27 -0
  68. package/src/plan/spec.mjs +82 -6
  69. package/src/plan/template.mjs +9 -0
  70. package/src/repo/declared-paths.mjs +74 -6
  71. package/src/repo/integrate.mjs +1 -1
  72. package/src/repo/scope-closure.mjs +59 -0
  73. package/src/repo/signal-block.mjs +26 -0
  74. package/src/repo/signal.mjs +9 -24
  75. package/src/repo/workspace.mjs +189 -14
  76. package/src/repo/worktree-prune.mjs +13 -5
  77. package/src/repo/worktree.mjs +54 -0
  78. package/src/report/campaign-brief.mjs +4 -0
  79. package/src/report/message.mjs +37 -6
  80. package/src/report/progress.mjs +13 -2
  81. package/src/run/paths.mjs +14 -1
  82. package/src/run/usage.mjs +66 -1
  83. package/src/util.mjs +18 -2
@@ -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
@@ -3,6 +3,20 @@ import { fileURLToPath } from "node:url";
3
3
  import { parseVersion } from "../protocol.mjs";
4
4
  import { normalizeRunnerTranscript, runnerEnvironmentOverlay, withSchema } from "../runner-transcript.mjs";
5
5
 
6
+ /**
7
+ * The environment names the dsh adapter reads to authenticate and configure the
8
+ * harness: the provider credential the dsh CLI resolves for the declared
9
+ * provider and the binary override `executable()` honours. The environment
10
+ * overlay's `GIT_CONFIG_*` scrub is a generic pass over ambient keys, not a
11
+ * declared dependency. Values never travel here.
12
+ *
13
+ * @type {readonly string[]}
14
+ */
15
+ export const declaredEnvironment = Object.freeze([
16
+ "DEEPSEEK_API_KEY",
17
+ "FABERUN_DSH_BIN",
18
+ ]);
19
+
6
20
  const HERE = dirname(fileURLToPath(import.meta.url));
7
21
 
8
22
  /**
@@ -31,6 +31,18 @@ import { finite } from "../../util.mjs";
31
31
 
32
32
  export { HARNESS_OUTPUT_LIMIT_BYTES } from "../protocol.mjs";
33
33
 
34
+ /**
35
+ * The environment names the exec-jsonl adapter reads to authenticate and
36
+ * configure the wrapper: only the binary override `executable()` honours.
37
+ * Credentials belong to the wrapper the contract names, so the adapter reads
38
+ * none of them. Values never travel here.
39
+ *
40
+ * @type {readonly string[]}
41
+ */
42
+ export const declaredEnvironment = Object.freeze([
43
+ "FABERUN_EXEC_JSONL_BIN",
44
+ ]);
45
+
34
46
  export const EXEC_JSONL_PROTOCOL = Object.freeze({
35
47
  schemaVersion: 1,
36
48
  requestType: "run.request",
@@ -10,6 +10,19 @@ import { fileURLToPath } from "node:url";
10
10
  import { parseVersion } from "../protocol.mjs";
11
11
  import { normalizeRunnerTranscript, runnerEnvironmentOverlay, withSchema } from "../runner-transcript.mjs";
12
12
 
13
+ /**
14
+ * The environment names this adapter reads outside the base set and the
15
+ * runtime's own `*.env_key` names: the provider key the client falls back to
16
+ * when a runtime names none, and the binary override `executable()` honours.
17
+ * Values never travel here.
18
+ *
19
+ * @type {readonly string[]}
20
+ */
21
+ export const declaredEnvironment = Object.freeze([
22
+ "DEEPSEEK_API_KEY",
23
+ "FABERUN_FX_BIN",
24
+ ]);
25
+
13
26
  const HERE = dirname(fileURLToPath(import.meta.url));
14
27
 
15
28
  /**
@@ -7,7 +7,7 @@ import { fxHarness } from "./fx/index.mjs";
7
7
  import { zcodeHarness } from "./zcode/index.mjs";
8
8
  import { execJsonlHarness } from "./exec-jsonl/index.mjs";
9
9
  import { replayHarness } from "./replay/index.mjs";
10
- import { withoutNotifyEnv } from "../notify/index.mjs";
10
+ import { passthroughEnvironment, workerEnvironment } from "../engine/worker-env.mjs";
11
11
  import { spawnInvocation } from "../host/platform.mjs";
12
12
 
13
13
  /** Current wire-contract version for runner protocol artifacts. */
@@ -72,7 +72,7 @@ const CAPABILITY_NAMES = new Set([
72
72
  * `config["replay.recording"]` for commands, and dsh requires
73
73
  * `config.provider` for the provider route every attempt runs on.
74
74
  *
75
- * @typedef {{id?: string, harness: string, model: string, reasoning?: string, sandbox?: string, permissionMode?: string, config?: Record<string, unknown>, printTimeout?: string, tools?: string[], executable?: string, args?: string[], versionArgs?: string[], maxArgvPromptBytes?: number, requiredCapabilities?: CapabilityRequirements, tier?: number|string, vendor?: string}} HarnessRuntime
75
+ * @typedef {{id?: string, harness: string, model: string, reasoning?: string, sandbox?: string, permissionMode?: string, config?: Record<string, unknown>, printTimeout?: string, tools?: string[], executable?: string, args?: string[], versionArgs?: string[], maxArgvPromptBytes?: number, requiredCapabilities?: CapabilityRequirements, envPassthrough?: string[], tier?: number|string, vendor?: string}} HarnessRuntime
76
76
  */
77
77
 
78
78
  /**
@@ -216,10 +216,14 @@ export function resolveVendor(runtime) {
216
216
 
217
217
  /**
218
218
  * Build one provider invocation. Prompt transport is explicit in the result:
219
- * stdin adapters return `input`, while argv adapters append the prompt. An
220
- * optional `env` overlay is merged over the runner environment at spawn time;
221
- * a null value removes the ambient variable. A caller-supplied `options.env`
222
- * merges over the adapter's own overlay here, once, for every harness.
219
+ * stdin adapters return `input`, while argv adapters append the prompt.
220
+ *
221
+ * The returned `env` is the runtime-specific allowlist overlay (adapter
222
+ * declarations, `*.env_key` names, and the runtime's `envPassthrough`) merged
223
+ * over the adapter's own overlay; a null value removes the ambient variable.
224
+ * The spawn gate unions it with the base operating-system names, so no other
225
+ * controller variable reaches the child. A caller-supplied `options.env`
226
+ * merges over everything here, once, for every harness.
223
227
  *
224
228
  * @param {HarnessRuntime} runtime
225
229
  * @param {string} prompt
@@ -229,6 +233,12 @@ export function resolveVendor(runtime) {
229
233
  export function providerCommand(runtime, prompt, options = {}) {
230
234
  const harness = getHarness(runtime.harness);
231
235
  const command = harness.command(runtime, prompt, options);
236
+ // The runtime-specific allowlist travels to the gate as an overlay. The gate
237
+ // unions it with the base operating-system names and nothing else, so a
238
+ // controller variable no adapter, `*.env_key`, or envPassthrough named never
239
+ // reaches the child. It is deliberately not the whole environment: the
240
+ // caller's overlay stays an overlay, and the base set stays the gate's job.
241
+ command.env = { ...passthroughEnvironment(runtime, undefined, process.env), ...command.env };
232
242
  if (options.env) command.env = { ...command.env, ...options.env };
233
243
  if (command.promptTransport === "argv") {
234
244
  const limit = runtime.maxArgvPromptBytes ?? harness.capabilities.maxArgvPromptBytes;
@@ -507,10 +517,11 @@ export function probeRuntime(runtime, options = {}) {
507
517
  const invocation = spawnInvocation(executable, args, { cwd: options.cwd });
508
518
  child = spawn(invocation.command, invocation.args, {
509
519
  cwd: options.cwd,
510
- // A worker or judge never delivers a notification; the controller does.
511
- // In this repository a worker runs the test suite, whose fixture
512
- // controllers would otherwise inherit a live transport and deliver.
513
- env: withoutNotifyEnv(process.env),
520
+ // A probe is a worker-class spawn: it receives only the base names,
521
+ // the adapter's declared names, the runtime's `*.env_key` names, and
522
+ // any envPassthrough the runtime carries. It never delivers a
523
+ // notification; the controller does.
524
+ env: workerEnvironment(runtime),
514
525
  stdio: ["ignore", "pipe", "pipe"],
515
526
  ...invocation.options,
516
527
  });
@@ -2,6 +2,18 @@ import { resolve } from "node:path";
2
2
  import { fileURLToPath } from "node:url";
3
3
  import { parseVersion } from "../protocol.mjs";
4
4
 
5
+ /**
6
+ * The environment names the replay adapter reads to authenticate and configure
7
+ * the recording: only the binary override `executable()` honours. A recording
8
+ * carries no credential of its own, and `replay/bin.mjs` reads no environment
9
+ * name at all. Values never travel here.
10
+ *
11
+ * @type {readonly string[]}
12
+ */
13
+ export const declaredEnvironment = Object.freeze([
14
+ "FABERUN_REPLAY_BIN",
15
+ ]);
16
+
5
17
  /** Envelope statuses a recording may carry. */
6
18
  const REPLAY_STATUSES = Object.freeze(new Set([
7
19
  "done",
@@ -3,6 +3,25 @@ import { homedir } from "node:os";
3
3
  import { delimiter, join } from "node:path";
4
4
  import { normalizeZcodeResult, parseVersion } from "../protocol.mjs";
5
5
 
6
+ /**
7
+ * The environment names the zcode adapter reads to authenticate and configure
8
+ * the CLI: the credential the CLI resolves (`auth_token.env_key`, default
9
+ * `ZAI_API_KEY`, with `ANTHROPIC_AUTH_TOKEN` as its fallback), the app install
10
+ * directory `zcodeBundle()` reads, and the binary override `executable()`
11
+ * honours. The `ZCODE_MODEL`, `ZCODE_BASE_URL`, `ANTHROPIC_API_KEY` and
12
+ * `ZCODE_LOG_*` names this adapter writes into the child environment are
13
+ * configuration it supplies, not dependencies it reads. Values never travel
14
+ * here.
15
+ *
16
+ * @type {readonly string[]}
17
+ */
18
+ export const declaredEnvironment = Object.freeze([
19
+ "ANTHROPIC_AUTH_TOKEN",
20
+ "FABERUN_ZCODE_APP_DIR",
21
+ "FABERUN_ZCODE_BIN",
22
+ "ZAI_API_KEY",
23
+ ]);
24
+
6
25
  /** Default Z.ai Anthropic-compatible endpoint serving GLM models. */
7
26
  const ZCODE_DEFAULT_BASE_URL = "https://api.z.ai/api/anthropic";
8
27
 
@@ -3,7 +3,9 @@
3
3
  * enabled, which runtime is the default worker and judge, the machine's own
4
4
  * ordered `judges` list (R18) -- read only when a contract declares none --
5
5
  * and its ordered `reviewers` list (R19), `faberun plan`'s own machine
6
- * default, read only when `--reviewers` names none.
6
+ * default, read only when `--reviewers` names none. Its `judgeIndependence`
7
+ * (R36) is the machine-wide same-vendor opt-in, read only when a contract
8
+ * declares none.
7
9
  *
8
10
  * It is separate from `host/home.mjs`, which owns the path, because reading a
9
11
  * config is a validation problem and writing it is an atomic-write problem;
@@ -13,8 +15,9 @@
13
15
  import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
14
16
  import { dirname } from "node:path";
15
17
  import { configPath, faberunHome } from "./home.mjs";
18
+ import { SAME_VENDOR_REVIEW_MODE } from "../contract/judge-independence.mjs";
16
19
 
17
- /** @typedef {{schemaVersion: 1, harnesses: string[], worker?: string, judge?: string, judges?: string[], reviewers?: string[], updatedAt: string}} UserConfig */
20
+ /** @typedef {{schemaVersion: 1, harnesses: string[], worker?: string, judge?: string, judges?: string[], reviewers?: string[], judgeIndependence?: "same-vendor", updatedAt: string}} UserConfig */
18
21
 
19
22
  /** Paths already reported malformed, so a process that reads twice warns once. */
20
23
  const warned = new Set();
@@ -88,5 +91,8 @@ function isUserConfig(value) {
88
91
  if (record.judge !== undefined && typeof record.judge !== "string") return false;
89
92
  if (record.judges !== undefined && (!Array.isArray(record.judges) || !record.judges.every((id) => typeof id === "string"))) return false;
90
93
  if (record.reviewers !== undefined && (!Array.isArray(record.reviewers) || !record.reviewers.every((id) => typeof id === "string"))) return false;
94
+ // The machine's same-vendor opt-in shares the contract field's single
95
+ // accepted value; anything else is malformed, not silently ignored.
96
+ if (record.judgeIndependence !== undefined && record.judgeIndependence !== SAME_VENDOR_REVIEW_MODE) return false;
91
97
  return typeof record.updatedAt === "string";
92
98
  }