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
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { rejectUnknown, requireId, requireString } from "./assert.mjs";
|
|
2
|
+
import { errorMessage, shellWords } from "../util.mjs";
|
|
3
|
+
import { Script } from "node:vm";
|
|
2
4
|
/**
|
|
3
5
|
* Schema 2 Definition of Done items. Each item is an object that names an
|
|
4
6
|
* observable outcome and declares how it is proven: mechanically through a
|
|
@@ -210,5 +212,112 @@ export function unquotedFilterValueWarnings(items, index) {
|
|
|
210
212
|
return warnings;
|
|
211
213
|
}
|
|
212
214
|
|
|
215
|
+
/**
|
|
216
|
+
* The body of an inline `node -e`/`node --eval` script named by a command
|
|
217
|
+
* proof's shell command, or null when the command carries none. The proof runs
|
|
218
|
+
* through a shell, so the body is the next shell word with its quotes removed:
|
|
219
|
+
* `shellWords` expands and escapes nothing, which is also how the shell hands
|
|
220
|
+
* the body to node. `-e` belongs to other commands too (`grep -e` the common
|
|
221
|
+
* one), so a scan only starts at a token that names the node binary.
|
|
222
|
+
*
|
|
223
|
+
* @param {string} commandText
|
|
224
|
+
* @returns {string|null}
|
|
225
|
+
*/
|
|
226
|
+
export function inlineScriptBody(commandText) {
|
|
227
|
+
const tokens = shellWords(commandText);
|
|
228
|
+
for (let index = 0; index < tokens.length; index += 1) {
|
|
229
|
+
if (!isNodeExecutable(tokens[index])) continue;
|
|
230
|
+
for (let position = index + 1; position < tokens.length; position += 1) {
|
|
231
|
+
const token = tokens[position];
|
|
232
|
+
if (SHELL_SEPARATORS.has(token)) break;
|
|
233
|
+
if (token === "-e" || token === "--eval") {
|
|
234
|
+
const body = tokens[position + 1];
|
|
235
|
+
return body === undefined || SHELL_SEPARATORS.has(body) ? null : body;
|
|
236
|
+
}
|
|
237
|
+
if (token.startsWith("--eval=")) return token.slice("--eval=".length);
|
|
238
|
+
if (token.startsWith("-e=")) return token.slice("-e=".length);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return null;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* The syntax error a `node -e`/`node --eval` body names, or null when the
|
|
246
|
+
* command is not an inline script or its body parses. `vm.Script` is node's
|
|
247
|
+
* own grammar for `-e`; the two shapes node's module wrapper accepts but a
|
|
248
|
+
* classic script does not -- top-level `await` and static ESM syntax -- are
|
|
249
|
+
* re-tried or allowed rather than refused, so a valid proof is never refused
|
|
250
|
+
* for a body this check cannot parse. A genuine error in the tail of an
|
|
251
|
+
* awaited body still surfaces through the async re-try.
|
|
252
|
+
*
|
|
253
|
+
* @param {string} commandText
|
|
254
|
+
* @returns {string|null}
|
|
255
|
+
*/
|
|
256
|
+
export function inlineScriptParseError(commandText) {
|
|
257
|
+
const body = inlineScriptBody(commandText);
|
|
258
|
+
return body === null ? null : scriptSyntaxError(body);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/**
|
|
262
|
+
* Refuse a contract whose command proof is an inline script that does not
|
|
263
|
+
* parse, naming the node and the Definition of Done item. The launch calls
|
|
264
|
+
* this before the first dispatch: a body the parser rejects can never exit 0,
|
|
265
|
+
* so every attempt would be spent on work no delivery could satisfy.
|
|
266
|
+
*
|
|
267
|
+
* @param {{id?: unknown, definitionOfDone?: {id?: unknown, proof?: {kind?: unknown, ref?: unknown}}[]}[]} nodes
|
|
268
|
+
* @returns {void}
|
|
269
|
+
*/
|
|
270
|
+
export function assertInlineScriptProofsParse(nodes) {
|
|
271
|
+
for (const node of nodes) {
|
|
272
|
+
for (const item of node.definitionOfDone ?? []) {
|
|
273
|
+
if (item.proof?.kind !== "command" || typeof item.proof.ref !== "string") continue;
|
|
274
|
+
const parseError = inlineScriptParseError(item.proof.ref);
|
|
275
|
+
if (parseError === null) continue;
|
|
276
|
+
const nodeName = node.id === undefined ? "a node" : `node ${node.id}`;
|
|
277
|
+
throw new TypeError(`${nodeName}, Definition of Done item "${item.id}": the proof command's inline script does not parse: ${parseError}`);
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** @param {string} token @returns {boolean} */
|
|
283
|
+
function isNodeExecutable(token) {
|
|
284
|
+
const base = (token.split(/[\\/]/u).pop() ?? token).toLowerCase();
|
|
285
|
+
return base === "node" || base === "nodejs" || base === "node.exe";
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* @param {string} body
|
|
290
|
+
* @returns {string|null}
|
|
291
|
+
*/
|
|
292
|
+
function scriptSyntaxError(body) {
|
|
293
|
+
try {
|
|
294
|
+
void new Script(body);
|
|
295
|
+
return null;
|
|
296
|
+
} catch (error) {
|
|
297
|
+
const message = errorMessage(error);
|
|
298
|
+
if (AWAIT_OUTSIDE_ASYNC.test(message)) {
|
|
299
|
+
try {
|
|
300
|
+
// The rest of the body still has to parse, so an unparsable tail is
|
|
301
|
+
// refused even when the head needed the async wrapper to parse at all.
|
|
302
|
+
void new Script(`(async () => {\n${body}\n})`);
|
|
303
|
+
return null;
|
|
304
|
+
} catch (wrapped) {
|
|
305
|
+
const wrappedMessage = errorMessage(wrapped);
|
|
306
|
+
return MODULE_SYNTAX.test(wrappedMessage) ? null : wrappedMessage;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
// node's `-e` accepts static imports, exports and `import.meta`; no stable
|
|
310
|
+
// API parses a module here, so a module-only diagnostic is not a syntax
|
|
311
|
+
// error this check can prove and the body is allowed.
|
|
312
|
+
return MODULE_SYNTAX.test(message) ? null : message;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** vm.Script's diagnostics for syntax node's `-e` module wrapper accepts. */
|
|
317
|
+
const AWAIT_OUTSIDE_ASYNC = /await is only valid in async functions|^Unexpected reserved word$/u;
|
|
318
|
+
const MODULE_SYNTAX = /Cannot use import statement outside a module|Cannot use 'import\.meta' outside a module|Unexpected token 'export'/u;
|
|
319
|
+
/** Shell tokens that end one command, so a later `-e` is not this node's. */
|
|
320
|
+
const SHELL_SEPARATORS = new Set(["&&", "||", "|", ";", "&", ">", ">>", "<"]);
|
|
321
|
+
|
|
213
322
|
/** The node:test filter flags a `kind: "command"` proof's shell can split on an unquoted value. */
|
|
214
323
|
const TEST_FILTER_FLAGS = ["--test-name-pattern", "--test-skip-pattern"];
|
package/src/contract/index.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { readFileSync, statSync } from "node:fs";
|
|
3
3
|
import { dirname, resolve } from "node:path";
|
|
4
|
-
import { loadTaskPacket, renderWorkerPrompt } from "./task-packet.mjs";
|
|
4
|
+
import { loadTaskPacket, renderWorkerPrompt, validateEnvPassthrough } from "./task-packet.mjs";
|
|
5
5
|
import { RESERVED_ARTICLES } from "./articles.mjs";
|
|
6
6
|
import { judgmentReasonWarnings, unquotedFilterValueWarnings, validateDefinitionOfDone } from "./definition-of-done.mjs";
|
|
7
7
|
import { validateFinalVerification, validateSharedVerification } from "./final-verification.mjs";
|
|
@@ -37,7 +37,7 @@ const CONTRACT_FIELDS = new Set([
|
|
|
37
37
|
"schemaVersion", "contractVersion", "id", "campaignId", "goal", "cwd", "sourceIdentity",
|
|
38
38
|
"maxParallel", "pollIntervalMs", "stallTimeoutSec", "timeoutSec", "maxTurns", "phaseSessionReuse",
|
|
39
39
|
"runtimeDefaults", "runtimes", "nodes", "warnings", "finalVerification", "sharedVerification", "nodeAdvisory", "judges",
|
|
40
|
-
"judgeIndependence",
|
|
40
|
+
"judgeIndependence", "envPassthrough",
|
|
41
41
|
]);
|
|
42
42
|
const DEFAULTS_FIELDS = new Set(["worker", "judge"]);
|
|
43
43
|
const NODE_FIELDS = new Set([
|
|
@@ -68,13 +68,13 @@ const GATE_REVIEWS = new Set(["none", "advisory", "blocking"]);
|
|
|
68
68
|
|
|
69
69
|
/** @typedef {{mode: "execution"|"discovery"|"autonomous", objective: string, instructions: string[], readFiles: string[], writeFiles?: string[], writeRoots?: string[], symbols: string[], scopeAcknowledged?: string[], decisions: string[], nonGoals: string[], verification: VerificationCommand[]}} TaskPacket */
|
|
70
70
|
|
|
71
|
-
/** @typedef {{harness: "claude"|"codex"|"agy"|"dsh"|"zcode"|"exec-jsonl"|"replay", model: string, reasoning?: string, sandbox?: "read-only"|"workspace-write"|"danger-full-access", permissionMode?: string, config?: Record<string, unknown>, printTimeout?: string, tools?: string[], executable?: string, args?: string[], versionArgs?: string[], maxArgvPromptBytes?: number, requiredCapabilities?: CapabilityRequirements, costRank?: number, fallback?: string, vendor: string, tier?: number|string, stallTimeoutSec?: number, maxConcurrent?: number}} ValidatedRuntime */
|
|
71
|
+
/** @typedef {{harness: "claude"|"codex"|"agy"|"dsh"|"fx"|"zcode"|"exec-jsonl"|"replay", model: string, reasoning?: string, sandbox?: "read-only"|"workspace-write"|"danger-full-access", permissionMode?: string, config?: Record<string, unknown>, printTimeout?: string, tools?: string[], executable?: string, args?: string[], versionArgs?: string[], maxArgvPromptBytes?: number, requiredCapabilities?: CapabilityRequirements, costRank?: number, fallback?: string, vendor: string, tier?: number|string, stallTimeoutSec?: number, maxConcurrent?: number, envPassthrough?: string[]}} ValidatedRuntime */
|
|
72
72
|
|
|
73
73
|
/** @typedef {{enabled: boolean, review?: ("none"|"advisory"|"blocking"), runtime?: string, failOn?: ("minor"|"major"|"critical")[], maxRevisions?: number, requiredCapabilities?: CapabilityRequirements, skipWhen?: {verificationGreen: true, maxChangedPaths: number}}} ValidatedGate */
|
|
74
74
|
|
|
75
75
|
/** @typedef {{id: string, type: string, phase: string, requirementIds?: string[], runtime?: string, dependsOn: string[], taskPacket: TaskPacket, taskPacketFile?: string, prompt: string, definitionOfDone: import("./definition-of-done.mjs").DefinitionOfDoneItem[], gate: ValidatedGate, timeoutSec?: number, maxTurns?: number, requiredCapabilities: CapabilityRequirements, packetHash: string, sourceIdentity: SourceIdentity, replayPolicy: "safe"|"reconcile"|"never", sameProviderReview: boolean, humanStep?: {step: string, command: string}}} ValidatedNode */
|
|
76
76
|
|
|
77
|
-
/** @typedef {{schemaVersion: number, contractVersion: string, id: string, campaignId: string, goal: string, cwd: string, sourceIdentity: SourceIdentity, runtimes: Record<string, ValidatedRuntime>, runtimeDefaults: {worker?: string, judge?: string}, judges?: string[], judgeIndependence?: "same-vendor", nodes: ValidatedNode[], maxParallel: number, pollIntervalMs: number, stallTimeoutSec: number, timeoutSec: number, maxTurns: number, phaseSessionReuse: boolean, finalVerification?: VerificationCommand[], sharedVerification?: VerificationCommand[], nodeAdvisory?: NodeAdvisoryPolicy, warnings: string[]}} ValidatedContract */
|
|
77
|
+
/** @typedef {{schemaVersion: number, contractVersion: string, id: string, campaignId: string, goal: string, cwd: string, sourceIdentity: SourceIdentity, runtimes: Record<string, ValidatedRuntime>, runtimeDefaults: {worker?: string, judge?: string}, judges?: string[], judgeIndependence?: "same-vendor", nodes: ValidatedNode[], maxParallel: number, pollIntervalMs: number, stallTimeoutSec: number, timeoutSec: number, maxTurns: number, phaseSessionReuse: boolean, finalVerification?: VerificationCommand[], sharedVerification?: VerificationCommand[], nodeAdvisory?: NodeAdvisoryPolicy, envPassthrough?: string[], warnings: string[]}} ValidatedContract */
|
|
78
78
|
/** @typedef {{costUsd?: number, durationSec?: number}} NodeAdvisoryPolicy */
|
|
79
79
|
|
|
80
80
|
/** @typedef {"pending"|"running"|"done"|"no-op"|"blocked"|"failed"|"exhausted"|"stalled"|"canceled"} NodeStatus */
|
|
@@ -134,6 +134,9 @@ export function validateContract(raw, contractPath, options = {}) {
|
|
|
134
134
|
requireId(raw.id, "contract.id");
|
|
135
135
|
requireId(raw.campaignId, "contract.campaignId");
|
|
136
136
|
requireString(raw.goal, "contract.goal");
|
|
137
|
+
// The contract-wide passthrough, unioned with each runtime's own at spawn
|
|
138
|
+
// time. Absent keeps every contract authored before the field valid.
|
|
139
|
+
if (raw.envPassthrough !== undefined) validateEnvPassthrough(raw.envPassthrough, "contract.envPassthrough");
|
|
137
140
|
|
|
138
141
|
const contractDir = dirname(resolve(contractPath));
|
|
139
142
|
const cwd = resolve(contractDir, typeof raw.cwd === "string" ? raw.cwd : ".");
|
|
@@ -25,6 +25,24 @@ export function validateJudgeIndependence(value, label) {
|
|
|
25
25
|
return value;
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* The `judgeIndependence` in force for a launch (R36): the contract's own
|
|
30
|
+
* declaration wins, and a contract that declares none inherits the machine
|
|
31
|
+
* config's opt-in. Both sources are already validated to the single accepted
|
|
32
|
+
* value before they reach here -- the contract by `validateJudgeIndependence`,
|
|
33
|
+
* the config by `isUserConfig` -- so this is a plain precedence read, not a
|
|
34
|
+
* second validation. A value neither source names (or a malformed raw config a
|
|
35
|
+
* test hands in) reads as no opt-in, exactly as an absent field would.
|
|
36
|
+
*
|
|
37
|
+
* @param {{judgeIndependence?: string}|null|undefined} contract
|
|
38
|
+
* @param {{judgeIndependence?: string}|null|undefined} config
|
|
39
|
+
* @returns {"same-vendor"|undefined}
|
|
40
|
+
*/
|
|
41
|
+
export function effectiveJudgeIndependence(contract, config) {
|
|
42
|
+
const mode = contract?.judgeIndependence ?? config?.judgeIndependence;
|
|
43
|
+
return mode === SAME_VENDOR_REVIEW_MODE ? SAME_VENDOR_REVIEW_MODE : undefined;
|
|
44
|
+
}
|
|
45
|
+
|
|
28
46
|
/**
|
|
29
47
|
* Same-vendor mode's own admissibility rule for a worker/judge pair that
|
|
30
48
|
* share a vendor: a judge model absent from the declared tier table can never
|
package/src/contract/runtime.mjs
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* validator to reach it.
|
|
11
11
|
*/
|
|
12
12
|
import { assertObject, nonNegativeNumber, positiveInteger, positiveNumber, rejectUnknown, requireId, requireString, requireStringArray, requireTimestamp } from "./assert.mjs";
|
|
13
|
+
import { validateEnvPassthrough } from "./task-packet.mjs";
|
|
13
14
|
import { composeAssignments } from "../engine/runtime-discovery.mjs";
|
|
14
15
|
import { harnessCapabilities, readOnlyModeOf, resolvePermissionExecution, resolveVendor, validateCapabilityRequirements, writesWorkspace } from "../harnesses/index.mjs";
|
|
15
16
|
import { canonicalProvider } from "./provider.mjs";
|
|
@@ -25,9 +26,9 @@ import { stableJson } from "../util.mjs";
|
|
|
25
26
|
const RUNTIME_FIELDS = new Set([
|
|
26
27
|
"harness", "model", "reasoning", "sandbox", "permissionMode", "config", "printTimeout", "tools",
|
|
27
28
|
"executable", "args", "versionArgs", "maxArgvPromptBytes", "requiredCapabilities", "costRank",
|
|
28
|
-
"fallback", "vendor", "tier", "pricing", "stallTimeoutSec", "maxConcurrent",
|
|
29
|
+
"fallback", "vendor", "tier", "pricing", "stallTimeoutSec", "maxConcurrent", "envPassthrough",
|
|
29
30
|
]);
|
|
30
|
-
const RUNTIME_HARNESSES = new Set(["claude", "codex", "agy", "dsh", "zcode", "exec-jsonl", "replay"]);
|
|
31
|
+
const RUNTIME_HARNESSES = new Set(["claude", "codex", "agy", "dsh", "fx", "zcode", "exec-jsonl", "replay"]);
|
|
31
32
|
|
|
32
33
|
/**
|
|
33
34
|
* The strategies a routing rule may name: how a rule consumes its `prefer`
|
|
@@ -150,6 +151,9 @@ function validateRuntimeValues(runtime, label, executableRequired) {
|
|
|
150
151
|
if (runtime.args !== undefined) requireStringArray(runtime.args, `${label}.args`);
|
|
151
152
|
if (runtime.versionArgs !== undefined) requireStringArray(runtime.versionArgs, `${label}.versionArgs`);
|
|
152
153
|
if (runtime.maxArgvPromptBytes !== undefined) positiveInteger(runtime.maxArgvPromptBytes, `${label}.maxArgvPromptBytes`);
|
|
154
|
+
// Names only, on the runtime descriptor directly or on the contract: either
|
|
155
|
+
// one asks the worker allowlist to carry the variable through by name.
|
|
156
|
+
if (runtime.envPassthrough !== undefined) validateEnvPassthrough(runtime.envPassthrough, `${label}.envPassthrough`);
|
|
153
157
|
if (runtime.costRank !== undefined) nonNegativeNumber(runtime.costRank, `${label}.costRank`);
|
|
154
158
|
if (runtime.stallTimeoutSec !== undefined) positiveNumber(runtime.stallTimeoutSec, `${label}.stallTimeoutSec`);
|
|
155
159
|
if (runtime.pricing !== undefined) validatePricing(runtime.pricing, `${label}.pricing`);
|
|
@@ -87,3 +87,123 @@ export function verificationFailureWithScope(verdict, scope) {
|
|
|
87
87
|
}],
|
|
88
88
|
};
|
|
89
89
|
}
|
|
90
|
+
|
|
91
|
+
/** The additions a reauthor discovery pass may propose to a refused packet. */
|
|
92
|
+
/** @typedef {{readFiles?: string[], writeFiles?: string[], scopeAcknowledged?: string[], symbols?: string[]}} ReauthorAdditions */
|
|
93
|
+
|
|
94
|
+
/** The bounded journal record a reauthor attempt leaves behind. */
|
|
95
|
+
export const MAX_REAUTHOR_PATHS = 64;
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* A widened packet may not take over a write another node's packet already
|
|
99
|
+
* owns. `validateContract` catches a packet that does not close its own scope
|
|
100
|
+
* and a pair whose test one node cannot touch, but two nodes declaring the
|
|
101
|
+
* same write are each individually closed, so no per-packet check sees the
|
|
102
|
+
* collision. This names the owner the discovery pass must leave alone.
|
|
103
|
+
*
|
|
104
|
+
* @param {import("../contract/index.mjs").ValidatedNode[]} nodes every node of the contract
|
|
105
|
+
* @param {string} nodeId the node being widened
|
|
106
|
+
* @param {string[]|undefined} addedWriteFiles the writes the discovery pass proposes to add
|
|
107
|
+
* @returns {{path: string, nodeId: string, reason: string}[]}
|
|
108
|
+
*/
|
|
109
|
+
export function reauthorWriteConflicts(nodes, nodeId, addedWriteFiles) {
|
|
110
|
+
const added = new Set(addedWriteFiles ?? []);
|
|
111
|
+
if (added.size === 0) return [];
|
|
112
|
+
/** @type {{path: string, nodeId: string, reason: string}[]} */
|
|
113
|
+
const conflicts = [];
|
|
114
|
+
for (const node of nodes) {
|
|
115
|
+
if (node.id === nodeId) continue;
|
|
116
|
+
for (const path of node.taskPacket.writeFiles ?? []) {
|
|
117
|
+
if (!added.has(path)) continue;
|
|
118
|
+
conflicts.push({
|
|
119
|
+
path,
|
|
120
|
+
nodeId: node.id,
|
|
121
|
+
reason: `${path} is already declared in writeFiles by node ${node.id}; a widened packet may not take over another node's write`,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return conflicts;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The bounded journal record a reauthor attempt leaves: the widened packet the
|
|
130
|
+
* discovery pass proposed (`reauthorProposal`) and the hard budget it spent
|
|
131
|
+
* (`reauthorRounds`). It lives in `reauthor.jsonl` beside the run's events
|
|
132
|
+
* rather than on the node snapshot, so a proposal never moves the authored
|
|
133
|
+
* packet hash or asks the snapshot schema to carry a transient shape.
|
|
134
|
+
*
|
|
135
|
+
* @param {{
|
|
136
|
+
* nodeId: string,
|
|
137
|
+
* outcome: "applied"|"approval_required"|"rounds_exhausted",
|
|
138
|
+
* riskTier: string,
|
|
139
|
+
* budget: number,
|
|
140
|
+
* roundsUsed: number,
|
|
141
|
+
* additions: ReauthorAdditions|null|undefined,
|
|
142
|
+
* packet: import("./task-packet.mjs").TaskPacket|null|undefined,
|
|
143
|
+
* findings: string[]|null|undefined,
|
|
144
|
+
* history: {round: number, accepted: boolean, additions?: ReauthorAdditions, findings?: string[]}[],
|
|
145
|
+
* }} entry
|
|
146
|
+
* @returns {Record<string, unknown>}
|
|
147
|
+
*/
|
|
148
|
+
export function reauthorProposalRecord({ nodeId, outcome, riskTier, budget, roundsUsed, additions, packet, findings, history }) {
|
|
149
|
+
/** @param {string[]|undefined} paths @returns {string[]} */
|
|
150
|
+
const bounded = (paths) => [...new Set(paths ?? [])].slice(0, MAX_REAUTHOR_PATHS);
|
|
151
|
+
return {
|
|
152
|
+
at: new Date().toISOString(),
|
|
153
|
+
node: nodeId,
|
|
154
|
+
outcome,
|
|
155
|
+
riskTier,
|
|
156
|
+
reauthorProposal: {
|
|
157
|
+
packet: packet ?? null,
|
|
158
|
+
addedReadFiles: bounded(additions?.readFiles),
|
|
159
|
+
addedWriteFiles: bounded(additions?.writeFiles),
|
|
160
|
+
addedScopeAcknowledged: bounded(additions?.scopeAcknowledged),
|
|
161
|
+
findings: (findings ?? []).slice(0, 8),
|
|
162
|
+
},
|
|
163
|
+
reauthorRounds: {
|
|
164
|
+
budget,
|
|
165
|
+
used: roundsUsed,
|
|
166
|
+
exhausted: outcome === "rounds_exhausted",
|
|
167
|
+
history: history.map((item) => ({
|
|
168
|
+
round: item.round,
|
|
169
|
+
accepted: item.accepted,
|
|
170
|
+
addedReadFiles: bounded(item.additions?.readFiles),
|
|
171
|
+
addedWriteFiles: bounded(item.additions?.writeFiles),
|
|
172
|
+
findings: (item.findings ?? []).slice(0, 8),
|
|
173
|
+
})),
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* The risk tier a frozen node's gate encodes: a disabled gate is low risk, a
|
|
180
|
+
* blocking review is high, an advisory review is standard. A contract does not
|
|
181
|
+
* carry the plan's own riskTier, so the gate it froze into is the durable
|
|
182
|
+
* statement of how much a widening of its packet is trusted.
|
|
183
|
+
*
|
|
184
|
+
* @param {import("../contract/index.mjs").ValidatedNode} node
|
|
185
|
+
* @returns {"low"|"standard"|"high"}
|
|
186
|
+
*/
|
|
187
|
+
export function reauthorRiskTier(node) {
|
|
188
|
+
if (!node.gate.enabled) return "low";
|
|
189
|
+
return node.gate.review === "blocking" ? "high" : "standard";
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Whether a validated widening may be applied without an explicit operator
|
|
194
|
+
* yes. Mirrors the planning pipeline's `--approve-below`: `high` approves
|
|
195
|
+
* everything, `none` approves nothing, and the default `standard` approves
|
|
196
|
+
* everything but a high-risk node. `approve` overrides the threshold.
|
|
197
|
+
*
|
|
198
|
+
* @param {import("../contract/index.mjs").ValidatedNode} node
|
|
199
|
+
* @param {{approve?: boolean, approveBelow?: string}} [options]
|
|
200
|
+
* @returns {boolean}
|
|
201
|
+
*/
|
|
202
|
+
export function reauthorApproved(node, options = {}) {
|
|
203
|
+
if (options.approve === true) return true;
|
|
204
|
+
const approveBelow = options.approveBelow ?? "standard";
|
|
205
|
+
if (approveBelow === "high") return true;
|
|
206
|
+
if (approveBelow === "none") return false;
|
|
207
|
+
if (approveBelow !== "standard") throw new TypeError(`approveBelow must be one of standard, high, none: ${approveBelow}`);
|
|
208
|
+
return reauthorRiskTier(node) !== "high";
|
|
209
|
+
}
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import { Buffer } from "node:buffer";
|
|
12
12
|
import { MAX_SCOPE_FINDING_PATHS } from "./scope-findings.mjs";
|
|
13
13
|
import { REVIEW_MODES } from "./review-modes.mjs";
|
|
14
|
+
import { SAME_VENDOR_REVIEW_MODE } from "./judge-independence.mjs";
|
|
14
15
|
import { assertObject, boundedString, nonNegativeInteger, nonNegativeNumber, positiveInteger, positiveNumber, rejectUnknown, requireId, requireInteger, requirePacketHash, requireString, requireTimestamp } from "./assert.mjs";
|
|
15
16
|
import { stableJson } from "../util.mjs";
|
|
16
17
|
import { isAbsolute } from "node:path";
|
|
@@ -26,11 +27,17 @@ import { validateWorkerResult } from "./worker-result.mjs";
|
|
|
26
27
|
/** @typedef {import("./index.mjs").RunMetadata} RunMetadata */
|
|
27
28
|
/** @typedef {import("./index.mjs").ValidatedNode} ValidatedNode */
|
|
28
29
|
|
|
30
|
+
/** Hard byte ceiling for an operator's answer text, enforced where it is accepted. */
|
|
31
|
+
export const OPERATOR_ANSWER_MAX_BYTES = 8 * 1024;
|
|
32
|
+
|
|
33
|
+
/** Hard ceiling for the failure evidence of a `Previous attempt` section, in bytes. */
|
|
34
|
+
export const PREVIOUS_ATTEMPT_EVIDENCE_MAX_BYTES = 8 * 1024;
|
|
35
|
+
|
|
29
36
|
/**
|
|
30
|
-
*
|
|
31
|
-
*
|
|
37
|
+
* The whole persisted `Previous attempt` section: the bounded evidence, then
|
|
38
|
+
* the operator's answer carried whole under its heading (engine/retry.mjs).
|
|
32
39
|
*/
|
|
33
|
-
|
|
40
|
+
const PREVIOUS_ATTEMPT_MAX_BYTES = PREVIOUS_ATTEMPT_EVIDENCE_MAX_BYTES + OPERATOR_ANSWER_MAX_BYTES + 64;
|
|
34
41
|
|
|
35
42
|
const GATE_VERDICTS = new Set(["pass", "fail", "invalid_judge_output"]);
|
|
36
43
|
const NODE_STATUSES = new Set(["pending", "running", "done", "no-op", "blocked", "failed", "exhausted", "stalled", "canceled"]);
|
|
@@ -203,7 +210,7 @@ export function validateNodeSnapshot(value, expectedNode = null) {
|
|
|
203
210
|
if (typeof value.previousAttempt !== "string" || !value.previousAttempt.trim()) {
|
|
204
211
|
throw new TypeError("node snapshot.previousAttempt must be a non-empty string");
|
|
205
212
|
}
|
|
206
|
-
if (Buffer.byteLength(value.previousAttempt, "utf8") >
|
|
213
|
+
if (Buffer.byteLength(value.previousAttempt, "utf8") > PREVIOUS_ATTEMPT_MAX_BYTES) throw new TypeError(`node snapshot.previousAttempt exceeds ${PREVIOUS_ATTEMPT_MAX_BYTES} bytes`);
|
|
207
214
|
}
|
|
208
215
|
if (expectedNode) validateSnapshotBinding(value, expectedNode);
|
|
209
216
|
if (Buffer.byteLength(JSON.stringify(value), "utf8") > 128 * 1024) throw new TypeError("node snapshot exceeds 131072 bytes");
|
|
@@ -508,12 +515,19 @@ function validateRoutingState(value, label) {
|
|
|
508
515
|
if (value.judgeList !== undefined) validateJudgeListState(value.judgeList, `${label}.judgeList`);
|
|
509
516
|
if (value.assignments !== undefined) {
|
|
510
517
|
assertObject(value.assignments, `${label}.assignments`);
|
|
511
|
-
rejectUnknown(value.assignments, new Set(["worker", "judge", "composedWorker", "composedJudge"]), `${label}.assignments`);
|
|
518
|
+
rejectUnknown(value.assignments, new Set(["worker", "judge", "composedWorker", "composedJudge", "judgeIndependence"]), `${label}.assignments`);
|
|
512
519
|
requireId(value.assignments.worker, `${label}.assignments.worker`);
|
|
513
520
|
requireId(value.assignments.judge, `${label}.assignments.judge`);
|
|
514
521
|
for (const key of ["composedWorker", "composedJudge"]) {
|
|
515
522
|
if (value.assignments[key] !== undefined && typeof value.assignments[key] !== "boolean") throw new TypeError(`${label}.assignments.${key} must be boolean`);
|
|
516
523
|
}
|
|
524
|
+
// R36: the effective judge-independence mode the launch composed under,
|
|
525
|
+
// recorded so a resume, brief or report reads the decision back. Only the
|
|
526
|
+
// one accepted mode may be persisted; its absence is the cross-vendor
|
|
527
|
+
// default.
|
|
528
|
+
if (value.assignments.judgeIndependence !== undefined && value.assignments.judgeIndependence !== SAME_VENDOR_REVIEW_MODE) {
|
|
529
|
+
throw new TypeError(`${label}.assignments.judgeIndependence must be "${SAME_VENDOR_REVIEW_MODE}"`);
|
|
530
|
+
}
|
|
517
531
|
}
|
|
518
532
|
if (value.availability !== undefined) {
|
|
519
533
|
assertObject(value.availability, `${label}.availability`);
|
|
@@ -2,6 +2,7 @@ import { existsSync, lstatSync, readFileSync, realpathSync, statSync } from "nod
|
|
|
2
2
|
import { Buffer } from "node:buffer";
|
|
3
3
|
import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
|
4
4
|
import { validateVerificationCommands } from "./verification.mjs";
|
|
5
|
+
import { RESERVED_OWNER_DECISIONS, uncoveredReservedOwnerDecisions } from "./articles.mjs";
|
|
5
6
|
import { errorCode } from "../util.mjs";
|
|
6
7
|
import { requireString, requireStringArray } from "./assert.mjs";
|
|
7
8
|
|
|
@@ -101,6 +102,25 @@ export function loadTaskPacket(node, contractDir, cwd, index, options = {}) {
|
|
|
101
102
|
return validateTaskPacket(packet, index, cwd, options);
|
|
102
103
|
}
|
|
103
104
|
|
|
105
|
+
/**
|
|
106
|
+
* The `## Reserved owner decisions` section every worker prompt carries: the
|
|
107
|
+
* same list the judge reads, with the packet's own coverage spelled out, so an
|
|
108
|
+
* uncovered owner decision is refused with `blocked_context` instead of being
|
|
109
|
+
* chosen by a worker.
|
|
110
|
+
*
|
|
111
|
+
* @param {string[]} decisions
|
|
112
|
+
* @returns {string[]}
|
|
113
|
+
*/
|
|
114
|
+
function reservedOwnerDecisionsLines(decisions) {
|
|
115
|
+
const uncovered = uncoveredReservedOwnerDecisions(decisions);
|
|
116
|
+
return [
|
|
117
|
+
"## Reserved owner decisions",
|
|
118
|
+
`These decisions belong to the repository owner, never to this node: ${RESERVED_OWNER_DECISIONS.join(", ")}.`,
|
|
119
|
+
`Reserved decisions with no coverage in "Decisions already made": ${uncovered.length ? uncovered.join(", ") : "(none)"}.`,
|
|
120
|
+
"If the task requires taking an uncovered decision, do not choose it: return the blocked_context worker-result object naming the decision.",
|
|
121
|
+
];
|
|
122
|
+
}
|
|
123
|
+
|
|
104
124
|
/**
|
|
105
125
|
* @param {TaskPacket} packet
|
|
106
126
|
* @param {string} nodeId
|
|
@@ -133,6 +153,8 @@ export function renderWorkerPrompt(packet, nodeId) {
|
|
|
133
153
|
"## Decisions already made",
|
|
134
154
|
...bulletOrNone(packet.decisions),
|
|
135
155
|
"",
|
|
156
|
+
...reservedOwnerDecisionsLines(packet.decisions),
|
|
157
|
+
"",
|
|
136
158
|
"## Non-goals",
|
|
137
159
|
...bulletOrNone(packet.nonGoals),
|
|
138
160
|
"",
|
|
@@ -266,6 +288,28 @@ export function validateTaskPacket(packet, index, cwd, options = {}) {
|
|
|
266
288
|
};
|
|
267
289
|
}
|
|
268
290
|
|
|
291
|
+
/**
|
|
292
|
+
* The environment-variable names an operator allows through to a worker, the
|
|
293
|
+
* one validator both the runtime descriptor and the contract share. The list
|
|
294
|
+
* is names only -- never values -- so it selects from the controller's own
|
|
295
|
+
* environment without carrying a secret of its own. A name is the portable
|
|
296
|
+
* shell shape: a letter or underscore, then letters, digits, or underscores.
|
|
297
|
+
*
|
|
298
|
+
* @param {unknown} value
|
|
299
|
+
* @param {string} label
|
|
300
|
+
* @returns {string[]}
|
|
301
|
+
*/
|
|
302
|
+
export function validateEnvPassthrough(value, label) {
|
|
303
|
+
if (!Array.isArray(value)) throw new TypeError(`${label} must be an array of environment-variable names`);
|
|
304
|
+
const names = /** @type {unknown[]} */ (value);
|
|
305
|
+
names.forEach((name, index) => {
|
|
306
|
+
if (typeof name !== "string" || !/^[A-Za-z_][A-Za-z0-9_]*$/u.test(name)) {
|
|
307
|
+
throw new TypeError(`${label}[${index}] must be an environment-variable name`);
|
|
308
|
+
}
|
|
309
|
+
});
|
|
310
|
+
return /** @type {string[]} */ (names);
|
|
311
|
+
}
|
|
312
|
+
|
|
269
313
|
/**
|
|
270
314
|
* @param {string} path
|
|
271
315
|
* @param {string} label
|
|
@@ -456,6 +500,8 @@ function renderDiscoveryPrompt(packet, nodeId) {
|
|
|
456
500
|
"## Decisions already made",
|
|
457
501
|
...bulletOrNone(packet.decisions),
|
|
458
502
|
"",
|
|
503
|
+
...reservedOwnerDecisionsLines(packet.decisions),
|
|
504
|
+
"",
|
|
459
505
|
"## Non-goals",
|
|
460
506
|
...bulletOrNone(packet.nonGoals),
|
|
461
507
|
"",
|
|
@@ -484,7 +530,7 @@ function renderAutonomousPrompt(packet, nodeId) {
|
|
|
484
530
|
packet.objective,
|
|
485
531
|
"",
|
|
486
532
|
"## Closed context",
|
|
487
|
-
"This autonomous context may inspect the repository read-only as needed.
|
|
533
|
+
"This autonomous context may inspect the repository read-only as needed. The write roots below are the expected boundary, not a hard wall: a write outside them is allowed when the task requires it, and the result must name the path and the reason. Return the blocked_context worker-result object below only for context that is genuinely missing.",
|
|
488
534
|
"",
|
|
489
535
|
"## Instructions",
|
|
490
536
|
...numbered(packet.instructions),
|
|
@@ -501,6 +547,8 @@ function renderAutonomousPrompt(packet, nodeId) {
|
|
|
501
547
|
"## Decisions already made",
|
|
502
548
|
...bulletOrNone(packet.decisions),
|
|
503
549
|
"",
|
|
550
|
+
...reservedOwnerDecisionsLines(packet.decisions),
|
|
551
|
+
"",
|
|
504
552
|
"## Non-goals",
|
|
505
553
|
...bulletOrNone(packet.nonGoals),
|
|
506
554
|
"",
|
|
@@ -25,6 +25,16 @@ const RESULT_LIMITS = Object.freeze({
|
|
|
25
25
|
*/
|
|
26
26
|
export const DERIVED_WORKER_RESULT_FIELDS = Object.freeze(["changedFiles"]);
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* The named classification a `workspace-write` sandbox earns when a toolchain
|
|
30
|
+
* is refused a write outside the worktree. It is a classification of the
|
|
31
|
+
* failure, not a worker-result status: the controller derives it from the
|
|
32
|
+
* failed command's output (`src/engine/run-command.mjs`) and records it beside
|
|
33
|
+
* the attempt, carrying the mode and the denied path, so what the operator
|
|
34
|
+
* reads says "sandbox", not "filesystem".
|
|
35
|
+
*/
|
|
36
|
+
export const SANDBOX_BLOCKED_WRITE = "sandbox_blocked_write";
|
|
37
|
+
|
|
28
38
|
/**
|
|
29
39
|
* A worker result that is well-formed JSON but breaks one of the byte
|
|
30
40
|
* ceilings above. Typed, not a plain TypeError, so the repair prompt can name
|
|
@@ -40,6 +40,37 @@ export async function runtimeAssignments(contract) {
|
|
|
40
40
|
(node.runtime === undefined && contract.runtimeDefaults?.worker === undefined)
|
|
41
41
|
|| (node.gate.enabled && node.gate.runtime === undefined && contract.runtimeDefaults?.judge === undefined));
|
|
42
42
|
const availability = needsComposition ? await discoverRuntimes(contract.runtimes, { cwd: contract.cwd }) : {};
|
|
43
|
+
return composeRuntimeAssignments(contract, availability);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Every declared runtime marked available, the catalogue a dry-run assignment
|
|
48
|
+
* reads. `validate` composes over this so a contract no live probe could
|
|
49
|
+
* rescue -- an omitted judge with no cross-vendor candidate, a same-vendor
|
|
50
|
+
* pair below the admitted tier, an exhausted judge list -- is refused at
|
|
51
|
+
* authoring time. Modeling unavailability here would make a contract's
|
|
52
|
+
* validity depend on the machine that happens to read it, when the point is
|
|
53
|
+
* to refuse what the launch would refuse whatever is up.
|
|
54
|
+
*
|
|
55
|
+
* @param {ValidatedContract} contract
|
|
56
|
+
* @returns {Record<string, RuntimeAvailability>}
|
|
57
|
+
*/
|
|
58
|
+
export function allRuntimesAvailable(contract) {
|
|
59
|
+
return Object.fromEntries(Object.keys(contract.runtimes).map((id) => [id, { available: true, exhaustedUntil: null, reason: "ready" }]));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The launch-time assignment minus discovery: the same machine-config
|
|
64
|
+
* narrowing, judge-list selection, and `composeAssignments` call
|
|
65
|
+
* `runtimeAssignments` runs once the availability catalogue is in hand. Kept
|
|
66
|
+
* synchronous so `validate` can ask what a launch would assign without
|
|
67
|
+
* probing a provider.
|
|
68
|
+
*
|
|
69
|
+
* @param {ValidatedContract} contract
|
|
70
|
+
* @param {Record<string, RuntimeAvailability>} availability
|
|
71
|
+
* @returns {{assignments: Record<string, {worker: string, judge: string, composedWorker: boolean, composedJudge: boolean}>, decisions: Record<string, {worker: {strategy: string|null, reason: string}, judge: {strategy: string|null, reason: string}}>, availability: Record<string, RuntimeAvailability>, judgeListStates: Record<string, JudgeListState|undefined>}}
|
|
72
|
+
*/
|
|
73
|
+
export function composeRuntimeAssignments(contract, availability) {
|
|
43
74
|
const config = readUserConfig(process.env);
|
|
44
75
|
const list = resolveJudgeList(contract, config);
|
|
45
76
|
/** @type {Record<string, JudgeListState|undefined>} */
|
|
@@ -101,6 +132,20 @@ export async function runtimeAssignments(contract) {
|
|
|
101
132
|
judgeListStates,
|
|
102
133
|
};
|
|
103
134
|
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Throw the refusal a launch-time assignment would raise for this contract,
|
|
138
|
+
* with every runtime given as available. This is the check `faberun validate`
|
|
139
|
+
* runs (R35) so a contract the launch would refuse is refused at authoring
|
|
140
|
+
* time, before a run directory or a provider is touched. A persisted run's
|
|
141
|
+
* contract is never passed here: its assignment is already frozen.
|
|
142
|
+
*
|
|
143
|
+
* @param {ValidatedContract} contract
|
|
144
|
+
* @returns {void}
|
|
145
|
+
*/
|
|
146
|
+
export function assertRuntimeAssignment(contract) {
|
|
147
|
+
composeRuntimeAssignments(contract, allRuntimesAvailable(contract));
|
|
148
|
+
}
|
|
104
149
|
/**
|
|
105
150
|
* @param {ValidatedContract} contract
|
|
106
151
|
* @param {string} runDir
|
package/src/engine/detach.mjs
CHANGED
|
@@ -27,6 +27,8 @@ import { randomUUID } from "node:crypto";
|
|
|
27
27
|
import { unlinkSync } from "node:fs";
|
|
28
28
|
import { sameProcessStartToken, validBootstrapNonce } from "../run/lock.mjs";
|
|
29
29
|
import { bootstrapAckPath, bootstrapAttemptPath, readJson } from "../run/store.mjs";
|
|
30
|
+
import { removeRunDirectory } from "../run/paths.mjs";
|
|
31
|
+
import { runNeverHeldNode } from "./run-identity.mjs";
|
|
30
32
|
import { delay, errorCode } from "../util.mjs";
|
|
31
33
|
|
|
32
34
|
/** How long a detached child waits for its launcher to acknowledge. */
|
|
@@ -99,3 +101,19 @@ export function cleanupBootstrapNonce(runDir, nonce) {
|
|
|
99
101
|
}
|
|
100
102
|
}
|
|
101
103
|
|
|
104
|
+
/**
|
|
105
|
+
* Remove a run directory that died before it could hold a node, so the next
|
|
106
|
+
* launch is not refused with `run already exists` because of evidence that
|
|
107
|
+
* never became a run. A directory that already holds a node is left exactly
|
|
108
|
+
* where it is: that evidence is what a resume reads. Returns whether the
|
|
109
|
+
* directory was removed.
|
|
110
|
+
*
|
|
111
|
+
* @param {string} runDir
|
|
112
|
+
* @returns {boolean}
|
|
113
|
+
*/
|
|
114
|
+
export function discardNodeLessRunDirectory(runDir) {
|
|
115
|
+
if (!runNeverHeldNode(runDir)) return false;
|
|
116
|
+
removeRunDirectory(runDir);
|
|
117
|
+
return true;
|
|
118
|
+
}
|
|
119
|
+
|
package/src/engine/gate.mjs
CHANGED
|
@@ -33,6 +33,7 @@ import { dirname } from "node:path";
|
|
|
33
33
|
import { spawn } from "node:child_process";
|
|
34
34
|
import { NOTIFY_ENV_NAMES } from "../notify/index.mjs";
|
|
35
35
|
import { killTarget, spawnInvocation } from "../host/platform.mjs";
|
|
36
|
+
import { baseEnvironment } from "./worker-env.mjs";
|
|
36
37
|
|
|
37
38
|
/** @typedef {{executable: string, args: string[], cwd: string, promptTransport: "stdin"|"argv", harness: string, env: Record<string, string|null>|null, stdoutPath: string, stderrPath: string}} GateConfig */
|
|
38
39
|
|
|
@@ -164,10 +165,17 @@ function capLog(path, preservePrefix = false) {
|
|
|
164
165
|
|
|
165
166
|
/** @returns {Record<string, string|undefined>} */
|
|
166
167
|
function childEnv() {
|
|
167
|
-
|
|
168
|
+
// The worker/judge allowlist. The base operating-system names come from this
|
|
169
|
+
// process; the runtime-specific names (adapter-declared, `*.env_key`, and
|
|
170
|
+
// every explicit envPassthrough) travelled here in the gate config because
|
|
171
|
+
// only the controller knows the runtime. A controller variable nothing named
|
|
172
|
+
// is absent from `config.env`, so it never reaches the provider. Never start
|
|
173
|
+
// from `{ ...process.env }`: that was the leak (an exported `AWS_*` or
|
|
174
|
+
// `GITHUB_TOKEN` handed to a model that runs arbitrary commands).
|
|
175
|
+
const merged = baseEnvironment(process.env);
|
|
168
176
|
for (const [key, value] of Object.entries(config.env ?? {})) {
|
|
169
177
|
if (value === null) delete merged[key];
|
|
170
|
-
else merged[key] = value;
|
|
178
|
+
else if (value !== undefined) merged[key] = value;
|
|
171
179
|
}
|
|
172
180
|
// Worker providers are not a notification surface: strip every controller-only
|
|
173
181
|
// transport after the harness overlay so no harness can reintroduce one. The
|