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
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The process half every Faberun harness runner shares: one prompt read from
3
+ * stdin, one JSONL transcript written to fd 1. Kept apart from
4
+ * `runner-transcript.mjs` because that module runs inside the controller and
5
+ * this one inside the spawned runner process.
6
+ */
7
+
8
+ import { writeSync } from "node:fs";
9
+
10
+ /**
11
+ * Whole-line writes to fd 1: `process.exit` cannot lose an unfinished write.
12
+ *
13
+ * @param {Record<string, unknown>} event
14
+ */
15
+ export function emit(event) {
16
+ const line = Buffer.from(`${JSON.stringify(event)}\n`, "utf8");
17
+ for (let written = 0; written < line.length; ) written += writeSync(1, line, written);
18
+ }
19
+
20
+ /** @returns {Promise<string>} */
21
+ export async function readPrompt() {
22
+ const chunks = [];
23
+ for await (const chunk of process.stdin) chunks.push(chunk);
24
+ return Buffer.concat(chunks).toString("utf8");
25
+ }
@@ -0,0 +1,127 @@
1
+ /**
2
+ * The adapter half of Faberun's own harness clients. `dsh` and `fx` are
3
+ * driven by a runner this repository ships (a JSON-RPC client per harness),
4
+ * and both runners write the same transcript: `<prefix>.started`,
5
+ * `<prefix>.tool`, `<prefix>.message`, then exactly one `<prefix>.completed`
6
+ * or `<prefix>.failed` carrying the session usage. Folding that transcript
7
+ * into an envelope is one job whatever harness sits behind the runner, so it
8
+ * lives here instead of in either adapter.
9
+ */
10
+
11
+ import { canonicalUsage, extractJson, failed, isQuotaText, isVerdictCandidate, parseJsonLines } from "./protocol.mjs";
12
+
13
+ /**
14
+ * @param {string} prefix the runner's event namespace, `dsh` or `fx`
15
+ * @param {string} stdout
16
+ * @param {number|null} exitCode
17
+ * @param {string|null} signal
18
+ * @param {import("./index.mjs").NormalizeOptions} [options]
19
+ * @returns {import("./index.mjs").ProviderEnvelope}
20
+ */
21
+ export function normalizeRunnerTranscript(prefix, stdout, exitCode, signal, options = {}) {
22
+ if (signal) return failed("canceled", `provider ended after ${signal}`, "canceled");
23
+ let events;
24
+ try {
25
+ events = parseJsonLines(stdout, prefix);
26
+ } catch (error) {
27
+ return failed("invalid_protocol", error instanceof Error ? error.message : String(error));
28
+ }
29
+ const terminal = events.findLast((event) => event.type === `${prefix}.completed` || event.type === `${prefix}.failed`);
30
+ if (!terminal) {
31
+ const detail = options.stderr?.trim();
32
+ return failed(
33
+ "incomplete_stream",
34
+ `${prefix} emitted no terminal event${detail ? `: ${detail.slice(-512)}` : ""}${exitCode === null ? "" : ` (exit ${exitCode})`}`,
35
+ );
36
+ }
37
+ const usage = canonicalUsage(terminal.usage);
38
+ if (terminal.type === `${prefix}.completed`) {
39
+ const text = typeof terminal.result === "string" ? terminal.result : null;
40
+ const result = options.preferStructured ? extractJson(text) ?? text : text;
41
+ const verdicts = options.preferStructured
42
+ ? events.filter((event) => event.type === `${prefix}.message` && isVerdictCandidate(event.text)).length
43
+ : null;
44
+ return {
45
+ status: result?.trim() ? "done" : "no-op",
46
+ result,
47
+ continuationId: null,
48
+ usage,
49
+ costUsd: null,
50
+ error: null,
51
+ ...(verdicts === null ? {} : { judgeCandidates: verdicts }),
52
+ };
53
+ }
54
+ const error = terminal.error && typeof terminal.error === "object" ? /** @type {Record<string, unknown>} */ (terminal.error) : {};
55
+ const kind = typeof terminal.kind === "string" ? terminal.kind : "error";
56
+ const code = typeof error.code === "string" && error.code ? error.code : kind;
57
+ const message = typeof error.message === "string" && error.message ? error.message : code;
58
+ const resetAt = resetTimestamp(error.retryAfterMs);
59
+ return {
60
+ status: statusFor(kind, `${code} ${message}`),
61
+ result: null,
62
+ continuationId: null,
63
+ usage,
64
+ costUsd: null,
65
+ error: { code, message, ...(resetAt ? { resetAt } : {}) },
66
+ ...(resetAt ? { exhaustedUntil: resetAt } : {}),
67
+ };
68
+ }
69
+
70
+ /**
71
+ * Append the output schema the judge prompt refers to. Codex and Claude receive
72
+ * it through a native flag; neither runner-driven harness has one, so it
73
+ * travels in the prompt.
74
+ *
75
+ * @param {string} prompt
76
+ * @param {object|undefined} schema
77
+ * @returns {string}
78
+ */
79
+ export function withSchema(prompt, schema) {
80
+ if (!schema) return prompt;
81
+ return `${prompt}\n\nOutput schema (the JSON object must validate against it):\n${JSON.stringify(schema)}`;
82
+ }
83
+
84
+ /**
85
+ * The environment overlay every runner turn spawns with. The Claude Code shell
86
+ * exports `GIT_CONFIG_COUNT` with the VALUE half of each `GIT_CONFIG_{KEY,VALUE}_<n>`
87
+ * pair but not the key half, so `git init` inside the worker fails with status
88
+ * 128; the overlay removes the whole family. `GIT_TERMINAL_PROMPT=0` takes over
89
+ * the prompting that family was for. A null value removes the ambient variable
90
+ * when the gate merges the overlay over the runner environment.
91
+ *
92
+ * @param {NodeJS.ProcessEnv} env
93
+ * @returns {Record<string, string|null>}
94
+ */
95
+ export function runnerEnvironmentOverlay(env) {
96
+ /** @type {Record<string, string|null>} */
97
+ const overlay = { GIT_TERMINAL_PROMPT: "0" };
98
+ for (const key of Object.keys(env)) {
99
+ if (key === "GIT_CONFIG_COUNT" || /^GIT_CONFIG_(?:KEY|VALUE)_\d+$/u.test(key)) overlay[key] = null;
100
+ }
101
+ return overlay;
102
+ }
103
+
104
+ /**
105
+ * @param {string} kind
106
+ * @param {string} text
107
+ * @returns {"done"|"no-op"|"blocked"|"failed"|"exhausted"|"stalled"|"canceled"}
108
+ */
109
+ function statusFor(kind, text) {
110
+ if (kind === "aborted") return "canceled";
111
+ if (kind === "blocked") return "blocked";
112
+ if (isQuotaText(text) || /insufficient balance/iu.test(text)) return "exhausted";
113
+ if (/permission|approval|sandbox/iu.test(text)) return "blocked";
114
+ return "failed";
115
+ }
116
+
117
+ /**
118
+ * Turn the harness's relative retry hint into the absolute instant the
119
+ * controller needs; without one it takes the failover edge instead of waiting.
120
+ *
121
+ * @param {unknown} retryAfterMs
122
+ * @returns {string|null}
123
+ */
124
+ function resetTimestamp(retryAfterMs) {
125
+ if (typeof retryAfterMs !== "number" || !Number.isFinite(retryAfterMs) || retryAfterMs <= 0) return null;
126
+ return new Date(Date.now() + retryAfterMs).toISOString();
127
+ }
@@ -26,7 +26,29 @@ import { finite } from "../util.mjs";
26
26
  */
