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.
- package/README.md +11 -4
- package/package.json +1 -1
- package/skills/faberun/SKILL.md +3 -3
- package/skills/faberun/references/contract.md +9 -9
- package/skills/faberun/references/local-env.md +11 -0
- package/skills/faberun/references/operations.md +26 -33
- package/src/campaign/brief.mjs +41 -9
- package/src/campaign/campaign-brief.mjs +18 -3
- package/src/campaign/index.mjs +22 -9
- package/src/campaign/ledger.mjs +72 -4
- package/src/campaign/metrics.mjs +29 -2
- package/src/campaign/record.mjs +34 -0
- package/src/cli/campaign.mjs +35 -12
- package/src/cli/contract.mjs +11 -1
- package/src/cli/launch.mjs +12 -2
- package/src/cli/manual.mjs +24 -8
- package/src/cli/setup.mjs +6 -1
- package/src/cli/skills.mjs +18 -7
- package/src/cli/uninstall.mjs +542 -0
- package/src/cli/walkthrough.mjs +462 -0
- package/src/cli.mjs +68 -8
- package/src/contract/articles.mjs +30 -0
- package/src/contract/definition-of-done.mjs +109 -0
- package/src/contract/index.mjs +7 -4
- package/src/contract/judge-independence.mjs +18 -0
- package/src/contract/runtime.mjs +6 -2
- package/src/contract/scope-findings.mjs +120 -0
- package/src/contract/snapshot.mjs +19 -5
- package/src/contract/task-packet.mjs +49 -1
- package/src/contract/worker-result.mjs +10 -0
- package/src/engine/assignment.mjs +45 -0
- package/src/engine/detach.mjs +18 -0
- package/src/engine/gate.mjs +10 -2
- package/src/engine/judge-gate.mjs +24 -9
- package/src/engine/live-preflight.mjs +114 -18
- package/src/engine/prompts.mjs +41 -3
- package/src/engine/resume.mjs +119 -12
- package/src/engine/retry.mjs +11 -11
- package/src/engine/run-command.mjs +80 -4
- package/src/engine/run-identity.mjs +27 -14
- package/src/engine/runtime-discovery.mjs +61 -13
- package/src/engine/worker-env.mjs +179 -0
- package/src/harnesses/agy/index.mjs +11 -0
- package/src/harnesses/catalogue.mjs +54 -2
- package/src/harnesses/claude/index.mjs +177 -2
- package/src/harnesses/codex/index.mjs +15 -0
- package/src/harnesses/dsh/index.mjs +18 -116
- package/src/harnesses/dsh/runner.mjs +1 -17
- package/src/harnesses/exec-jsonl/index.mjs +12 -0
- package/src/harnesses/fx/home.mjs +57 -0
- package/src/harnesses/fx/index.mjs +137 -0
- package/src/harnesses/fx/native/build.zig +20 -0
- package/src/harnesses/fx/native/build.zig.zon +7 -0
- package/src/harnesses/fx/native/home.zig +85 -0
- package/src/harnesses/fx/native/main.zig +503 -0
- package/src/harnesses/fx/native/permissions.zig +51 -0
- package/src/harnesses/fx/native/relay.zig +253 -0
- package/src/harnesses/fx/native/usage.zig +112 -0
- package/src/harnesses/fx/permissions.mjs +35 -0
- package/src/harnesses/fx/runner.mjs +350 -0
- package/src/harnesses/fx/usage-proxy.mjs +176 -0
- package/src/harnesses/index.mjs +29 -14
- package/src/harnesses/replay/index.mjs +12 -0
- package/src/harnesses/runner-io.mjs +25 -0
- package/src/harnesses/runner-transcript.mjs +127 -0
- package/src/harnesses/session-metrics.mjs +38 -13
- package/src/harnesses/zcode/index.mjs +19 -0
- package/src/host/config.mjs +8 -2
- package/src/host/environment-report.mjs +85 -0
- package/src/host/home.mjs +27 -0
- package/src/host/install-registry.mjs +511 -0
- package/src/host/preflight.mjs +34 -10
- package/src/host/uninstall-roots.mjs +196 -0
- package/src/plan/proof-check.mjs +15 -0
- package/src/plan/repo-facts.mjs +95 -11
- package/src/plan/rounds.mjs +27 -0
- package/src/repo/declared-paths.mjs +74 -6
- package/src/repo/integrate.mjs +1 -1
- package/src/repo/scope-closure.mjs +59 -0
- package/src/repo/signal-block.mjs +26 -0
- package/src/repo/signal.mjs +9 -24
- package/src/repo/workspace.mjs +189 -14
- package/src/report/campaign-brief.mjs +4 -0
- package/src/report/message.mjs +37 -6
- package/src/report/progress.mjs +13 -2
- package/src/run/paths.mjs +14 -1
- package/src/run/usage.mjs +66 -1
- 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
|
-
|
|
94
|
-
|
|
95
|
-
//
|
|
96
|
-
|
|
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 ===
|
|
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
|
|
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
|
-
|
|
457
|
-
|
|
458
|
-
//
|
|
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 ===
|
|
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 ===
|
|
492
|
+
} else if (record.type === transcript.tool) {
|
|
468
493
|
totals.toolCalls += 1;
|
|
469
|
-
} else if (record.type ===
|
|
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
|
|
package/src/host/config.mjs
CHANGED
|
@@ -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");
|