@aiwg/cli 2026.10.8 → 2026.10.10
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/agentic/code/providers/model-capabilities.v1.json +197 -3
- package/bin/aiwg.mjs +8 -0
- package/dist/src/artifacts/cli.js +9 -5
- package/dist/src/auth/config.js +3 -8
- package/dist/src/auth/resource-credentials.js +35 -2
- package/dist/src/cli/handlers/diagnose.js +13 -0
- package/dist/src/cli/handlers/mc-admission.js +39 -0
- package/dist/src/cli/handlers/mc.js +2 -31
- package/dist/src/cli/handlers/models-help.js +52 -0
- package/dist/src/cli/handlers/models.js +11 -81
- package/dist/src/cli/handlers/resource-versions.js +8 -5
- package/dist/src/cli/handlers/script-runner.js +13 -1
- package/dist/src/cli/handlers/use-addon-profile.js +72 -0
- package/dist/src/cli/handlers/use.js +209 -233
- package/dist/src/cli/ui.js +1 -0
- package/dist/src/config/aiwg-config.js +38 -184
- package/dist/src/config/cli-help.js +54 -0
- package/dist/src/config/cli.js +5 -69
- package/dist/src/config/config-normalization.js +10 -0
- package/dist/src/config/worker-activation-gate.js +26 -0
- package/dist/src/config/worker-adoption-cli.js +102 -0
- package/dist/src/config/worker-adoption.js +368 -0
- package/dist/src/config/worker-config-digest.js +14 -0
- package/dist/src/config/worker-group-templates.js +150 -0
- package/dist/src/config/worker-guidance-warning.js +28 -0
- package/dist/src/config/worker-owner-lock.js +152 -0
- package/dist/src/config/worker-parallelism.js +154 -0
- package/dist/src/config/worker-write.js +75 -0
- package/dist/src/decision/telemetry/exporter.js +2 -1
- package/dist/src/decision/telemetry/index.js +1 -0
- package/dist/src/decision/telemetry/mapping.js +43 -0
- package/dist/src/decision/telemetry/otlp-http.js +5 -2
- package/dist/src/decision/telemetry/redaction.js +6 -1
- package/dist/src/decision/telemetry/types.js +3 -0
- package/dist/src/decision/telemetry/worker.js +69 -0
- package/dist/src/lint/rule-activation.js +15 -7
- package/dist/src/lint/runner.js +2 -2
- package/dist/src/mcp/server.mjs +2 -11
- package/dist/src/mcp/stdio-lifecycle.mjs +27 -0
- package/dist/src/mcp/tools/worker-failure-diagnostics.mjs +9 -0
- package/dist/src/mcp/tools/worker-invoke.mjs +6 -6
- package/dist/src/mission-protocol/codecs.js +8 -38
- package/dist/src/mission-protocol/worker-contract-diagnostics.js +62 -0
- package/dist/src/mission-protocol/worker-invocation-types.js +2 -0
- package/dist/src/models/conductor-data-policy.d.mts +8 -0
- package/dist/src/models/conductor-data-policy.mjs +190 -0
- package/dist/src/models/config-loader.js +19 -3
- package/dist/src/models/config-writer.js +27 -0
- package/dist/src/models/model-capabilities.v1.json +197 -3
- package/dist/src/models/provider-policy.js +7 -155
- package/dist/src/models/resolver.js +2 -293
- package/dist/src/models/worker-artifact-binding.mjs +12 -0
- package/dist/src/models/worker-direct-readiness-policy.js +155 -0
- package/dist/src/models/worker-direct-readiness.js +9 -218
- package/dist/src/models/worker-health-readiness.js +105 -26
- package/dist/src/models/worker-installation.mjs +12 -14
- package/dist/src/models/worker-member-references.js +64 -0
- package/dist/src/models/worker-plan.js +317 -0
- package/dist/src/models/worker-selection-policy.js +217 -0
- package/dist/src/models/wrapper-deployment.js +1 -1
- package/dist/src/resources/web-release.js +76 -65
- package/dist/src/serve/shared-host-admission-checks.js +116 -0
- package/dist/src/serve/shared-host-scheduler.js +25 -107
- package/dist/src/serve/shared-host-starvation.js +73 -0
- package/dist/src/serve/worker-artifacts.d.mts +4 -0
- package/dist/src/serve/worker-artifacts.mjs +56 -0
- package/dist/src/serve/worker-command-request.js +68 -0
- package/dist/src/serve/worker-invocation-paths.js +144 -0
- package/dist/src/serve/worker-invocation-preparation.js +169 -0
- package/dist/src/serve/worker-invocation.js +24 -291
- package/dist/src/serve/worker-stdin-frame.d.mts +2 -2
- package/dist/src/serve/worker-stdin-frame.mjs +6 -6
- package/dist/src/smiths/context-pipeline/parallelism-section.js +19 -7
- package/dist/src/storage/backends/fortemi.js +33 -29
- package/dist/src/storage/backends/logseq.js +16 -3
- package/dist/src/storage/backends/postgres.js +74 -33
- package/dist/src/storage/backends/postgrest.js +37 -13
- package/dist/src/storage/config.js +6 -1
- package/dist/src/uhp/config.js +4 -0
- package/package.json +9 -1
- package/schemas/mission-v1.schema.json +20 -19
- package/schemas/models/model-config-compatibility.v1.schema.json +16 -0
- package/tools/agents/providers/claude-post-deploy.mjs +35 -0
- package/tools/agents/providers/claude.mjs +3 -30
- package/tools/agents/providers/codex-post-deploy.mjs +13 -0
- package/tools/agents/providers/codex.mjs +2 -8
- package/tools/agents/providers/conductor-data-guard.mjs +61 -0
- package/tools/agents/providers/rule-delivery.mjs +3 -16
- package/tools/agents/providers/rule-presentation.mjs +38 -0
- package/tools/plugin/package-plugins.mjs +1 -1
- package/tools/ralph-external/lib/claude-adapter.mjs +2 -2
- package/tools/ralph-external/lib/codex-adapter.mjs +3 -3
- package/tools/ralph-external/lib/managed-worker-attempt.mjs +86 -0
- package/tools/ralph-external/lib/provider-adapter.mjs +11 -260
- package/tools/ralph-external/lib/session-attempt.mjs +85 -0
- package/tools/ralph-external/lib/strict-worker-json.mjs +190 -0
- package/tools/ralph-external/lib/worker-artifacts.mjs +209 -0
- package/tools/ralph-external/lib/worker-final.mjs +143 -0
- package/tools/ralph-external/lib/worker-inputs.mjs +55 -0
- package/tools/ralph-external/lib/worker-isolation.mjs +2 -44
- package/tools/ralph-external/lib/worker-launch-cli.mjs +47 -0
- package/tools/ralph-external/lib/worker-native-sandbox.mjs +83 -0
- package/tools/ralph-external/session-launcher.mjs +73 -217
- package/vscode-extension/schemas/aiwg.config.v1.json +161 -2
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Classify native continuation and retain the original execution assumptions.
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { writeFileSync } from 'node:fs';
|
|
4
|
+
import { join } from 'node:path';
|
|
5
|
+
import { importImpl } from '../../_resolve-impl.mjs';
|
|
6
|
+
|
|
7
|
+
const { qualificationDigest } = await importImpl(import.meta.url, 'models/qualification-digest.mjs');
|
|
8
|
+
const validSession = value => typeof value === 'string' && /^[a-zA-Z0-9][a-zA-Z0-9._:/-]{0,255}$/.test(value);
|
|
9
|
+
|
|
10
|
+
export function changedBindings(original, current) {
|
|
11
|
+
if (!original) return [];
|
|
12
|
+
return [...new Set([...Object.keys(original), ...Object.keys(current)])].sort()
|
|
13
|
+
.filter(key => qualificationDigest(original[key] ?? null) !== qualificationDigest(current[key] ?? null))
|
|
14
|
+
.map(key => ({ binding: key, previous: original[key] ?? null, current: current[key] ?? null }));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function classifySessionAttempt(adapter, { sessionId, original, current, sessionValid = true } = {}) {
|
|
18
|
+
const changes = changedBindings(original, current ?? {});
|
|
19
|
+
const requested = sessionId !== null && sessionId !== undefined;
|
|
20
|
+
const reason = !requested ? 'no-session' : !adapter.hasCapability('sessionResume') ? 'resume-unsupported'
|
|
21
|
+
: !validSession(sessionId) || !sessionValid || !original ? 'invalid-session'
|
|
22
|
+
: changes.length ? 'bindings-changed' : 'session-bound';
|
|
23
|
+
return { kind: reason === 'session-bound' ? 'continuation' : 'fresh', reason,
|
|
24
|
+
requestedSessionId: validSession(sessionId) ? sessionId : null,
|
|
25
|
+
changes, originalBindings: original ?? null, bindings: current ?? {} };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function persistCycleAttempt(owner, state, outputDir, attempt) {
|
|
29
|
+
state.currentAttempt = attempt;
|
|
30
|
+
owner.stateManager.save(state);
|
|
31
|
+
writeFileSync(join(outputDir, 'session-attempt.json'), JSON.stringify(attempt, null, 2), { mode: 0o600 });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function prepareNativeAttempt(owner, state, options) {
|
|
35
|
+
const adapter = owner.providerAdapter;
|
|
36
|
+
// Resolve again even when a requested continuation cannot be used.
|
|
37
|
+
const model = adapter.mapModel(options.model);
|
|
38
|
+
const prior = state.sessionBinding;
|
|
39
|
+
const sessionId = prior?.sessionId ?? (state.currentIteration > 1 ? state.sessionId : null);
|
|
40
|
+
const bindings = { provider: adapter.getName(), model, sessionId,
|
|
41
|
+
policyDigest: qualificationDigest({ budget: options.budget, maxTurns: options.maxTurns,
|
|
42
|
+
mcpConfig: options.mcpConfig, tools: options.tools, thinking: options.thinking,
|
|
43
|
+
workingDir: options.workingDir }) };
|
|
44
|
+
const attempt = { attemptId: randomUUID(), previousAttemptId: state.currentAttempt?.attemptId ?? null,
|
|
45
|
+
...adapter.classifySessionAttempt({ sessionId, original: prior?.originalBindings,
|
|
46
|
+
current: bindings, sessionValid: prior?.valid === true }), status: 'prepared' };
|
|
47
|
+
// Only Claude/Muse accept a caller-created session id. Other CLIs must
|
|
48
|
+
// report a native id before it can be used for continuation.
|
|
49
|
+
const nativeId = attempt.kind === 'continuation' ? sessionId
|
|
50
|
+
: ['claude', 'muse'].includes(adapter.getName()) ? randomUUID() : null;
|
|
51
|
+
attempt.bindings.sessionId = nativeId;
|
|
52
|
+
persistCycleAttempt(owner, state, options.outputDir, attempt);
|
|
53
|
+
return { ...options, model, sessionId: nativeId, resumeSession: attempt.kind === 'continuation' ? nativeId : null,
|
|
54
|
+
attempt };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function finishNativeAttempt(owner, state, options, result) {
|
|
58
|
+
let sessionId = options.sessionId;
|
|
59
|
+
let reportedId = null;
|
|
60
|
+
let conflictingSession = false;
|
|
61
|
+
let providerFailed = false;
|
|
62
|
+
// Session ids are accepted only from this attempt's captured provider output.
|
|
63
|
+
for (const line of String(result.stdoutBuffer ?? '').split('\n')) {
|
|
64
|
+
try {
|
|
65
|
+
const event = JSON.parse(line);
|
|
66
|
+
const observed = owner.providerAdapter.observeHealthEvent(event, { kind: 'stdout' }, {});
|
|
67
|
+
providerFailed ||= observed?.kind === 'error' || observed?.kind === 'terminal' && observed.success === false;
|
|
68
|
+
const candidate = observed?.sessionId ?? event.session_id ?? event.sessionId ?? event.stream?.id;
|
|
69
|
+
if (validSession(candidate)) {
|
|
70
|
+
conflictingSession ||= reportedId !== null && reportedId !== candidate
|
|
71
|
+
|| options.resumeSession != null && options.resumeSession !== candidate;
|
|
72
|
+
reportedId = candidate;
|
|
73
|
+
sessionId = candidate;
|
|
74
|
+
}
|
|
75
|
+
} catch { /* Non-JSON stdout has no native session binding. */ }
|
|
76
|
+
}
|
|
77
|
+
const bindings = { ...options.attempt.bindings, sessionId };
|
|
78
|
+
const valid = result.exitCode === 0 && !result.timedOut && !providerFailed && !conflictingSession && validSession(sessionId);
|
|
79
|
+
state.sessionBinding = { sessionId, valid,
|
|
80
|
+
originalBindings: options.attempt.kind === 'continuation' ? options.attempt.originalBindings : bindings };
|
|
81
|
+
options.attempt.status = 'finished';
|
|
82
|
+
options.attempt.bindings = bindings;
|
|
83
|
+
persistCycleAttempt(owner, state, options.outputDir, options.attempt);
|
|
84
|
+
result.attempt = options.attempt;
|
|
85
|
+
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// Parse bounded worker JSON without accepting duplicate object keys.
|
|
2
|
+
/**
|
|
3
|
+
* Thrown by {@link parseStrictWorkerJson} with a stable `code` so callers can
|
|
4
|
+
* map a scan failure to a specific contract-violation message without
|
|
5
|
+
* string-matching free-text.
|
|
6
|
+
*/
|
|
7
|
+
export class StrictJsonScanError extends Error {
|
|
8
|
+
constructor(code, message) {
|
|
9
|
+
super(message);
|
|
10
|
+
this.code = code;
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* RFC 8259 JSON parser used on every strict worker-final path (JSONL lines,
|
|
16
|
+
* `format: 'json'`, and the MCP `content[0].text`/top-level envelope) instead
|
|
17
|
+
* of `JSON.parse`. `JSON.parse` silently keeps the LAST value when an object
|
|
18
|
+
* repeats a member name — RFC 8259 §4 explicitly leaves this to the
|
|
19
|
+
* implementation, and V8 picks "last wins". That lets a forged worker final
|
|
20
|
+
* smuggle a second, hidden value past a first one: a trailing duplicate
|
|
21
|
+
* `"status":"completed"` can overwrite an earlier `"status":"failed"`, or a
|
|
22
|
+
* trailing duplicate `"type":"result"` can collapse a `"type":"message"`
|
|
23
|
+
* framing envelope into a bogus result in one line (#2890 BLOCKER 1+2).
|
|
24
|
+
*
|
|
25
|
+
* This scanner tracks member names per object independently at every
|
|
26
|
+
* nesting level (a sibling object may reuse a name the scanner already saw
|
|
27
|
+
* in its parent or an earlier sibling) and rejects any object that repeats
|
|
28
|
+
* one. Names are compared only after the scanner has fully decoded them —
|
|
29
|
+
* resolving `\"`, other single-character escapes, and `\uXXXX` escapes
|
|
30
|
+
* (including surrogate pairs, since JS string concatenation of two decoded
|
|
31
|
+
* UTF-16 code units forms the pair automatically) — so an escape-obfuscated
|
|
32
|
+
* collision (`"status"` vs `"status"`) is caught exactly like a literal
|
|
33
|
+
* one. A non-colliding unknown key, escaped or not, is left for the existing
|
|
34
|
+
* `additionalProperties: false` schema check to reject, unchanged.
|
|
35
|
+
*
|
|
36
|
+
* Nesting depth is bounded during the scan (not only afterward, by
|
|
37
|
+
* `boundedJson()`'s post-parse walk) so pathological depth fails fast with a
|
|
38
|
+
* clear `DEPTH` code instead of relying on a recursive-descent stack
|
|
39
|
+
* overflow to be caught as a generic syntax error.
|
|
40
|
+
*
|
|
41
|
+
* @param {string} text - Decoded JSON text (already UTF-8 validated by the caller)
|
|
42
|
+
* @param {number} maxDepth - Same bound as `WORKER_MAX_NESTING_DEPTH`; root value is depth 1
|
|
43
|
+
* @returns {unknown}
|
|
44
|
+
*/
|
|
45
|
+
export function parseStrictWorkerJson(text, maxDepth) {
|
|
46
|
+
const length = text.length;
|
|
47
|
+
let index = 0;
|
|
48
|
+
|
|
49
|
+
function fail(code, message) {
|
|
50
|
+
throw new StrictJsonScanError(code, `${message} (offset ${index})`);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function skipWhitespace() {
|
|
54
|
+
while (index < length) {
|
|
55
|
+
const code = text.charCodeAt(index);
|
|
56
|
+
if (code === 0x20 || code === 0x09 || code === 0x0a || code === 0x0d) index += 1;
|
|
57
|
+
else break;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function parseValue(depth) {
|
|
62
|
+
skipWhitespace();
|
|
63
|
+
if (index >= length) fail('SYNTAX', 'Unexpected end of JSON input');
|
|
64
|
+
const code = text.charCodeAt(index);
|
|
65
|
+
if (code === 0x7b) return parseObject(depth);
|
|
66
|
+
if (code === 0x5b) return parseArray(depth);
|
|
67
|
+
if (code === 0x22) return parseString();
|
|
68
|
+
if (code === 0x2d || (code >= 0x30 && code <= 0x39)) return parseNumber();
|
|
69
|
+
if (text.startsWith('true', index)) { index += 4; return true; }
|
|
70
|
+
if (text.startsWith('false', index)) { index += 5; return false; }
|
|
71
|
+
if (text.startsWith('null', index)) { index += 4; return null; }
|
|
72
|
+
fail('SYNTAX', 'Unexpected token');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function parseObject(depth) {
|
|
76
|
+
if (depth > maxDepth) fail('DEPTH', 'Worker nesting depth exceeds bound');
|
|
77
|
+
index += 1; // {
|
|
78
|
+
const result = {};
|
|
79
|
+
const seen = new Set();
|
|
80
|
+
skipWhitespace();
|
|
81
|
+
if (text.charCodeAt(index) === 0x7d) { index += 1; return result; }
|
|
82
|
+
for (;;) {
|
|
83
|
+
skipWhitespace();
|
|
84
|
+
if (text.charCodeAt(index) !== 0x22) fail('SYNTAX', 'Expected object member name');
|
|
85
|
+
const key = parseString();
|
|
86
|
+
if (seen.has(key)) fail('DUPLICATE', `Duplicate object member name ${JSON.stringify(key)}`);
|
|
87
|
+
seen.add(key);
|
|
88
|
+
skipWhitespace();
|
|
89
|
+
if (text.charCodeAt(index) !== 0x3a) fail('SYNTAX', 'Expected colon after object member name');
|
|
90
|
+
index += 1;
|
|
91
|
+
// Define, never assign: assignment would invoke the inherited __proto__
|
|
92
|
+
// setter, hiding the member from own-key validation and diverging from
|
|
93
|
+
// JSON.parse, which creates an ordinary own property.
|
|
94
|
+
Object.defineProperty(result, key, { value: parseValue(depth + 1), enumerable: true, writable: true, configurable: true });
|
|
95
|
+
skipWhitespace();
|
|
96
|
+
const delimiter = text.charCodeAt(index);
|
|
97
|
+
if (delimiter === 0x2c) { index += 1; continue; }
|
|
98
|
+
if (delimiter === 0x7d) { index += 1; break; }
|
|
99
|
+
fail('SYNTAX', 'Expected , or } in object');
|
|
100
|
+
}
|
|
101
|
+
return result;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function parseArray(depth) {
|
|
105
|
+
if (depth > maxDepth) fail('DEPTH', 'Worker nesting depth exceeds bound');
|
|
106
|
+
index += 1; // [
|
|
107
|
+
const result = [];
|
|
108
|
+
skipWhitespace();
|
|
109
|
+
if (text.charCodeAt(index) === 0x5d) { index += 1; return result; }
|
|
110
|
+
for (;;) {
|
|
111
|
+
result.push(parseValue(depth + 1));
|
|
112
|
+
skipWhitespace();
|
|
113
|
+
const delimiter = text.charCodeAt(index);
|
|
114
|
+
if (delimiter === 0x2c) { index += 1; continue; }
|
|
115
|
+
if (delimiter === 0x5d) { index += 1; break; }
|
|
116
|
+
fail('SYNTAX', 'Expected , or ] in array');
|
|
117
|
+
}
|
|
118
|
+
return result;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function parseString() {
|
|
122
|
+
index += 1; // opening quote
|
|
123
|
+
let value = '';
|
|
124
|
+
for (;;) {
|
|
125
|
+
if (index >= length) fail('SYNTAX', 'Unterminated string');
|
|
126
|
+
const code = text.charCodeAt(index);
|
|
127
|
+
if (code === 0x22) { index += 1; return value; }
|
|
128
|
+
if (code === 0x5c) {
|
|
129
|
+
index += 1;
|
|
130
|
+
const escape = text[index];
|
|
131
|
+
if (escape === undefined) fail('SYNTAX', 'Unterminated escape sequence');
|
|
132
|
+
switch (escape) {
|
|
133
|
+
case '"': value += '"'; index += 1; break;
|
|
134
|
+
case '\\': value += '\\'; index += 1; break;
|
|
135
|
+
case '/': value += '/'; index += 1; break;
|
|
136
|
+
case 'b': value += '\b'; index += 1; break;
|
|
137
|
+
case 'f': value += '\f'; index += 1; break;
|
|
138
|
+
case 'n': value += '\n'; index += 1; break;
|
|
139
|
+
case 'r': value += '\r'; index += 1; break;
|
|
140
|
+
case 't': value += '\t'; index += 1; break;
|
|
141
|
+
case 'u': {
|
|
142
|
+
const hex = text.slice(index + 1, index + 5);
|
|
143
|
+
if (!/^[0-9a-fA-F]{4}$/.test(hex)) fail('SYNTAX', 'Invalid unicode escape');
|
|
144
|
+
// Decoded code unit is appended as-is; two consecutive decoded
|
|
145
|
+
// surrogate halves recombine into one character automatically
|
|
146
|
+
// through normal JS UTF-16 string concatenation.
|
|
147
|
+
value += String.fromCharCode(parseInt(hex, 16));
|
|
148
|
+
index += 5;
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
151
|
+
default: fail('SYNTAX', 'Invalid escape sequence');
|
|
152
|
+
}
|
|
153
|
+
} else if (code < 0x20) {
|
|
154
|
+
fail('SYNTAX', 'Unescaped control character in string');
|
|
155
|
+
} else {
|
|
156
|
+
value += text[index];
|
|
157
|
+
index += 1;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function parseNumber() {
|
|
163
|
+
const start = index;
|
|
164
|
+
if (text.charCodeAt(index) === 0x2d) index += 1;
|
|
165
|
+
if (text.charCodeAt(index) === 0x30) {
|
|
166
|
+
index += 1;
|
|
167
|
+
} else if (text.charCodeAt(index) >= 0x31 && text.charCodeAt(index) <= 0x39) {
|
|
168
|
+
index += 1;
|
|
169
|
+
while (text.charCodeAt(index) >= 0x30 && text.charCodeAt(index) <= 0x39) index += 1;
|
|
170
|
+
} else fail('SYNTAX', 'Invalid number');
|
|
171
|
+
if (text.charCodeAt(index) === 0x2e) {
|
|
172
|
+
index += 1;
|
|
173
|
+
if (!(text.charCodeAt(index) >= 0x30 && text.charCodeAt(index) <= 0x39)) fail('SYNTAX', 'Invalid number fraction');
|
|
174
|
+
while (text.charCodeAt(index) >= 0x30 && text.charCodeAt(index) <= 0x39) index += 1;
|
|
175
|
+
}
|
|
176
|
+
if (text.charCodeAt(index) === 0x65 || text.charCodeAt(index) === 0x45) {
|
|
177
|
+
index += 1;
|
|
178
|
+
if (text.charCodeAt(index) === 0x2b || text.charCodeAt(index) === 0x2d) index += 1;
|
|
179
|
+
if (!(text.charCodeAt(index) >= 0x30 && text.charCodeAt(index) <= 0x39)) fail('SYNTAX', 'Invalid number exponent');
|
|
180
|
+
while (text.charCodeAt(index) >= 0x30 && text.charCodeAt(index) <= 0x39) index += 1;
|
|
181
|
+
}
|
|
182
|
+
return Number(text.slice(start, index));
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const result = parseValue(1);
|
|
186
|
+
skipWhitespace();
|
|
187
|
+
if (index !== length) fail('SYNTAX', 'Unexpected trailing characters after JSON value');
|
|
188
|
+
return result;
|
|
189
|
+
}
|
|
190
|
+
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
/** Materialize bounded artifacts; quarantine entries before checking ownership or removing them. */
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { open, mkdir, mkdtemp, lstat, readdir, unlink, rmdir, rename } from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { importImpl } from '../../_resolve-impl.mjs';
|
|
6
|
+
|
|
7
|
+
const { validateWorkerArtifacts } = await importImpl(import.meta.url, 'serve/worker-artifacts.mjs');
|
|
8
|
+
const directoryFlags = constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW;
|
|
9
|
+
const sameFile = (a, b) => a.dev === b.dev && a.ino === b.ino;
|
|
10
|
+
|
|
11
|
+
export async function materializeWorkerArtifacts(invocation, runDir) {
|
|
12
|
+
const artifacts = validateWorkerArtifacts(invocation);
|
|
13
|
+
const targets = new Map(artifacts.map(artifact => [artifact.name, artifact.location === 'workspace-relative'
|
|
14
|
+
? path.join(invocation.cwd, artifact.relativePath) : path.join(runDir, `artifact-${artifact.name}`)]));
|
|
15
|
+
const argv = invocation.argv.map(token => {
|
|
16
|
+
if (!token.includes('${artifact:')) return token;
|
|
17
|
+
const match = /^\$\{artifact:([A-Za-z0-9][A-Za-z0-9_-]{0,63})\}$/.exec(token);
|
|
18
|
+
if (!match || !targets.has(match[1])) throw new Error('Undeclared worker artifact placeholder');
|
|
19
|
+
return targets.get(match[1]);
|
|
20
|
+
});
|
|
21
|
+
const handles = [], created = [], directories = [], filenames = new Map();
|
|
22
|
+
let cleaned = false, quarantine, quarantinePath, sequence = 0;
|
|
23
|
+
const remember = (handle, filename) => { handles.push(handle); if (filename) filenames.set(handle, filename); return handle; };
|
|
24
|
+
// Linux can address relocated pinned parents. Other POSIX hosts use checked
|
|
25
|
+
// paths, retaining artifacts if a parent's identity cannot be recovered.
|
|
26
|
+
const childPath = (parent, name) => process.platform === 'linux'
|
|
27
|
+
? `/proc/self/fd/${parent.fd}/${name}` : path.join(filenames.get(parent), name);
|
|
28
|
+
async function pinDirectory(filename) {
|
|
29
|
+
if (!path.isAbsolute(filename) || path.normalize(filename) !== filename || filename.includes('\0')) throw new Error('Invalid worker artifact root');
|
|
30
|
+
let directory = remember(await open('/', directoryFlags), '/');
|
|
31
|
+
for (const component of filename.split('/').filter(Boolean)) {
|
|
32
|
+
const target = childPath(directory, component), resolved = path.join(filenames.get(directory), component);
|
|
33
|
+
directory = remember(await open(target, directoryFlags), resolved);
|
|
34
|
+
}
|
|
35
|
+
return directory;
|
|
36
|
+
}
|
|
37
|
+
async function rewalk(cwd, chain) {
|
|
38
|
+
let directory = await pinDirectory(cwd);
|
|
39
|
+
if (!sameFile(await directory.stat({ bigint: true }), chain[0].stat)) throw new Error('cwd changed');
|
|
40
|
+
for (const entry of chain.slice(1)) {
|
|
41
|
+
const target = childPath(directory, entry.component), filename = path.join(filenames.get(directory), entry.component);
|
|
42
|
+
const next = remember(await open(target, directoryFlags), filename);
|
|
43
|
+
if (!sameFile(await next.stat({ bigint: true }), entry.stat)
|
|
44
|
+
|| !sameFile(await lstat(target, { bigint: true }), entry.stat)) throw new Error('ancestor changed');
|
|
45
|
+
directory = next;
|
|
46
|
+
}
|
|
47
|
+
return directory;
|
|
48
|
+
}
|
|
49
|
+
async function workspaceParent(root, relativePath) {
|
|
50
|
+
let directory = root;
|
|
51
|
+
const rootStat = await root.stat({ bigint: true });
|
|
52
|
+
const quarantineStat = await quarantine.stat({ bigint: true });
|
|
53
|
+
if (rootStat.dev !== quarantineStat.dev) throw new Error('Worker artifact quarantine must be on the cwd filesystem');
|
|
54
|
+
const chain = [{ stat: rootStat }];
|
|
55
|
+
const components = relativePath.split('/').slice(0, -1);
|
|
56
|
+
for (const [i, component] of components.entries()) {
|
|
57
|
+
const target = childPath(directory, component);
|
|
58
|
+
const invocationDirectory = i === components.length - 1;
|
|
59
|
+
let made = false;
|
|
60
|
+
// Rewalk BEFORE mkdir too; a pinned parent alone is not cwd authority.
|
|
61
|
+
await rewalk(invocation.cwd, chain);
|
|
62
|
+
try { await mkdir(target, { mode: 0o700 }); made = true; }
|
|
63
|
+
catch (error) {
|
|
64
|
+
if (error.code !== 'EEXIST') throw error;
|
|
65
|
+
if (invocationDirectory) throw Object.assign(new Error('Worker artifact directory collision (existing entry preserved)'), { code: 'EEXIST' });
|
|
66
|
+
}
|
|
67
|
+
const before = await lstat(target, { bigint: true });
|
|
68
|
+
const next = remember(await open(target, directoryFlags), path.join(filenames.get(directory), component));
|
|
69
|
+
const stat = await next.stat({ bigint: true });
|
|
70
|
+
try {
|
|
71
|
+
if (!before.isDirectory() || !sameFile(before, stat)) throw new Error('directory substituted');
|
|
72
|
+
if (made && (await readdir(childPath(next, '.'))).length) throw new Error('new directory is not empty');
|
|
73
|
+
const checkedParent = await rewalk(invocation.cwd, chain);
|
|
74
|
+
if (!sameFile(await lstat(childPath(checkedParent, component), { bigint: true }), stat)
|
|
75
|
+
|| !sameFile(await lstat(target, { bigint: true }), stat)) throw new Error('directory substituted');
|
|
76
|
+
} catch (cause) { throw new Error('Worker artifact path changed; directory identity verification failed (unproven entry retained)', { cause }); }
|
|
77
|
+
if (stat.dev !== quarantineStat.dev) throw new Error('Worker artifact quarantine must share every parent filesystem');
|
|
78
|
+
// Only the verified invocation directory is owned. Leave shared prefixes.
|
|
79
|
+
if (invocationDirectory) directories.push({ target, stat, parent: directory });
|
|
80
|
+
chain.push({ component, stat });
|
|
81
|
+
directory = next;
|
|
82
|
+
}
|
|
83
|
+
return { directory, chain };
|
|
84
|
+
}
|
|
85
|
+
async function verifyPath(cwd, chain, leaf, identity) {
|
|
86
|
+
try {
|
|
87
|
+
// Re-open cwd from / as well: a relocated cwd descriptor is not authority.
|
|
88
|
+
const directory = await rewalk(cwd, chain);
|
|
89
|
+
const file = remember(await open(childPath(directory, leaf), constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK));
|
|
90
|
+
if (!sameFile(await file.stat({ bigint: true }), identity)) throw new Error('leaf changed');
|
|
91
|
+
} catch (cause) { throw new Error('Worker artifact path changed; containment verification failed', { cause }); }
|
|
92
|
+
}
|
|
93
|
+
async function quarantineEntry(entry, directory = false) {
|
|
94
|
+
if (process.platform !== 'linux') {
|
|
95
|
+
try {
|
|
96
|
+
const current = await pinDirectory(filenames.get(entry.parent));
|
|
97
|
+
if (!sameFile(await current.stat({ bigint: true }), await entry.parent.stat({ bigint: true }))) throw new Error('parent identity mismatch');
|
|
98
|
+
} catch (cause) { throw new Error('Worker artifact parent changed; artifact retained', { cause }); }
|
|
99
|
+
}
|
|
100
|
+
const captured = childPath(quarantine, `entry-${sequence++}`);
|
|
101
|
+
// The source can change up to rename; inspect only the privately captured entry.
|
|
102
|
+
await rename(entry.target, captured);
|
|
103
|
+
if (!sameFile(await lstat(captured, { bigint: true }), entry.stat)) {
|
|
104
|
+
throw new Error(`Worker artifact replacement preserved in quarantine: ${quarantinePath}/${path.basename(captured)}`);
|
|
105
|
+
}
|
|
106
|
+
if (directory) await rmdir(captured); else await unlink(captured);
|
|
107
|
+
}
|
|
108
|
+
const closeHandles = async () => {
|
|
109
|
+
const results = await Promise.allSettled(handles.reverse().map(handle => handle.close()));
|
|
110
|
+
const errors = results.filter(result => result.status === 'rejected').map(result => result.reason);
|
|
111
|
+
if (errors.length) throw new AggregateError(errors, 'Worker artifact descriptor close failed');
|
|
112
|
+
};
|
|
113
|
+
const cleanup = async ({ confirmedStop = true } = {}) => {
|
|
114
|
+
if (cleaned) return;
|
|
115
|
+
cleaned = true;
|
|
116
|
+
if (!confirmedStop) {
|
|
117
|
+
await closeHandles();
|
|
118
|
+
return { status: 'retained', reason: 'stop-proof-uncertain', paths: [...targets.values()] };
|
|
119
|
+
}
|
|
120
|
+
const errors = [];
|
|
121
|
+
try {
|
|
122
|
+
for (const [entries, directory] of [[created, false], [directories, true]]) {
|
|
123
|
+
for (const entry of entries.reverse()) {
|
|
124
|
+
try { await quarantineEntry(entry, directory); }
|
|
125
|
+
catch (error) { if (error.code !== 'ENOENT') errors.push(error); }
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
if (quarantinePath) {
|
|
129
|
+
try { await rmdir(quarantinePath); }
|
|
130
|
+
catch (error) { if (error.code !== 'ENOTEMPTY') errors.push(error); }
|
|
131
|
+
}
|
|
132
|
+
} finally {
|
|
133
|
+
try { await closeHandles(); } catch (error) { errors.push(error); }
|
|
134
|
+
}
|
|
135
|
+
if (errors.length) throw new AggregateError(errors, `Worker artifact cleanup failed; preserved entries: ${quarantinePath}`);
|
|
136
|
+
};
|
|
137
|
+
try {
|
|
138
|
+
const run = await pinDirectory(runDir);
|
|
139
|
+
const stat = await run.stat();
|
|
140
|
+
if (stat.uid !== process.getuid() || (stat.mode & 0o077)) throw new Error('Worker artifact directory must be private and owned');
|
|
141
|
+
if (artifacts.length) {
|
|
142
|
+
quarantinePath = await mkdtemp(childPath(run, '.artifact-quarantine-'));
|
|
143
|
+
quarantine = remember(await open(quarantinePath, directoryFlags), path.join(runDir, path.basename(quarantinePath)));
|
|
144
|
+
// Keep the diagnostic path meaningful after descriptors close.
|
|
145
|
+
quarantinePath = path.join(runDir, path.basename(quarantinePath));
|
|
146
|
+
}
|
|
147
|
+
let workspace;
|
|
148
|
+
for (const artifact of artifacts) {
|
|
149
|
+
let parent = run, chain;
|
|
150
|
+
let leaf = `artifact-${artifact.name}`;
|
|
151
|
+
if (artifact.location === 'workspace-relative') {
|
|
152
|
+
if (!workspace) {
|
|
153
|
+
const { realBeneath } = await importImpl(import.meta.url, 'serve/worker-invocation-paths.js');
|
|
154
|
+
if (await realBeneath(invocation.cwd, runDir)) throw new Error('Worker artifact quarantine must be outside worker-writable cwd');
|
|
155
|
+
workspace = await pinDirectory(invocation.cwd);
|
|
156
|
+
}
|
|
157
|
+
({ directory: parent, chain } = await workspaceParent(workspace, artifact.relativePath));
|
|
158
|
+
leaf = path.basename(artifact.relativePath);
|
|
159
|
+
}
|
|
160
|
+
const target = childPath(parent, leaf);
|
|
161
|
+
let handle;
|
|
162
|
+
try { handle = await open(target, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600); }
|
|
163
|
+
catch (error) {
|
|
164
|
+
if (error.code === 'EEXIST') throw Object.assign(new Error(`Worker artifact collision: ${targets.get(artifact.name)} (existing file preserved)`), { code: 'EEXIST' });
|
|
165
|
+
throw error;
|
|
166
|
+
}
|
|
167
|
+
// Keep the descriptor until cleanup so an unlinked inode cannot be reused.
|
|
168
|
+
remember(handle);
|
|
169
|
+
const identity = await handle.stat({ bigint: true });
|
|
170
|
+
created.push({ target, stat: identity, parent });
|
|
171
|
+
if (chain) await verifyPath(invocation.cwd, chain, leaf, identity);
|
|
172
|
+
await handle.writeFile(artifact.content); await handle.sync();
|
|
173
|
+
if (chain) await verifyPath(invocation.cwd, chain, leaf, identity);
|
|
174
|
+
}
|
|
175
|
+
return { argv, cleanup };
|
|
176
|
+
} catch (error) {
|
|
177
|
+
try { await cleanup(); } catch (cleanupError) { throw new AggregateError([error, cleanupError], `${error.message}; worker artifact creation and cleanup failed`); }
|
|
178
|
+
throw error;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Gate artifact removal on launcher-owned stop evidence and report retention. */
|
|
183
|
+
export class WorkerArtifactLifecycle {
|
|
184
|
+
constructor(invocation, invocationId = invocation.invocationId) {
|
|
185
|
+
this.invocationId = invocationId;
|
|
186
|
+
this.invocation = invocation;
|
|
187
|
+
this.confirmedStop = true; // Materialization precedes any dispatch.
|
|
188
|
+
this.result = null;
|
|
189
|
+
}
|
|
190
|
+
spawning() { this.confirmedStop = false; }
|
|
191
|
+
observe(name, value) {
|
|
192
|
+
if (name !== 'exit.json') return;
|
|
193
|
+
this.confirmedStop = value.invocationId === this.invocationId && value.termination === 'confirmed';
|
|
194
|
+
this.result = value;
|
|
195
|
+
if (this.invocation.artifacts?.length) value.artifactCleanup = this.status();
|
|
196
|
+
}
|
|
197
|
+
status() {
|
|
198
|
+
return this.confirmedStop ? { status: 'eligible' }
|
|
199
|
+
: { status: 'retained', reason: 'stop-proof-uncertain' };
|
|
200
|
+
}
|
|
201
|
+
failure(error) {
|
|
202
|
+
if (this.invocation.artifacts?.length && !this.confirmedStop) error.artifactCleanup = this.status();
|
|
203
|
+
}
|
|
204
|
+
async finish(artifacts) {
|
|
205
|
+
if (this.result?.termination === 'uncertain') this.confirmedStop = false;
|
|
206
|
+
if (this.result && this.invocation.artifacts?.length) this.result.artifactCleanup = this.status();
|
|
207
|
+
return artifacts?.cleanup({ confirmedStop: this.confirmedStop });
|
|
208
|
+
}
|
|
209
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
// Collect workerClaims candidates only after schema and on-disk artifact validation.
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import { constants } from 'node:fs';
|
|
4
|
+
import { lstat, open } from 'node:fs/promises';
|
|
5
|
+
import { isAbsolute, relative, resolve } from 'node:path';
|
|
6
|
+
import { importImpl } from '../../_resolve-impl.mjs';
|
|
7
|
+
import { boundedPrivateRead, withWorkerPath } from './worker-inputs.mjs';
|
|
8
|
+
import { parseStrictWorkerJson } from './strict-worker-json.mjs';
|
|
9
|
+
|
|
10
|
+
const digest = bytes => `sha256:${createHash('sha256').update(bytes).digest('hex')}`;
|
|
11
|
+
const escapePointer = key => key.replace(/~/g, '~0').replace(/\//g, '~1');
|
|
12
|
+
|
|
13
|
+
// The shared validator renders Ajv instancePath; required/additional fields
|
|
14
|
+
// identify the child in params, rendered in its existing diagnostic text.
|
|
15
|
+
function schemaDiagnostic(error) {
|
|
16
|
+
const match = /^(\/\S*) (must (?:have required|NOT have additional) propert(?:y|ies) '([^']+)'.*)$/.exec(error);
|
|
17
|
+
return match ? `${match[1] === '/' ? '' : match[1]}/${escapePointer(match[3])} ${match[2]}` : error;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
async function artifactErrors(claims, workdir) {
|
|
21
|
+
const errors = [];
|
|
22
|
+
for (const [index, artifact] of claims.artifacts.entries()) {
|
|
23
|
+
const pointer = `/artifacts/${index}`;
|
|
24
|
+
const filename = resolve(workdir, artifact.path);
|
|
25
|
+
const within = relative(workdir, filename);
|
|
26
|
+
if (!within || within === '..' || within.startsWith('../') || isAbsolute(within) || artifact.path.includes('\0')) {
|
|
27
|
+
errors.push(`${pointer}/path Artifact must name a file beneath the worker tree`);
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
try {
|
|
31
|
+
// Pin every ancestor and refuse symlinks, hardlinks and special files.
|
|
32
|
+
const actual = await withWorkerPath(filename, async target => {
|
|
33
|
+
const handle = await open(target, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
|
34
|
+
try {
|
|
35
|
+
const before = await handle.stat({ bigint: true });
|
|
36
|
+
if (!before.isFile() || before.nlink !== 1n) throw new Error('Artifact must be an unaliased regular file');
|
|
37
|
+
const hash = createHash('sha256');
|
|
38
|
+
for await (const chunk of handle.createReadStream({ autoClose: false })) hash.update(chunk);
|
|
39
|
+
const after = await handle.stat({ bigint: true });
|
|
40
|
+
if (before.size !== after.size || before.mtimeNs !== after.mtimeNs || before.ctimeNs !== after.ctimeNs) {
|
|
41
|
+
throw new Error('Artifact changed while hashing');
|
|
42
|
+
}
|
|
43
|
+
return `sha256:${hash.digest('hex')}`;
|
|
44
|
+
} finally { await handle.close(); }
|
|
45
|
+
});
|
|
46
|
+
if (actual !== artifact.digest) errors.push(`${pointer}/digest Artifact digest mismatch: expected ${artifact.digest}, actual ${actual}`);
|
|
47
|
+
} catch (error) {
|
|
48
|
+
errors.push(`${pointer}/path Cannot hash artifact in worker tree: ${error.message}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return errors;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function collectWorkerFinal({ path, workdir, driver, output, invocationId, result, inspect, handle, publish, outputFraming }) {
|
|
55
|
+
const { validateWorkerPayload, WORKER_MAX_JSON_BYTES, WORKER_MAX_NESTING_DEPTH, assertWorkerTerminal } =
|
|
56
|
+
await importImpl(import.meta.url, 'mission-protocol/index.js');
|
|
57
|
+
const scan = bytes => parseStrictWorkerJson(
|
|
58
|
+
new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(bytes), WORKER_MAX_NESTING_DEPTH);
|
|
59
|
+
let finalBytes, extracted = false, parsedClaims, parseError;
|
|
60
|
+
const evidence = () => ({ invocationId, path, digest: finalBytes ? digest(finalBytes) : null,
|
|
61
|
+
source: extracted ? 'adapter-extraction' : 'worker-output', candidate: true, verified: false });
|
|
62
|
+
try {
|
|
63
|
+
const stat = await lstat(path);
|
|
64
|
+
if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1 || stat.size > WORKER_MAX_JSON_BYTES) throw new Error('Invalid final output');
|
|
65
|
+
await inspect(path, 'file');
|
|
66
|
+
let claims;
|
|
67
|
+
if (stat.size === 0 && driver.extractFinal) {
|
|
68
|
+
claims = driver.extractFinal(output.toString('utf8'));
|
|
69
|
+
if (claims === null || claims === undefined || claims.success === false) throw new Error('Missing or unsuccessful final extraction');
|
|
70
|
+
const bytes = JSON.stringify({ invocationId, source: 'adapter-extraction', candidate: true, verified: false, result: claims });
|
|
71
|
+
if (Buffer.byteLength(bytes) > WORKER_MAX_JSON_BYTES) throw new Error('Final exceeds bound');
|
|
72
|
+
await handle.writeFile(bytes); extracted = true;
|
|
73
|
+
}
|
|
74
|
+
finalBytes = await boundedPrivateRead(path, WORKER_MAX_JSON_BYTES, inspect, true);
|
|
75
|
+
if (!finalBytes.length) throw new Error('Missing final output');
|
|
76
|
+
if (extracted) {
|
|
77
|
+
if (driver.parseWorkerFinal) {
|
|
78
|
+
try {
|
|
79
|
+
// The adapter owns framing (including text preambles) and strict
|
|
80
|
+
// decoding. Invoke it once with the launcher's actual exit status.
|
|
81
|
+
parsedClaims = await driver.parseWorkerFinal(new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(output), {
|
|
82
|
+
format: outputFraming === 'jsonl' ? 'jsonl' : 'json', observation: { transport: 'shell',
|
|
83
|
+
status: result.exitCode === 0 && !result.errorCode && !result.timedOut ? 'completed' : 'failed',
|
|
84
|
+
termination: result.termination, exitCode: result.exitCode },
|
|
85
|
+
// Content validation precedes the independent terminal assertion
|
|
86
|
+
// below, so degraded termination cannot hide artifact mismatches.
|
|
87
|
+
assertTerminal: () => {},
|
|
88
|
+
});
|
|
89
|
+
claims = parsedClaims;
|
|
90
|
+
} catch (error) {
|
|
91
|
+
parseError = error;
|
|
92
|
+
// A decoded claims document must satisfy the schema. A non-claims
|
|
93
|
+
// text candidate retains exit-only evidence and a rejected strict
|
|
94
|
+
// parse; it can never authorize health completion or recovery.
|
|
95
|
+
if (/^Invalid workerClaims:|Duplicate worker JSON|nesting depth|byte bound/.test(error.message)) throw error;
|
|
96
|
+
claims = undefined;
|
|
97
|
+
}
|
|
98
|
+
} else {
|
|
99
|
+
// Without a strict adapter hook, scan original bytes before trusting
|
|
100
|
+
// synchronous extraction, which may have discarded duplicate keys.
|
|
101
|
+
if (outputFraming === 'jsonl') {
|
|
102
|
+
const text = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true }).decode(output);
|
|
103
|
+
for (const line of text.split('\n')) if (line.trim()) scan(Buffer.from(line));
|
|
104
|
+
} else scan(output);
|
|
105
|
+
}
|
|
106
|
+
} else {
|
|
107
|
+
const decoded = scan(finalBytes);
|
|
108
|
+
claims = driver.extractFinal ? driver.extractFinal(finalBytes.toString('utf8')) : decoded;
|
|
109
|
+
}
|
|
110
|
+
if (!parseError) {
|
|
111
|
+
const validation = validateWorkerPayload('workerClaims', claims);
|
|
112
|
+
const errors = validation.errors.map(schemaDiagnostic);
|
|
113
|
+
if (validation.valid) errors.push(...await artifactErrors(claims, workdir));
|
|
114
|
+
if (errors.length) {
|
|
115
|
+
result.finalOutputErrors = errors;
|
|
116
|
+
throw new Error('Invalid workerClaims');
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
} catch (error) {
|
|
120
|
+
result.errorCode ??= 'FINAL_OUTPUT_INVALID';
|
|
121
|
+
// Preserve the existing missing/empty-final failure: there is no
|
|
122
|
+
// candidate document (and therefore no schema pointer) to record.
|
|
123
|
+
if (!finalBytes?.length) throw error;
|
|
124
|
+
// Adapter decoding uses assertValidWorkerPayload's existing rendered
|
|
125
|
+
// diagnostics; retain its pointers when validation fails inside it.
|
|
126
|
+
const schemaErrors = /^Invalid workerClaims: (.*)\.$/.exec(error.message)?.[1];
|
|
127
|
+
result.finalOutputErrors ??= schemaErrors ? schemaErrors.split('; ').map(schemaDiagnostic) : [`/ ${error.message}`];
|
|
128
|
+
await publish('final-evidence.json', { ...evidence(), terminalSuccess: false,
|
|
129
|
+
errorCode: 'FINAL_OUTPUT_INVALID', errors: result.finalOutputErrors });
|
|
130
|
+
throw error;
|
|
131
|
+
}
|
|
132
|
+
await publish('final-evidence.json', { ...evidence(),
|
|
133
|
+
terminalSuccess: result.exitCode === 0 && !result.timedOut && !result.errorCode });
|
|
134
|
+
// Reuse the adapter's content result without a second hook invocation.
|
|
135
|
+
// Keep the existing health path's terminal assertion independent of content.
|
|
136
|
+
const parseFinal = async (bytes, options) => {
|
|
137
|
+
if (!extracted) return driver.parseWorkerFinal(bytes, options);
|
|
138
|
+
if (parseError) throw parseError;
|
|
139
|
+
assertWorkerTerminal(options.observation);
|
|
140
|
+
return parsedClaims;
|
|
141
|
+
};
|
|
142
|
+
return { finalBytes, extracted, parseFinal };
|
|
143
|
+
}
|