27
27
 
28
28
  /** The harnesses whose streams carry usage per provider request; the others meter turns and tool calls only. */
29
- const PER_REQUEST_USAGE = new Set(["claude", "dsh", "agy", "exec-jsonl", "replay"]);
29
+ const PER_REQUEST_USAGE = new Set(["claude", "dsh", "fx", "agy", "exec-jsonl", "replay"]);
30
+
31
+ /**
32
+ * The transcripts Faberun's own harness clients write (`runner-transcript.mjs`).
33
+ * `usage` names the event that carries one provider request's usage: dsh
34
+ * attaches it to each assistant message, while fx's relay reports it per HTTP
35
+ * request, apart from the message text.
36
+ */
37
+ const RUNNER_TRANSCRIPTS = Object.freeze({
38
+ dsh: Object.freeze({ usage: "dsh.message", tool: "dsh.tool", completed: "dsh.completed", failed: "dsh.failed" }),
39
+ fx: Object.freeze({ usage: "fx.request", tool: "fx.tool", completed: "fx.completed", failed: "fx.failed" }),
40
+ });
41
+
42
+ /** @param {string} harness */
43
+ function runnerTranscript(harness) {
44
+ return Object.hasOwn(RUNNER_TRANSCRIPTS, harness) ? RUNNER_TRANSCRIPTS[/** @type {"dsh"|"fx"} */ (harness)] : null;
45
+ }
46
+
47
+ /** @param {string} harness @param {unknown} type */
48
+ function isRunnerTerminal(harness, type) {
49
+ const transcript = runnerTranscript(harness);
50
+ return transcript !== null && (type === transcript.completed || type === transcript.failed);
51
+ }
30
52
 
