@aiwg/cli 2026.10.9 → 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.
Files changed (91) hide show
  1. package/agentic/code/providers/model-capabilities.v1.json +197 -3
  2. package/bin/aiwg.mjs +8 -0
  3. package/dist/src/cli/handlers/mc-admission.js +39 -0
  4. package/dist/src/cli/handlers/mc.js +2 -31
  5. package/dist/src/cli/handlers/models-help.js +52 -0
  6. package/dist/src/cli/handlers/models.js +11 -81
  7. package/dist/src/cli/handlers/use-addon-profile.js +72 -0
  8. package/dist/src/cli/handlers/use.js +191 -230
  9. package/dist/src/cli/ui.js +1 -0
  10. package/dist/src/config/aiwg-config.js +38 -184
  11. package/dist/src/config/cli-help.js +54 -0
  12. package/dist/src/config/cli.js +5 -69
  13. package/dist/src/config/config-normalization.js +10 -0
  14. package/dist/src/config/worker-activation-gate.js +26 -0
  15. package/dist/src/config/worker-adoption-cli.js +102 -0
  16. package/dist/src/config/worker-adoption.js +368 -0
  17. package/dist/src/config/worker-config-digest.js +14 -0
  18. package/dist/src/config/worker-group-templates.js +150 -0
  19. package/dist/src/config/worker-guidance-warning.js +28 -0
  20. package/dist/src/config/worker-owner-lock.js +152 -0
  21. package/dist/src/config/worker-parallelism.js +154 -0
  22. package/dist/src/config/worker-write.js +75 -0
  23. package/dist/src/decision/telemetry/exporter.js +2 -1
  24. package/dist/src/decision/telemetry/index.js +1 -0
  25. package/dist/src/decision/telemetry/mapping.js +43 -0
  26. package/dist/src/decision/telemetry/otlp-http.js +5 -2
  27. package/dist/src/decision/telemetry/redaction.js +6 -1
  28. package/dist/src/decision/telemetry/types.js +3 -0
  29. package/dist/src/decision/telemetry/worker.js +69 -0
  30. package/dist/src/lint/rule-activation.js +15 -7
  31. package/dist/src/lint/runner.js +2 -2
  32. package/dist/src/mcp/server.mjs +2 -11
  33. package/dist/src/mcp/stdio-lifecycle.mjs +27 -0
  34. package/dist/src/mcp/tools/worker-failure-diagnostics.mjs +9 -0
  35. package/dist/src/mcp/tools/worker-invoke.mjs +6 -6
  36. package/dist/src/mission-protocol/codecs.js +8 -38
  37. package/dist/src/mission-protocol/worker-contract-diagnostics.js +62 -0
  38. package/dist/src/mission-protocol/worker-invocation-types.js +2 -0
  39. package/dist/src/models/conductor-data-policy.d.mts +8 -0
  40. package/dist/src/models/conductor-data-policy.mjs +190 -0
  41. package/dist/src/models/config-loader.js +19 -3
  42. package/dist/src/models/config-writer.js +27 -0
  43. package/dist/src/models/model-capabilities.v1.json +197 -3
  44. package/dist/src/models/provider-policy.js +7 -155
  45. package/dist/src/models/resolver.js +2 -293
  46. package/dist/src/models/worker-artifact-binding.mjs +12 -0
  47. package/dist/src/models/worker-direct-readiness-policy.js +155 -0
  48. package/dist/src/models/worker-direct-readiness.js +9 -218
  49. package/dist/src/models/worker-health-readiness.js +105 -26
  50. package/dist/src/models/worker-installation.mjs +12 -14
  51. package/dist/src/models/worker-member-references.js +64 -0
  52. package/dist/src/models/worker-plan.js +317 -0
  53. package/dist/src/models/worker-selection-policy.js +217 -0
  54. package/dist/src/models/wrapper-deployment.js +1 -1
  55. package/dist/src/serve/shared-host-admission-checks.js +116 -0
  56. package/dist/src/serve/shared-host-scheduler.js +25 -107
  57. package/dist/src/serve/shared-host-starvation.js +73 -0
  58. package/dist/src/serve/worker-artifacts.d.mts +4 -0
  59. package/dist/src/serve/worker-artifacts.mjs +56 -0
  60. package/dist/src/serve/worker-command-request.js +68 -0
  61. package/dist/src/serve/worker-invocation-paths.js +144 -0
  62. package/dist/src/serve/worker-invocation-preparation.js +169 -0
  63. package/dist/src/serve/worker-invocation.js +24 -291
  64. package/dist/src/serve/worker-stdin-frame.d.mts +2 -2
  65. package/dist/src/serve/worker-stdin-frame.mjs +6 -6
  66. package/dist/src/smiths/context-pipeline/parallelism-section.js +19 -7
  67. package/package.json +9 -1
  68. package/schemas/mission-v1.schema.json +20 -19
  69. package/schemas/models/model-config-compatibility.v1.schema.json +16 -0
  70. package/tools/agents/providers/claude-post-deploy.mjs +35 -0
  71. package/tools/agents/providers/claude.mjs +3 -30
  72. package/tools/agents/providers/codex-post-deploy.mjs +13 -0
  73. package/tools/agents/providers/codex.mjs +2 -8
  74. package/tools/agents/providers/conductor-data-guard.mjs +61 -0
  75. package/tools/agents/providers/rule-delivery.mjs +3 -16
  76. package/tools/agents/providers/rule-presentation.mjs +38 -0
  77. package/tools/plugin/package-plugins.mjs +1 -1
  78. package/tools/ralph-external/lib/claude-adapter.mjs +2 -2
  79. package/tools/ralph-external/lib/codex-adapter.mjs +3 -3
  80. package/tools/ralph-external/lib/managed-worker-attempt.mjs +86 -0
  81. package/tools/ralph-external/lib/provider-adapter.mjs +11 -260
  82. package/tools/ralph-external/lib/session-attempt.mjs +85 -0
  83. package/tools/ralph-external/lib/strict-worker-json.mjs +190 -0
  84. package/tools/ralph-external/lib/worker-artifacts.mjs +209 -0
  85. package/tools/ralph-external/lib/worker-final.mjs +143 -0
  86. package/tools/ralph-external/lib/worker-inputs.mjs +55 -0
  87. package/tools/ralph-external/lib/worker-isolation.mjs +2 -44
  88. package/tools/ralph-external/lib/worker-launch-cli.mjs +47 -0
  89. package/tools/ralph-external/lib/worker-native-sandbox.mjs +83 -0
  90. package/tools/ralph-external/session-launcher.mjs +73 -217
  91. package/vscode-extension/schemas/aiwg.config.v1.json +161 -2
