@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
@@ -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
+ }
@@ -0,0 +1,55 @@
1
+ // Read bounded worker inputs through pinned, no-follow directory handles.
2
+ import { constants } from 'node:fs';
3
+ import { open } from 'node:fs/promises';
4
+ import { isAbsolute } from 'node:path';
5
+
6
+ // Linux directory handles pin each ancestor. O_NOFOLLOW on a pathname alone
7
+ // cannot protect an ancestor that the worker replaces after inspection.
8
+ export async function withWorkerPath(filename, operation) {
9
+ if (!isAbsolute(filename) || filename.includes('\0') || filename.split('/').includes('..')) throw new Error('Invalid worker path');
10
+ const components = filename.split('/').filter(Boolean);
11
+ const leaf = components.pop();
12
+ let directory = await open('/', constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
13
+ try {
14
+ for (const component of components) {
15
+ const next = await open(`/proc/self/fd/${directory.fd}/${component}`, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
16
+ await directory.close(); directory = next;
17
+ }
18
+ return await operation(`/proc/self/fd/${directory.fd}/${leaf ?? '.'}`);
19
+ } finally { await directory.close(); }
20
+ }
21
+
22
+ // #2915 review 4 (finding 4): `privateOutput: true` REPAIRS a loose mode
23
+ // (chmod 0600) rather than rejecting it — correct for the worker's own
24
+ // final-output file, which the launcher itself created and is free to
25
+ // re-tighten. `rejectInsecureMode: true` additionally REFUSES a mode other
26
+ // than exactly 0600 instead of repairing it, for evidence this launcher
27
+ // did NOT create moments ago (retry predecessor metadata) — a 0666 file
28
+ // there is itself a signal worth refusing on, not silently normalizing
29
+ // away.
30
+ export async function boundedPrivateRead(filename, limit, inspect, privateOutput = false, rejectInsecureMode = false) {
31
+ if (!await inspect(filename, 'file')) throw new Error('Required worker input is missing');
32
+ const handle = await withWorkerPath(filename, target => open(target, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK));
33
+ try {
34
+ const stat = await handle.stat();
35
+ if (!stat.isFile() || stat.nlink !== 1 || stat.size > limit) throw new Error('Worker input must be a bounded unaliased regular file');
36
+ if (privateOutput) {
37
+ if (stat.uid !== process.getuid()) throw new Error('Worker final must be conductor-owned');
38
+ if (rejectInsecureMode) {
39
+ if ((stat.mode & 0o777) !== 0o600) throw new Error('Worker input mode must already be exactly 0600; insecure modes are refused, not repaired');
40
+ } else {
41
+ await handle.chmod(0o600);
42
+ }
43
+ }
44
+ const buffer = Buffer.alloc(limit + 1);
45
+ let length = 0;
46
+ while (length < buffer.length) {
47
+ const { bytesRead } = await handle.read(buffer, length, buffer.length - length, length);
48
+ if (!bytesRead) break;
49
+ length += bytesRead;
50
+ }
51
+ if (length > limit) throw new Error('Worker input exceeds bound');
52
+ return buffer.subarray(0, length);
53
+ } finally { await handle.close(); }
54
+ }
55
+
@@ -42,50 +42,8 @@ import { buildSystemdClientEnv, resolveSystemdClient, resolveSystemdRun, CONNECT
42
42
 
43
43
  const UNIT_SAFE = /[^A-Za-z0-9:_.@-]/g;
44
44
  const transportBootstrapVariables = ['NODE_OPTIONS', 'NODE_PATH', 'LD_PRELOAD', 'LD_LIBRARY_PATH', 'LD_AUDIT', 'LD_DEBUG', 'LD_DEBUG_OUTPUT'];
45
- const nativeSandboxProbes = new Map();
46
- let nativeSandboxProbeSequence = 0;
47
- export const NATIVE_SANDBOX_UNAVAILABLE = 'OG_NATIVE_SANDBOX_UNAVAILABLE';
48
-
49
- export function workerNeedsNativeNamespaces(adapter, permissions = {}, platform = process.platform) {
50
- return platform === 'linux' && adapter?.provider === 'codex'
51
- && (permissions.transport ?? 'shell') === 'shell'
52
- && ['read-only', 'workspace-write'].includes(permissions.sandbox ?? 'workspace-write');
53
- }
54
-
55
- export function nativeSandboxFailure(text) {
56
- return /loopback: Failed RTM_NEWADDR: Operation not permitted|uid_map[^\n]*Operation not permitted/i.test(text);
57
- }
58
-
59
- export function nativeSandboxProbeMessage(result) {
60
- return `${NATIVE_SANDBOX_UNAVAILABLE}: Native sandbox unavailable: restricted unprivileged user namespaces inside systemd user units (host AppArmor policy may restrict userns). Probe: ${String(result.detail ?? 'failed').replace(/[\x00-\x1f\x7f]/g, ' ').slice(0, 512)}. Select parallelism.groups.<group>.execution.launchMode="native-sandbox" with a verified enforcing CLI sandbox, or configure a host AppArmor allowance for the systemd worker launch path.`;
61
- }
62
-
63
- /** No inference: exercise user/network namespaces through the worker's unit and client environment path. */
64
- export function probeWorkerNativeSandbox({ spawn = spawnSync, env = process.env, envNames = ['PATH', 'HOME'], cwd,
65
- cache = spawn === spawnSync ? nativeSandboxProbes : undefined } = {}) {
66
- const key = JSON.stringify([cwd, [...envNames].sort()]);
67
- if (cache?.has(key)) return cache.get(key);
68
- let result;
69
- try {
70
- const managerEnvNames = resolveManagerEnvNames({ spawn });
71
- const command = process.execPath;
72
- const args = [fileURLToPath(import.meta.url), '--worker-environment', JSON.stringify(unitEnvironmentNames(envNames)),
73
- '--', '/usr/bin/unshare', '--user', '--map-root-user', '--net', '/usr/bin/true'];
74
- const argv = buildWorkerIsolationArgs({ unit: proposeWorkerUnitName(`sandbox-probe-${process.pid}-${++nativeSandboxProbeSequence}`),
75
- command, args, cwd, env, envNames, managerEnvNames, memoryMaxGb: 0.25, attached: true,
76
- extraProperties: ['RuntimeMaxSec=5s', 'TimeoutStopSec=1s'] });
77
- const observed = spawn(resolveSystemdRun(), ['--collect', ...argv.slice(1)], {
78
- encoding: 'utf8', timeout: 8000, maxBuffer: 4096, env: buildSystemdClientEnv({ env, envNames }) });
79
- result = observed.status === 0 && !observed.error ? { ok: true, mode: 'systemd-unit' }
80
- : { ok: false, mode: 'systemd-unit', code: NATIVE_SANDBOX_UNAVAILABLE,
81
- detail: String(observed.error?.message ?? (String(observed.stderr ?? '').trim() || `exit ${observed.status}`)).replace(/[\x00-\x1f\x7f]/g, ' ').slice(0, 512) };
82
- } catch (error) {
83
- result = { ok: false, mode: 'systemd-unit', code: NATIVE_SANDBOX_UNAVAILABLE,
84
- detail: String(error.message).replace(/[\x00-\x1f\x7f]/g, ' ').slice(0, 512) };
85
- }
86
- cache?.set(key, result);
87
- return result;
88
- }
45
+ export { NATIVE_SANDBOX_UNAVAILABLE, workerNeedsNativeNamespaces, nativeSandboxFailure,
46
+ nativeSandboxProbeMessage, probeWorkerNativeSandbox } from './worker-native-sandbox.mjs';
89
47
 
90
48
  /** Derive a unit-safe `aiwg-worker-<name>-<epoch>` unit name. */
91
49
  export function proposeWorkerUnitName(name, nowSeconds = Math.floor(Date.now() / 1000)) {
@@ -0,0 +1,47 @@
1
+ // Parse one-shot launch options and derive retry IDs from launcher records.
2
+ import { join, isAbsolute } from 'node:path';
3
+ import { boundedPrivateRead } from './worker-inputs.mjs';
4
+
5
+ export function parseWorkerLaunchArgs(args) {
6
+ const result = {};
7
+ const seen = new Set();
8
+ let retryOfInvocationId;
9
+ let retryOfRunDir;
10
+ for (let i = 0; i < args.length; i++) {
11
+ const flag = args[i];
12
+ if (seen.has(flag)) throw new Error(`Duplicate worker launch option: ${flag}`);
13
+ seen.add(flag);
14
+ if (flag === '--launch-worker') continue;
15
+ if (flag === '--allow-degraded-worker') { result.allowDegraded = true; continue; }
16
+ // A run directory declares a retry; its launcher ID is derived before validation.
17
+ const key = { '--invocation': 'invocationPath', '--run-dir': 'runDir',
18
+ '--retry-of': '__retryOfInvocationId', '--retry-of-run-dir': '__retryOfRunDir' }[flag];
19
+ if (!key) throw new Error(`Unsupported worker launch option: ${flag}`);
20
+ const value = args[++i];
21
+ if (!value || value.startsWith('--')) throw new Error(`${flag} requires a value`);
22
+ if (key === '__retryOfInvocationId') retryOfInvocationId = value;
23
+ else if (key === '__retryOfRunDir') retryOfRunDir = value;
24
+ else result[key] = value;
25
+ }
26
+ if (!seen.has('--launch-worker') || !result.invocationPath || !result.runDir) {
27
+ throw new Error('Usage: ralph-external --launch-worker --invocation FILE --run-dir DIR [--allow-degraded-worker] [--retry-of-run-dir DIR [--retry-of ID]]');
28
+ }
29
+ if (retryOfInvocationId !== undefined && retryOfRunDir === undefined) {
30
+ throw new Error('--retry-of and --retry-of-run-dir must be supplied together when an explicit ID is used');
31
+ }
32
+ if (retryOfRunDir !== undefined) result.retryOf = { runDir: retryOfRunDir, ...(retryOfInvocationId === undefined ? {} : { invocationId: retryOfInvocationId }) };
33
+ return result;
34
+ }
35
+
36
+ export async function resolveRetryReference(retryOf, { inspect }) {
37
+ if (!retryOf || typeof retryOf !== 'object' || retryOf.invocationId !== undefined) return retryOf;
38
+ if (!isAbsolute(retryOf.runDir ?? '')) throw new Error('Retry run directory must be absolute');
39
+ const bytes = await boundedPrivateRead(join(retryOf.runDir, 'invocation.json'), 1048576, inspect, true, true);
40
+ const record = JSON.parse(bytes.toString('utf8'));
41
+ if (record.origin !== 'launcher' || typeof record.invocationId !== 'string' || !record.invocationId) {
42
+ throw new Error('Retry predecessor invocation.json must contain a launcher invocationId');
43
+ }
44
+ // Derivation is not acceptance: verifyRetryPredecessor still checks ownership,
45
+ // task-chain identity, matching records, writable roots and confirmed stop.
46
+ return { ...retryOf, invocationId: record.invocationId };
47
+ }
@@ -0,0 +1,83 @@
1
+ /** Probe each CLI's native sandbox mechanism through the worker's transient unit. */
2
+ import { spawnSync } from 'node:child_process';
3
+ import { accessSync, constants, statSync } from 'node:fs';
4
+ import { fileURLToPath } from 'node:url';
5
+ import path from 'node:path';
6
+ import { buildWorkerIsolationArgs, proposeWorkerUnitName, resolveManagerEnvNames } from './worker-isolation.mjs';
7
+ import { buildSystemdClientEnv, resolveSystemdRun, unitEnvironmentNames } from './systemd-client.mjs';
8
+
9
+ const nativeSandboxProbes = new Map();
10
+ let sequence = 0;
11
+ export const NATIVE_SANDBOX_UNAVAILABLE = 'OG_NATIVE_SANDBOX_UNAVAILABLE';
12
+
13
+ /** Prefer the system bwrap allowed by Ubuntu's executable-specific AppArmor policy. */
14
+ export function resolveWorkerBwrap({ env = process.env, executable = candidate => {
15
+ try { accessSync(candidate, constants.X_OK); return statSync(candidate).isFile(); } catch { return false; }
16
+ } } = {}) {
17
+ for (const candidate of ['/usr/bin/bwrap', ...(env.PATH ?? '').split(path.delimiter)
18
+ .filter(directory => path.isAbsolute(directory)).map(directory => path.join(directory, 'bwrap'))]) {
19
+ if (executable(candidate)) return candidate;
20
+ }
21
+ throw new Error('bwrap is unavailable at /usr/bin/bwrap or on the worker PATH');
22
+ }
23
+
24
+ const bwrapProbe = options => ({ mechanism: 'bwrap', command: resolveWorkerBwrap(options),
25
+ args: ['--ro-bind', '/', '/', '--unshare-user', '--unshare-net', '--', '/usr/bin/true'] });
26
+
27
+ // Hooks are owned by launcher code, never loaded from project configuration.
28
+ export const workerNativeSandboxProbeHooks = { codex: bwrapProbe, muse: bwrapProbe };
29
+
30
+ // CLIs without their own hook keep a generic user/network namespace probe: bwrap when the host
31
+ // has it (allowed by Ubuntu's bwrap profile), else the previous unshare probe.
32
+ const genericProbe = options => {
33
+ try { return bwrapProbe(options); }
34
+ catch { return { mechanism: 'unshare', command: '/usr/bin/unshare', args: ['--user', '--map-root-user', '--net', '/usr/bin/true'] }; }
35
+ };
36
+
37
+ export function workerNeedsNativeNamespaces(adapter, permissions = {}, platform = process.platform) {
38
+ return platform === 'linux' && Object.hasOwn(workerNativeSandboxProbeHooks, adapter?.provider ?? '')
39
+ && (permissions.transport ?? 'shell') === 'shell'
40
+ && ['read-only', 'workspace-write'].includes(permissions.sandbox ?? 'workspace-write');
41
+ }
42
+
43
+ export function nativeSandboxFailure(text) {
44
+ return /loopback: Failed RTM_NEWADDR: Operation not permitted|uid_map[^\n]*Operation not permitted/i.test(text);
45
+ }
46
+
47
+ const bounded = detail => String(detail).replace(/[\x00-\x1f\x7f]/g, ' ').slice(0, 512);
48
+
49
+ export function nativeSandboxProbeMessage(result) {
50
+ return `${NATIVE_SANDBOX_UNAVAILABLE}: Native sandbox unavailable: ${bounded(result.mechanism ?? 'CLI sandbox')} probe failed inside a systemd user unit. Probe: ${bounded(result.detail ?? 'failed')}. Verify that the named sandbox executable is installed and can create its sandbox in the worker unit environment.`;
51
+ }
52
+
53
+ /** Hooks return { mechanism, command, args }; the unit and environment path stay shared. */
54
+ export function probeWorkerNativeSandbox({ adapter = { provider: 'codex' }, probeHooks = workerNativeSandboxProbeHooks,
55
+ spawn = spawnSync, env = process.env, envNames = ['PATH', 'HOME'], cwd,
56
+ cache = spawn === spawnSync ? nativeSandboxProbes : undefined } = {}) {
57
+ let mechanism = ['codex', 'muse'].includes(adapter.provider) ? 'bwrap' : `${adapter.provider} sandbox`;
58
+ let key;
59
+ let result;
60
+ try {
61
+ const hook = Object.hasOwn(probeHooks, adapter.provider) ? probeHooks[adapter.provider] : genericProbe;
62
+ const probe = hook({ env, cwd, adapter });
63
+ mechanism = probe.mechanism;
64
+ key = JSON.stringify([adapter.provider, probe, cwd, [...envNames].sort().map(name => [name, env[name]])]);
65
+ if (cache?.has(key)) return cache.get(key);
66
+ const managerEnvNames = resolveManagerEnvNames({ spawn });
67
+ const command = process.execPath;
68
+ const args = [fileURLToPath(new URL('./worker-isolation.mjs', import.meta.url)), '--worker-environment',
69
+ JSON.stringify(unitEnvironmentNames(envNames)), '--', probe.command, ...probe.args];
70
+ const argv = buildWorkerIsolationArgs({ unit: proposeWorkerUnitName(`sandbox-probe-${process.pid}-${++sequence}`),
71
+ command, args, cwd, env, envNames, managerEnvNames, memoryMaxGb: 0.25, attached: true,
72
+ extraProperties: ['RuntimeMaxSec=5s', 'TimeoutStopSec=1s'] });
73
+ const observed = spawn(resolveSystemdRun(), ['--collect', ...argv.slice(1)], {
74
+ encoding: 'utf8', timeout: 8000, maxBuffer: 4096, env: buildSystemdClientEnv({ env, envNames }) });
75
+ result = observed.status === 0 && !observed.error ? { ok: true, mode: 'systemd-unit', mechanism }
76
+ : { ok: false, mode: 'systemd-unit', mechanism, code: NATIVE_SANDBOX_UNAVAILABLE,
77
+ detail: bounded(observed.error?.message ?? (String(observed.stderr ?? '').trim() || `exit ${observed.status}`)) };
78
+ } catch (error) {
79
+ result = { ok: false, mode: 'systemd-unit', mechanism, code: NATIVE_SANDBOX_UNAVAILABLE, detail: bounded(error.message) };
80
+ }
81
+ if (key) cache?.set(key, result);
82
+ return result;
83
+ }