31
53
  /**
32
54
  * Best-effort input-token meter over a still-growing transcript. The
@@ -90,15 +112,16 @@ export function liveUsage(harness, stdout) {
90
112
  cacheReadInputTokens: null,
91
113
  };
92
114
  }
93
- if (harness === "dsh") {
94
- // The terminal event carries the harness's own session total; before it
95
- // lands, sum the per-message usage the runner forwards.
96
- const terminal = events.findLast((event) => event?.type === "dsh.completed" || event?.type === "dsh.failed");
115
+ const transcript = runnerTranscript(harness);
116
+ if (transcript) {
117
+ // The terminal event carries the session total; before it lands, sum the
118
+ // per-request usage the runner forwards.
119
+ const terminal = events.findLast((event) => event?.type === transcript.completed || event?.type === transcript.failed);
97
120
  const terminalUsage = terminal ? canonicalUsage(terminal.usage) : null;
98
121
  if (terminalUsage && terminalUsage.inputTokens !== null) {
99
122
  return { inputTokens: terminalUsage.inputTokens, cacheReadInputTokens: terminalUsage.cacheReadInputTokens };
100
123
  }
101
- return sumRequestUsage(events.filter((event) => event?.type === "dsh.message").map((event) => event.usage));
124
+ return sumRequestUsage(events.filter((event) => event?.type === transcript.usage).map((event) => event.usage));
102
125
  }
103
126
  if (harness === "agy") {
104
127
  const resultRecord = events.map((event) => agyResult(event)).findLast((result) => result !== null);
@@ -394,7 +417,7 @@ export class SessionMetricsParser {
394
417
  }
395
418
  } else if (this.harness === "claude" && record.type === "result") {
396
419
  totals.completed = true;
397
- } else if (this.harness === "dsh" && (record.type === "dsh.completed" || record.type === "dsh.failed")) {
420
+ } else if (isRunnerTerminal(this.harness, record.type)) {
398
421
  totals.completed = true;
399
422
  } else if (this.harness === "agy" && record.event === "result") {
400
423
  totals.completed = true;
@@ -453,20 +476,22 @@ function foldRecord(harness, totals, record) {
453
476
  }
454
477
  return;
455
478
  }
456
- if (harness === "dsh") {
457
- // The runner folds the sdk firehose to one `dsh.message` per assistant
458
- // message (with that message's usage) and one `dsh.tool` per tool call.
479
+ const transcript = runnerTranscript(harness);
480
+ if (transcript) {
481
+ // The dsh runner folds the sdk firehose to one `dsh.message` per assistant
482
+ // message (with that message's usage) and one `dsh.tool` per tool call;
483
+ // the fx runner reports usage per relayed request as `fx.request`.
459
484
  // measured 2026-09-20: before this branch a dsh turn metered zero events,
460
485
  // so once progress became event-only (668f6c1) the stall detector cut
461
486
  // every dsh worker at the contract's stallTimeoutSec regardless of
462
487
  // activity -- three kills on 2026-09-16 at 903s against a 900s limit.
463
- if (record.type === "dsh.message") {
488
+ if (record.type === transcript.usage) {
464
489
  totals.turns += 1;
465
490
  totals.cacheReadInputTokens += canonicalUsage(record.usage).cacheReadInputTokens ?? 0;
466
491
  foldRequestUsage(totals, canonicalUsage(record.usage));
467
- } else if (record.type === "dsh.tool") {
492
+ } else if (record.type === transcript.tool) {
468
493
  totals.toolCalls += 1;
469
- } else if (record.type === "dsh.completed" || record.type === "dsh.failed") {
494
+ } else if (record.type === transcript.completed || record.type === transcript.failed) {
470
495
  const sessionTotal = canonicalUsage(record.usage).cacheReadInputTokens;
471
496
  if (sessionTotal !== null) totals.cacheReadInputTokens = sessionTotal;
472
497
  }
@@ -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
  }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * The `doctor --env` report: which controller variables each runtime's child
3
+ * would carry, which it would not, and which excluded names look like
4
+ * credentials. Only names cross this boundary, never values. Separate from
5
+ * `preflight.mjs` because it is a listing built from the worker environment
6
+ * builder, not a readiness check, and it took that module past its 800-line
7
+ * ceiling (measured 2026-09-27: 840 lines).
8
+ */
9
+ import { DISCOVERY_RUNTIME_DEFINITIONS } from "../engine/runtime-discovery.mjs";
10
+ import { workerEnvironment } from "../engine/worker-env.mjs";
11
+
12
+ /** @typedef {import("../contract/index.mjs").ValidatedContract} ValidatedContract */
13
+ /** @typedef {import("./preflight.mjs").ReachableRuntimes} ReachableRuntimes */
14
+
15
+ /**
16
+ * The name shapes that mark a controller variable as a credential. An excluded
17
+ * name matching one of these is reported as retained: the operator reads that
18
+ * the secret exists on the controller and is kept out of every worker, and
19
+ * never reads its value.
20
+ *
21
+ * `*_KEY`, `*_TOKEN`, `*_SECRET`, `*_PASSWORD`, `AWS_*` and `GITHUB_*` are the
22
+ * six shapes R3 names; they are a warning about the name, not a claim about the
23
+ * value, so a `GITHUB_ACTIONS` is retained too.
24
+ */
25
+ const SECRET_ENV_NAME_PATTERNS = Object.freeze([
26
+ /_KEY$/u,
27
+ /_TOKEN$/u,
28
+ /_SECRET$/u,
29
+ /_PASSWORD$/u,
30
+ /^AWS_/u,
31
+ /^GITHUB_/u,
32
+ ]);
33
+
34
+ /** @param {string} name @returns {boolean} */
35
+ export function isSecretEnvName(name) {
36
+ return SECRET_ENV_NAME_PATTERNS.some((pattern) => pattern.test(name));
37
+ }
38
+
39
+ /**
40
+ * @param {Record<string, string|undefined>} source
41
+ * @returns {string[]}
42
+ */
43
+ function presentEnvironmentNames(source) {
44
+ return Object.keys(source).filter((name) => source[name] !== undefined).sort((left, right) => left.localeCompare(right));
45
+ }
46
+
47
+ /**
48
+ * One runtime's side of the `doctor --env` report: the controller variable
49
+ * names it would carry and the names it would not, with every excluded
50
+ * credential-shaped name also listed under `retained`.
51
+ *
52
+ * Values never cross this boundary. The allowlist is computed from the same
53
+ * `workerEnvironment` the dispatch gate spawns with, and only its keys are
54
+ * read; the source is consulted for presence, never copied into the result.
55
+ *
56
+ * @param {{id?: string, harness?: string, config?: Record<string, unknown>, envPassthrough?: string[]}|null|undefined} runtime
57
+ * @param {{envPassthrough?: string[]}|null|undefined} [contract]
58
+ * @param {Record<string, string|undefined>} [source]
59
+ * @returns {{runtime: string, harness: string, passed: string[], excluded: string[], retained: string[]}}
60
+ */
61
+ export function runtimeEnvironmentReport(runtime, contract, source = process.env) {
62
+ const allowed = new Set(Object.keys(workerEnvironment(runtime, contract, source)));
63
+ const present = presentEnvironmentNames(source);
64
+ const passed = present.filter((name) => allowed.has(name));
65
+ const excluded = present.filter((name) => !allowed.has(name));
66
+ const retained = excluded.filter((name) => isSecretEnvName(name));
67
+ return { runtime: runtime?.id ?? "", harness: runtime?.harness ?? "", passed, excluded, retained };
68
+ }
69
+
70
+ /**
71
+ * The per-runtime listings behind `doctor --env`. A contract narrows the report
72
+ * to the reachable runtimes the run committed to; without one the report covers
73
+ * the discovery catalogue, because there is no routed runtime to name.
74
+ *
75
+ * @param {ReachableRuntimes} [runtimes]
76
+ * @param {ValidatedContract} [contract]
77
+ * @param {Record<string, string|undefined>} [source]
78
+ * @returns {{runtime: string, harness: string, passed: string[], excluded: string[], retained: string[]}[]}
79
+ */
80
+ export function environmentListings(runtimes, contract, source = process.env) {
81
+ const entries = runtimes && runtimes.size
82
+ ? [...runtimes.entries()].map(([id, { runtime }]) => ({ ...runtime, id }))
83
+ : Object.entries(DISCOVERY_RUNTIME_DEFINITIONS).map(([id, runtime]) => ({ ...runtime, id }));
84
+ return entries.map((runtime) => runtimeEnvironmentReport(runtime, contract, source));
85
+ }
package/src/host/home.mjs CHANGED
@@ -27,6 +27,33 @@ export function faberunHome(env = process.env) {
27
27
  return join(homedir(), ".faberun");
28
28
  }