@@ -1,220 +1,14 @@
1
- /**
2
- * Provider Adapter Base Class for External Ralph Loop
3
- *
4
- * Defines the interface that all CLI provider adapters must implement.
5
- * Each adapter wraps a specific agentic coding CLI (claude, codex, etc.)
6
- * and handles binary resolution, argument construction, model mapping,
7
- * and output parsing.
8
- *
9
- * @implements Plan: Multi-Provider Support for External Ralph Loop
10
- */
11
-
12
1
  import { spawn } from 'child_process';
13
2
  import { importImpl } from '../../_resolve-impl.mjs';
14
3
  import { createWorkerHealthObserver, observeProviderHealthEvent } from './worker-health.mjs';
15
4
 
16
- /**
17
- * Thrown by {@link parseStrictWorkerJson} with a stable `code` so callers can
18
- * map a scan failure to a specific contract-violation message without
19
- * string-matching free-text.
20
- */
21
- class StrictJsonScanError extends Error {
22
- constructor(code, message) {
23
- super(message);
24
- this.code = code;
25
- }
26
- }
27
-
28
- /**
29
- * RFC 8259 JSON parser used on every strict worker-final path (JSONL lines,
30
- * `format: 'json'`, and the MCP `content[0].text`/top-level envelope) instead
31
- * of `JSON.parse`. `JSON.parse` silently keeps the LAST value when an object
32
- * repeats a member name — RFC 8259 §4 explicitly leaves this to the
33
- * implementation, and V8 picks "last wins". That lets a forged worker final
34
- * smuggle a second, hidden value past a first one: a trailing duplicate
35
- * `"status":"completed"` can overwrite an earlier `"status":"failed"`, or a
36
- * trailing duplicate `"type":"result"` can collapse a `"type":"message"`
37
- * framing envelope into a bogus result in one line (#2890 BLOCKER 1+2).
38
- *
39
- * This scanner tracks member names per object independently at every
40
- * nesting level (a sibling object may reuse a name the scanner already saw
41
- * in its parent or an earlier sibling) and rejects any object that repeats
42
- * one. Names are compared only after the scanner has fully decoded them —
43
- * resolving `\"`, other single-character escapes, and `\uXXXX` escapes
44
- * (including surrogate pairs, since JS string concatenation of two decoded
45
- * UTF-16 code units forms the pair automatically) — so an escape-obfuscated
46
- * collision (`"status"` vs `"status"`) is caught exactly like a literal
47
- * one. A non-colliding unknown key, escaped or not, is left for the existing
48
- * `additionalProperties: false` schema check to reject, unchanged.
49
- *
50
- * Nesting depth is bounded during the scan (not only afterward, by
51
- * `boundedJson()`'s post-parse walk) so pathological depth fails fast with a
52
- * clear `DEPTH` code instead of relying on a recursive-descent stack
53
- * overflow to be caught as a generic syntax error.
54
- *
55
- * @param {string} text - Decoded JSON text (already UTF-8 validated by the caller)
56
- * @param {number} maxDepth - Same bound as `WORKER_MAX_NESTING_DEPTH`; root value is depth 1
57
- * @returns {unknown}
58
- */
59
- function parseStrictWorkerJson(text, maxDepth) {
60
- const length = text.length;
61
- let index = 0;
62
-
63
- function fail(code, message) {
64
- throw new StrictJsonScanError(code, `${message} (offset ${index})`);
65
- }
66
-
67
- function skipWhitespace() {
68
- while (index < length) {
69
- const code = text.charCodeAt(index);
70
- if (code === 0x20 || code === 0x09 || code === 0x0a || code === 0x0d) index += 1;
71
- else break;
72
- }
73
- }
74
-
75
- function parseValue(depth) {
76
- skipWhitespace();
77
- if (index >= length) fail('SYNTAX', 'Unexpected end of JSON input');
78
- const code = text.charCodeAt(index);
79
- if (code === 0x7b) return parseObject(depth);
80
- if (code === 0x5b) return parseArray(depth);
81
- if (code === 0x22) return parseString();
82
- if (code === 0x2d || (code >= 0x30 && code <= 0x39)) return parseNumber();
83
- if (text.startsWith('true', index)) { index += 4; return true; }
84
- if (text.startsWith('false', index)) { index += 5; return false; }
85
- if (text.startsWith('null', index)) { index += 4; return null; }
86
- fail('SYNTAX', 'Unexpected token');
87
- }
88
-
89
- function parseObject(depth) {
90
- if (depth > maxDepth) fail('DEPTH', 'Worker nesting depth exceeds bound');
91
- index += 1; // {
92
- const result = {};
93
- const seen = new Set();
94
- skipWhitespace();
95
- if (text.charCodeAt(index) === 0x7d) { index += 1; return result; }
96
- for (;;) {
97
- skipWhitespace();
98
- if (text.charCodeAt(index) !== 0x22) fail('SYNTAX', 'Expected object member name');
99
- const key = parseString();
100
- if (seen.has(key)) fail('DUPLICATE', `Duplicate object member name ${JSON.stringify(key)}`);
101
- seen.add(key);
102
- skipWhitespace();
103
- if (text.charCodeAt(index) !== 0x3a) fail('SYNTAX', 'Expected colon after object member name');
104
- index += 1;
105
- // Define, never assign: assignment would invoke the inherited __proto__
106
- // setter, hiding the member from own-key validation and diverging from
107
- // JSON.parse, which creates an ordinary own property.
108
- Object.defineProperty(result, key, { value: parseValue(depth + 1), enumerable: true, writable: true, configurable: true });
109
- skipWhitespace();
110
- const delimiter = text.charCodeAt(index);
111
- if (delimiter === 0x2c) { index += 1; continue; }
112
- if (delimiter === 0x7d) { index += 1; break; }
113
- fail('SYNTAX', 'Expected , or } in object');
114
- }
115
- return result;
116
- }
117
-
118
- function parseArray(depth) {
119
- if (depth > maxDepth) fail('DEPTH', 'Worker nesting depth exceeds bound');
120
- index += 1; // [
121
- const result = [];
122
- skipWhitespace();
123
- if (text.charCodeAt(index) === 0x5d) { index += 1; return result; }
124
- for (;;) {
125
- result.push(parseValue(depth + 1));
126
- skipWhitespace();
127
- const delimiter = text.charCodeAt(index);
128
- if (delimiter === 0x2c) { index += 1; continue; }
129
- if (delimiter === 0x5d) { index += 1; break; }
130
- fail('SYNTAX', 'Expected , or ] in array');
131
- }
132
- return result;
133
- }
134
-
135
- function parseString() {
136
- index += 1; // opening quote
137
- let value = '';
138
- for (;;) {
139
- if (index >= length) fail('SYNTAX', 'Unterminated string');
140
- const code = text.charCodeAt(index);
141
- if (code === 0x22) { index += 1; return value; }
142
- if (code === 0x5c) {
143
- index += 1;
144
- const escape = text[index];
145
- if (escape === undefined) fail('SYNTAX', 'Unterminated escape sequence');
146
- switch (escape) {
147
- case '"': value += '"'; index += 1; break;
148
- case '\\': value += '\\'; index += 1; break;
149
- case '/': value += '/'; index += 1; break;
150
- case 'b': value += '\b'; index += 1; break;
151
- case 'f': value += '\f'; index += 1; break;
152
- case 'n': value += '\n'; index += 1; break;
153
- case 'r': value += '\r'; index += 1; break;
154
- case 't': value += '\t'; index += 1; break;
155
- case 'u': {
156
- const hex = text.slice(index + 1, index + 5);
157
- if (!/^[0-9a-fA-F]{4}$/.test(hex)) fail('SYNTAX', 'Invalid unicode escape');
158
- // Decoded code unit is appended as-is; two consecutive decoded
159
- // surrogate halves recombine into one character automatically
160
- // through normal JS UTF-16 string concatenation.
161
- value += String.fromCharCode(parseInt(hex, 16));
162
- index += 5;
163
- break;
164
- }
165
- default: fail('SYNTAX', 'Invalid escape sequence');
166
- }
167
- } else if (code < 0x20) {
168
- fail('SYNTAX', 'Unescaped control character in string');
169
- } else {
170
- value += text[index];
171
- index += 1;
172
- }
173
- }
174
- }
175
-
176
- function parseNumber() {
177
- const start = index;
178
- if (text.charCodeAt(index) === 0x2d) index += 1;
179
- if (text.charCodeAt(index) === 0x30) {
180
- index += 1;
181
- } else if (text.charCodeAt(index) >= 0x31 && text.charCodeAt(index) <= 0x39) {
182
- index += 1;
183
- while (text.charCodeAt(index) >= 0x30 && text.charCodeAt(index) <= 0x39) index += 1;
184
- } else fail('SYNTAX', 'Invalid number');
185
- if (text.charCodeAt(index) === 0x2e) {
186
- index += 1;
187
- if (!(text.charCodeAt(index) >= 0x30 && text.charCodeAt(index) <= 0x39)) fail('SYNTAX', 'Invalid number fraction');
188
- while (text.charCodeAt(index) >= 0x30 && text.charCodeAt(index) <= 0x39) index += 1;
189
- }
190
- if (text.charCodeAt(index) === 0x65 || text.charCodeAt(index) === 0x45) {
191
- index += 1;
192
- if (text.charCodeAt(index) === 0x2b || text.charCodeAt(index) === 0x2d) index += 1;
193
- if (!(text.charCodeAt(index) >= 0x30 && text.charCodeAt(index) <= 0x39)) fail('SYNTAX', 'Invalid number exponent');
194
- while (text.charCodeAt(index) >= 0x30 && text.charCodeAt(index) <= 0x39) index += 1;
195
- }
196
- return Number(text.slice(start, index));
197
- }
198
-
199
- const result = parseValue(1);
200
- skipWhitespace();
201
- if (index !== length) fail('SYNTAX', 'Unexpected trailing characters after JSON value');
202
- return result;
203
- }
5
+ import { parseStrictWorkerJson, StrictJsonScanError } from './strict-worker-json.mjs';
6
+ import { classifySessionAttempt } from './session-attempt.mjs';
204
7
 