29
29
 
30
+ /**
31
+ * The home directory the *injected* environment names, or null when it names
32
+ * none. Unlike `faberunHome`, this never consults `os.homedir()`.
33
+ *
34
+ * The distinction matters for exactly one caller: `faberun uninstall` derives
35
+ * the roots it may delete from the environment it was handed, and
36
+ * `src/host/uninstall-roots.mjs` refuses every removal when that environment
37
+ * names neither root. A fallback to the operator's real home would aim a delete
38
+ * at it when the caller meant to scope the operation to a throwaway one, which
39
+ * is the accident the isolation requirement exists to stop. `HOME` is the POSIX
40
+ * name and `USERPROFILE` the Windows one; `HOMEDRIVE`+`HOMEPATH` is the last
41
+ * spelling `os.homedir()` itself accepts there.
42
+ *
43
+ * @param {NodeJS.ProcessEnv} [env]
44
+ * @returns {string|null}
45
+ */
46
+ export function effectiveHome(env = process.env) {
47
+ for (const name of ["HOME", "USERPROFILE"]) {
48
+ const value = env[name];
49
+ if (typeof value === "string" && value) return value;
50
+ }
51
+ const drive = env.HOMEDRIVE;
52
+ const path = env.HOMEPATH;
53
+ if (typeof drive === "string" && drive && typeof path === "string" && path) return `${drive}${path}`;
54
+ return null;
55
+ }
56
+
30
57
  /** @param {string} home @returns {string} */
31
58
  export function versionsDir(home) {
32
59
  return join(home, "versions");