205
8
  const installedOwner = await importImpl(import.meta.url, 'models/worker-installation.mjs');
206
9
  export const { workerNegativeFixtureSuite, workerNegativeFixtureDigest, registerProvider, createProvider, listProviders, hasProvider } = installedOwner;
207
10
 
208
- /**
209
- * Whether the optional muse exec Ralph adapter is registered.
210
- * Enabled by default; set `AIWG_MUSE_RALPH_ENABLED` to `0`, `false`, `no`,
211
- * or `off` to disable the adapter without affecting
212
- * `aiwg use --provider muse` (the deploy/writer path lives in `src/` and
213
- * never consults this registry). See
214
- * `tools/ralph-external/lib/muse-adapter.mjs` (#230).
215
- *
216
- * @returns {boolean}
217
- */
11
+ /** The optional Muse adapter is independent of provider deployment. */
218
12
  export function isMuseRalphEnabled() {
219
13
  const raw = String(process.env.AIWG_MUSE_RALPH_ENABLED ?? '').trim().toLowerCase();
220
14
  return !['0', 'false', 'no', 'off'].includes(raw);
@@ -661,24 +455,15 @@ export class ProviderAdapter {
661
455
  return null;
662
456
  }
663
457
 
664
- /**
665
- * Check if a capability is supported, and optionally log a warning.
666
- *
667
- * @param {string} capability - Capability name from ProviderCapabilities
668
- * @param {string} [context] - Context for the warning message
669
- * @returns {boolean}
670
- */
458
+ classifySessionAttempt(request) {
459
+ return classifySessionAttempt(this, request);
460
+ }
461
+
671
462
  hasCapability(capability) {
672
463
  const caps = this.getCapabilities();
673
464
  return !!caps[capability];
674
465
  }
675
466
 
676
- /**
677
- * Log a capability warning when a feature is not supported.
678
- *
679
- * @param {string} capability - Capability name
680
- * @param {string} feature - Human-readable feature description
681
- */
682
467
  warnUnsupported(capability, feature) {
683
468
  if (!this.hasCapability(capability)) {
684
469
  console.warn(
@@ -687,52 +472,18 @@ export class ProviderAdapter {
687
472
  }
688
473
  }
689
474
  }
690
-
691
- // ============================================================================
692
- // Provider Registry & Factory
693
- // ============================================================================
694
-
695
475
  export async function loadInstalledWorkerAdapters(request) {
696
476
  await ensureProvidersRegistered();
697
477
  return installedOwner.loadInstalledWorkerAdapters(request);
698
478
  }
699
479
 
700
- // ============================================================================
701
- // Auto-register built-in providers on import
702
- // ============================================================================
703
-
704
- // Lazy imports to avoid circular dependencies - adapters register themselves
705
- // when their modules are loaded. We trigger that here.
480
+ // Adapters register themselves; keep imports lazy to avoid registry cycles.
706
481
  async function registerBuiltinProviders() {
707
- try {
708
- await import('./claude-adapter.mjs');
709
- } catch { /* ignore if not found */ }
710
- try {
711
- await import('./codex-adapter.mjs');
712
- } catch { /* ignore if not found */ }
713
- try {
714
- await import('./opencode-adapter.mjs');
715
- } catch { /* ignore if not found */ }
716
- try {
717
- await import('./factory-adapter.mjs');
718
- } catch { /* ignore if not found */ }
719
- try {
720
- await import('./pi-adapter.mjs');
721
- await import('./omp-adapter.mjs');
722
- } catch { /* ignore if not found */ }
723
- try {
724
- await import('./deepseek-harness-adapter.mjs');
725
- } catch { /* ignore if not found */ }
726
- // #230: optional / post-experimental. Disabled via AIWG_MUSE_RALPH_ENABLED=0
727
- // without affecting `aiwg use --provider muse`.
728
- if (isMuseRalphEnabled()) {
729
- try {
730
- await import('./muse-adapter.mjs');
731
- } catch { /* ignore if not found */ }
482
+ for (const name of ['claude', 'codex', 'opencode', 'factory', 'pi', 'omp', 'deepseek-harness',
483
+ ...(isMuseRalphEnabled() ? ['muse'] : [])]) {
484
+ try { await import(`./${name}-adapter.mjs`); } catch { /* Optional adapter unavailable. */ }
732
485
  }
733
486
  }
734
-
735
- // Run registration — store promise so callers can await it
736
487
  const _registrationPromise = registerBuiltinProviders();
737
488
 
738
489
  /**
@@ -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
+