@aiwg/cli 2026.10.9 → 2026.10.11

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 (116) hide show
  1. package/agentic/code/providers/model-capabilities.v1.json +275 -17
  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/sessions.js +198 -15
  8. package/dist/src/cli/handlers/use-addon-profile.js +72 -0
  9. package/dist/src/cli/handlers/use.js +203 -234
  10. package/dist/src/cli/ui.js +1 -0
  11. package/dist/src/config/aiwg-config.js +38 -184
  12. package/dist/src/config/cli-help.js +54 -0
  13. package/dist/src/config/cli.js +5 -69
  14. package/dist/src/config/config-normalization.js +10 -0
  15. package/dist/src/config/worker-activation-gate.js +26 -0
  16. package/dist/src/config/worker-adoption-cli.js +102 -0
  17. package/dist/src/config/worker-adoption.js +368 -0
  18. package/dist/src/config/worker-config-digest.js +14 -0
  19. package/dist/src/config/worker-context.js +337 -0
  20. package/dist/src/config/worker-group-templates.js +150 -0
  21. package/dist/src/config/worker-guidance-warning.js +28 -0
  22. package/dist/src/config/worker-owner-lock.js +152 -0
  23. package/dist/src/config/worker-parallelism.js +154 -0
  24. package/dist/src/config/worker-write.js +75 -0
  25. package/dist/src/decision/telemetry/exporter.js +2 -1
  26. package/dist/src/decision/telemetry/index.js +1 -0
  27. package/dist/src/decision/telemetry/mapping.js +43 -0
  28. package/dist/src/decision/telemetry/otlp-http.js +5 -2
  29. package/dist/src/decision/telemetry/redaction.js +6 -1
  30. package/dist/src/decision/telemetry/types.js +3 -0
  31. package/dist/src/decision/telemetry/worker.js +69 -0
  32. package/dist/src/lint/rule-activation.js +15 -7
  33. package/dist/src/lint/runner.js +2 -2
  34. package/dist/src/mcp/server.mjs +2 -11
  35. package/dist/src/mcp/stdio-lifecycle.mjs +27 -0
  36. package/dist/src/mcp/tools/worker-failure-diagnostics.mjs +9 -0
  37. package/dist/src/mcp/tools/worker-invoke.mjs +114 -16
  38. package/dist/src/mission-protocol/codecs.js +8 -38
  39. package/dist/src/mission-protocol/worker-contract-diagnostics.js +62 -0
  40. package/dist/src/mission-protocol/worker-invocation-types.js +2 -0
  41. package/dist/src/models/conductor-data-policy.d.mts +8 -0
  42. package/dist/src/models/conductor-data-policy.mjs +190 -0
  43. package/dist/src/models/config-loader.js +19 -3
  44. package/dist/src/models/config-writer.js +27 -0
  45. package/dist/src/models/model-capabilities.v1.json +275 -17
  46. package/dist/src/models/provider-policy.js +14 -155
  47. package/dist/src/models/resolver.js +2 -293
  48. package/dist/src/models/worker-artifact-binding.mjs +12 -0
  49. package/dist/src/models/worker-direct-readiness-policy.js +155 -0
  50. package/dist/src/models/worker-direct-readiness.js +9 -218
  51. package/dist/src/models/worker-health-readiness.js +105 -26
  52. package/dist/src/models/worker-installation.mjs +12 -14
  53. package/dist/src/models/worker-member-references.js +64 -0
  54. package/dist/src/models/worker-plan.js +317 -0
  55. package/dist/src/models/worker-selection-policy.js +217 -0
  56. package/dist/src/models/wrapper-deployment.js +1 -1
  57. package/dist/src/policy/authorization.js +29 -1
  58. package/dist/src/process/aiwg-probe-command.d.mts +15 -0
  59. package/dist/src/process/aiwg-probe-command.mjs +37 -0
  60. package/dist/src/serve/shared-host-admission-checks.js +116 -0
  61. package/dist/src/serve/shared-host-scheduler.js +25 -107
  62. package/dist/src/serve/shared-host-starvation.js +73 -0
  63. package/dist/src/serve/worker-artifacts.d.mts +4 -0
  64. package/dist/src/serve/worker-artifacts.mjs +56 -0
  65. package/dist/src/serve/worker-command-request.js +68 -0
  66. package/dist/src/serve/worker-invocation-paths.js +144 -0
  67. package/dist/src/serve/worker-invocation-preparation.js +169 -0
  68. package/dist/src/serve/worker-invocation.js +24 -291
  69. package/dist/src/serve/worker-stdin-frame.d.mts +2 -2
  70. package/dist/src/serve/worker-stdin-frame.mjs +6 -6
  71. package/dist/src/sessions/adapters/grokbot.js +7 -0
  72. package/dist/src/sessions/batch-import.js +34 -6
  73. package/dist/src/sessions/contracts.js +19 -0
  74. package/dist/src/sessions/coverage-preflight.js +7 -1
  75. package/dist/src/sessions/fortemi-export-mapping.js +33 -3
  76. package/dist/src/sessions/host-catalog.js +157 -0
  77. package/dist/src/sessions/image-blobs.js +141 -0
  78. package/dist/src/sessions/importer.js +23 -3
  79. package/dist/src/sessions/index.js +2 -0
  80. package/dist/src/sessions/refresh.js +25 -2
  81. package/dist/src/sessions/repository.js +164 -22
  82. package/dist/src/sessions/workspace-discovery.js +5 -2
  83. package/dist/src/smiths/context-pipeline/parallelism-section.js +19 -7
  84. package/package.json +15 -1
  85. package/schemas/mission-v1.schema.json +20 -19
  86. package/schemas/models/model-config-compatibility.v1.schema.json +16 -0
  87. package/tools/agents/providers/claude-post-deploy.mjs +35 -0
  88. package/tools/agents/providers/claude.mjs +3 -30
  89. package/tools/agents/providers/codex-post-deploy.mjs +13 -0
  90. package/tools/agents/providers/codex.mjs +2 -8
  91. package/tools/agents/providers/conductor-data-guard.mjs +61 -0
  92. package/tools/agents/providers/rule-delivery.mjs +3 -16
  93. package/tools/agents/providers/rule-presentation.mjs +38 -0
  94. package/tools/plugin/package-plugins.mjs +1 -1
  95. package/tools/ralph-external/lib/claude-adapter.mjs +2 -2
  96. package/tools/ralph-external/lib/codex-adapter.mjs +3 -3
  97. package/tools/ralph-external/lib/managed-worker-attempt.mjs +86 -0
  98. package/tools/ralph-external/lib/muse-adapter.mjs +10 -0
  99. package/tools/ralph-external/lib/provider-adapter.mjs +11 -260
  100. package/tools/ralph-external/lib/session-attempt.mjs +85 -0
  101. package/tools/ralph-external/lib/strict-worker-json.mjs +190 -0
  102. package/tools/ralph-external/lib/worker-artifacts.mjs +209 -0
  103. package/tools/ralph-external/lib/worker-context.mjs +237 -0
  104. package/tools/ralph-external/lib/worker-final.mjs +143 -0
  105. package/tools/ralph-external/lib/worker-inputs.mjs +55 -0
  106. package/tools/ralph-external/lib/worker-invocation-ancestry.mjs +132 -0
  107. package/tools/ralph-external/lib/worker-invocation-processes.mjs +123 -0
  108. package/tools/ralph-external/lib/worker-isolation-status.mjs +48 -0
  109. package/tools/ralph-external/lib/worker-isolation.mjs +19 -73
  110. package/tools/ralph-external/lib/worker-launch-cli.mjs +49 -0
  111. package/tools/ralph-external/lib/worker-native-sandbox.mjs +83 -0
  112. package/tools/ralph-external/lib/worker-overflow.mjs +48 -13
  113. package/tools/ralph-external/lib/worker-retry-evidence.mjs +103 -0
  114. package/tools/ralph-external/lib/worker-termination.mjs +82 -0
  115. package/tools/ralph-external/session-launcher.mjs +124 -313
  116. package/vscode-extension/schemas/aiwg.config.v1.json +161 -2
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Worker AIWG context loading and launch-time ensure, tools side (#3685).
3
+ *
4
+ * The provider-set UNION lives in `src/config/worker-context.ts` and the
5
+ * config loads through the existing `readAiwgConfig` loader (never ad-hoc
6
+ * JSON). Both cross the tools/src boundary through `importImpl`, so this
7
+ * module stays runnable from an installed package (`dist/src`) and a
8
+ * checkout (`src/`); every such seam is injectable for synthetic tests.
9
+ * `src/` must never import `tools/`, so the src module is always the
10
+ * callee, never the caller.
11
+ *
12
+ * Also home to the single-pass deploy, section verifier, and
13
+ * skip-worktree helpers extracted from `worktree-preparation.mjs` (which
14
+ * re-exports them) so that file stays agent-friendly.
15
+ */
16
+
17
+ import { spawnSync } from 'node:child_process';
18
+ import { readFileSync } from 'node:fs';
19
+ import path from 'node:path';
20
+ import { importImpl } from '../../_resolve-impl.mjs';
21
+
22
+ /** Load the project config through the existing loader. */
23
+ async function defaultReadConfig(repoRoot) {
24
+ const { readAiwgConfig } = await importImpl(import.meta.url, 'config/aiwg-config.js');
25
+ return readAiwgConfig(repoRoot);
26
+ }
27
+
28
+ /** Resolve the provider union through the src-side pure function. */
29
+ async function defaultResolveFromConfig(config) {
30
+ const { resolveWorkerContextProvidersFromConfig } = await importImpl(import.meta.url, 'config/worker-context.js');
31
+ return resolveWorkerContextProvidersFromConfig(config);
32
+ }
33
+
34
+ /** Map one orchestration CLI id through the src-side registry mapping. */
35
+ async function defaultMapCli(cli, parallelism) {
36
+ const { mapCliToProviderId } = await importImpl(import.meta.url, 'config/worker-context.js');
37
+ return mapCliToProviderId(cli, parallelism);
38
+ }
39
+
40
+ /**
41
+ * Resolve the worker-context provider set (#3685 round 2): the union of
42
+ * `.aiwg/aiwg.config` `providers` and every CLI configured for
43
+ * orchestration (`parallelism.clis` keys/adapters and the `cli` of every
44
+ * `parallelism.groups.*.members[]`), mapped through the provider
45
+ * registry. An explicit list wins and is returned verbatim.
46
+ *
47
+ * @returns {Promise<{providers: string[], skipped: string[]}>}
48
+ */
49
+ export async function resolveWorkerContextProviders({ repoRoot, providers = null, readConfig = null, resolveFromConfig = null } = {}) {
50
+ if (providers !== null && providers !== undefined) {
51
+ if (!Array.isArray(providers) || providers.length === 0
52
+ || providers.some(p => typeof p !== 'string' || p.length === 0 || p.includes('\0') || p.includes(','))) {
53
+ throw new Error('worker context providers must be a non-empty array of provider ids');
54
+ }
55
+ return { providers: [...new Set(providers)], skipped: [] };
56
+ }
57
+ if (typeof repoRoot !== 'string' || !repoRoot) {
58
+ throw new Error('worker context providers need a repoRoot or an explicit provider list');
59
+ }
60
+ const config = await (readConfig ?? defaultReadConfig)(repoRoot);
61
+ if (!config) {
62
+ throw new Error(`worker context deployment needs configured providers: cannot read ${path.join(repoRoot, '.aiwg', 'aiwg.config')} (missing or invalid); configure providers and orchestration CLIs, or pass an explicit provider list`);
63
+ }
64
+ return (resolveFromConfig ?? defaultResolveFromConfig)(config);
65
+ }
66
+
67
+ /**
68
+ * Ensure a worker workdir carries its CLI's AIWG bootstrap before launch.
69
+ * Resolves the worker CLI to its provider (from the project config, so
70
+ * orchestration CLIs map through the registry), checks presence, and
71
+ * deploys through the same one-pass helper when missing. The deploy
72
+ * target is always exactly `workdir` — never anywhere else.
73
+ *
74
+ * Injectable for synthetic tests: `ensureFn` stubs the whole ensure,
75
+ * otherwise `mapCli`, `readConfig`, `resolveFromConfig`, `deployFn`,
76
+ * `statusFn`, and `skipWorktreeFn` stub each seam.
77
+ *
78
+ * @returns {Promise<{ensured: boolean, provider: string, providers: string[], skippedBridges: string[], skipped: string[]}>}
79
+ */
80
+ export async function ensureWorkerContextForLaunch({
81
+ workdir,
82
+ cli,
83
+ config = null,
84
+ repoRoot = null,
85
+ providers = null,
86
+ readConfig = null,
87
+ resolveFromConfig = null,
88
+ mapCli = null,
89
+ ensureFn = null,
90
+ statusFn = null,
91
+ deployFn = null,
92
+ skipWorktreeFn = null,
93
+ aiwgBin = process.env.AIWG_BIN ?? 'aiwg',
94
+ } = {}) {
95
+ if (typeof workdir !== 'string' || !workdir) throw new Error('worker context ensure needs a workdir');
96
+ if (typeof cli !== 'string' || !cli) throw new Error('worker context ensure needs the worker CLI id');
97
+ let resolved = Array.isArray(providers) && providers.length > 0 ? { providers: [...new Set(providers)], skipped: [] } : null;
98
+ let loaded = config ?? null;
99
+ if (!resolved) {
100
+ if (!loaded && repoRoot) loaded = await (readConfig ?? defaultReadConfig)(repoRoot);
101
+ if (loaded) {
102
+ resolved = await (resolveFromConfig ?? defaultResolveFromConfig)(loaded);
103
+ }
104
+ }
105
+ const map = mapCli ?? defaultMapCli;
106
+ const mapped = await map(cli, loaded?.parallelism ?? null);
107
+ if (!('provider' in mapped) || !mapped.provider) {
108
+ // Typed-shaped refusal without importing the src error class (which
109
+ // would couple this sync-throwing path to the compiled `dist`
110
+ // layout): callers discriminate on `code`, never `instanceof`.
111
+ throw Object.assign(
112
+ new Error(`cannot ensure worker context: CLI '${cli}' maps to no AIWG provider (${mapped.skipped ?? 'unmapped'})`),
113
+ { code: 'OG_WORKER_CONTEXT_UNAVAILABLE',
114
+ remedy: `configure parallelism.clis.${cli} with a providers:<id> adapter, or use a CLI whose adapter maps to a known provider`,
115
+ workdir },
116
+ );
117
+ }
118
+ const provider = mapped.provider;
119
+ const deployProviders = resolved ? [...new Set([...resolved.providers, provider])] : [provider];
120
+ const ensure = ensureFn ?? (async (opts) => {
121
+ const { ensureWorkerContextBootstrap } = await importImpl(import.meta.url, 'config/worker-context.js');
122
+ return ensureWorkerContextBootstrap(opts);
123
+ });
124
+ const outcome = await ensure({
125
+ workdir, provider, providers: deployProviders, aiwgBin,
126
+ ...(statusFn ? { statusFn } : {}),
127
+ ...(deployFn ? { deployFn } : {}),
128
+ ...(skipWorktreeFn ? { skipWorktreeFn } : {}),
129
+ });
130
+ return { ...outcome, provider, skipped: resolved?.skipped ?? [] };
131
+ }
132
+
133
+ /** Materialize the union set into the worktree config through the src-side sync. */
134
+ async function defaultSyncProviders(worktreePath, providers) {
135
+ const { syncWorkerContextProviders } = await importImpl(import.meta.url, 'config/worker-context.js');
136
+ return syncWorkerContextProviders({ workdir: worktreePath, providers });
137
+ }
138
+
139
+ /** Single-pass argv through the src-side canonical builder (never `--providers`). */
140
+ async function defaultDeployArgv(providers, worktreePath) {
141
+ const { buildWorkerContextDeployArgs } = await importImpl(import.meta.url, 'config/worker-context.js');
142
+ return buildWorkerContextDeployArgs({ providers, target: worktreePath });
143
+ }
144
+
145
+ /**
146
+ * Default single-pass AIWG context deploy (#3685): sync the union set
147
+ * into the worktree config, then one `aiwg use all --target <worktree>`
148
+ * covering every configured provider. Never one deploy per provider:
149
+ * per-provider invocations rewrite AGENTS.md piecemeal and clobber each
150
+ * other's rule sections, while a single pass keeps them all. No
151
+ * `--providers` flag: it never reaches project-scope deploys
152
+ * (`executeCore` resolves from config), so the configured-provider path
153
+ * above is the supported mechanism. Injectable via
154
+ * `prepareWorkerWorktree()`'s `deployContextFn` for synthetic tests;
155
+ * `syncFn` stubs the config sync as `(worktreePath, providers)` and
156
+ * `argvFn` stubs the argv builder as `(providers, target)`.
157
+ *
158
+ * @returns {Promise<{providers: string[]}>}
159
+ */
160
+ export async function defaultDeployWorkerContext({ worktreePath, providers, aiwgBin = 'aiwg', spawn = spawnSync, timeoutMs = 300000, syncFn = null, argvFn = null } = {}) {
161
+ if (typeof worktreePath !== 'string' || !worktreePath || typeof aiwgBin !== 'string' || !aiwgBin) {
162
+ throw new Error('worker context deploy requires a worktree path and an aiwg binary');
163
+ }
164
+ if (!Array.isArray(providers) || providers.length === 0) {
165
+ throw new Error('worker context deploy requires a non-empty provider list');
166
+ }
167
+ const sync = syncFn ?? defaultSyncProviders;
168
+ const synced = await sync(worktreePath, providers);
169
+ const syncedProviders = Array.isArray(synced?.providers) && synced.providers.length > 0 ? synced.providers : [...providers];
170
+ const buildArgv = argvFn ?? defaultDeployArgv;
171
+ const argv = await buildArgv(syncedProviders, worktreePath);
172
+ const result = spawn(aiwgBin, argv, { cwd: worktreePath, encoding: 'utf8', timeout: timeoutMs });
173
+ if (result.error) throw new Error(`aiwg ${argv.join(' ')} failed to run: ${result.error.message}`);
174
+ if (result.status !== 0) {
175
+ throw new Error(`aiwg ${argv.join(' ')} exited ${result.status}: ${String(result.stderr ?? '').trim().slice(0, 2000)}`);
176
+ }
177
+ return { providers: [...syncedProviders] };
178
+ }
179
+
180
+ /** AIWG-managed rule-section markers written into a deployed AGENTS.md. */
181
+ const WORKER_CONTEXT_RULE_MARKER = /<!--\s*AIWG:[A-Za-z0-9_-]+:(start|end)\s*-->/;
182
+
183
+ /**
184
+ * Verify a prepared worktree actually carries AIWG rule sections (#3685):
185
+ * the worktree's AGENTS.md must exist and contain AIWG-managed section
186
+ * markers. Throws before launch otherwise — a worktree without them would
187
+ * run the worker with no project rules/skills/hooks. Injectable via
188
+ * `prepareWorkerWorktree()`'s `verifyContextFn` for synthetic tests.
189
+ *
190
+ * @returns {{verified: boolean, providers: string[]}}
191
+ */
192
+ export function verifyWorkerContextSections({ worktreePath, providers = [] } = {}) {
193
+ const agentsMd = path.join(worktreePath, 'AGENTS.md');
194
+ let content;
195
+ try {
196
+ content = readFileSync(agentsMd, 'utf8');
197
+ } catch {
198
+ throw new Error(`worker context verification failed: ${agentsMd} is missing; the worker would run without AIWG rule sections`);
199
+ }
200
+ if (!WORKER_CONTEXT_RULE_MARKER.test(content)) {
201
+ throw new Error(`worker context verification failed: ${agentsMd} has no AIWG rule sections (expected <!-- AIWG:<rule>:start --> markers for ${[...providers].join(',') || 'the configured providers'})`);
202
+ }
203
+ return { verified: true, providers: [...providers] };
204
+ }
205
+
206
+ /**
207
+ * Tracked files the context deploy modified, so the caller can hide them
208
+ * from the worker diff. Parses `git status --porcelain=v1` with untracked
209
+ * files excluded: every remaining entry is a tracked modification (the
210
+ * fresh worktree starts clean, so each one is deploy-caused).
211
+ *
212
+ * @returns {string[]} Repo-relative paths of tracked-modified files.
213
+ */
214
+ export function listDeployDirtiedBridges({ worktreePath, spawn = spawnSync, timeoutMs = 30000 } = {}) {
215
+ // Raw spawn output, never a trimmed helper: trim would eat the
216
+ // significant leading space of the first porcelain line (` M AGENTS.md`
217
+ // trims to `M AGENTS.md`, shifting slice(3) one byte into the filename).
218
+ const result = spawn('git', ['status', '--porcelain=v1', '--untracked-files=no'], { cwd: worktreePath, encoding: 'utf8', timeout: timeoutMs });
219
+ if (result.error) throw new Error(`git status failed to run: ${result.error.message}`);
220
+ if (result.status !== 0) {
221
+ throw new Error(`git status exited ${result.status}: ${String(result.stderr ?? '').trim()}`);
222
+ }
223
+ const files = [];
224
+ for (const line of String(result.stdout ?? '').split('\n')) {
225
+ if (!line.trim()) continue;
226
+ // `XY PATH` or `XY ORIG -> NEW` for renames; take the new path, which is
227
+ // what the worker diff would report.
228
+ const raw = line.slice(3);
229
+ const arrow = raw.indexOf(' -> ');
230
+ const rel = arrow >= 0 ? raw.slice(arrow + 4) : raw;
231
+ // Porcelain quotes paths with special bytes; a deploy that creates such
232
+ // a name is refused rather than mis-flagged.
233
+ if (!rel || rel.startsWith('"')) throw new Error(`worker context deploy dirtied an unflaggeable path: ${line}`);
234
+ files.push(rel);
235
+ }
236
+ return files;
237
+ }
@@ -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
+
@@ -0,0 +1,132 @@
1
+ // Classify unreadable same-uid processes as possible escaped worker descendants or unrelated trees.
2
+ import { constants } from 'node:fs';
3
+
4
+ // Ancestry walks stay bounded: a pathological ppid chain can never turn this
5
+ // proof into an unbounded scan. Depth and the caller's time bound both apply.
6
+ export const MAX_WORKER_ANCESTRY_DEPTH = 32;
7
+
8
+ const vanished = error => ['ENOENT', 'ESRCH'].includes(error?.code);
9
+
10
+ function readSmallFile(fs, filename, maxBytes = 4096) {
11
+ let fd;
12
+ try {
13
+ fd = fs.openSync(filename, constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOFOLLOW);
14
+ const buffer = Buffer.alloc(maxBytes + 1);
15
+ const chunks = [];
16
+ let bytes = 0;
17
+ for (;;) {
18
+ const size = fs.readSync(fd, buffer, 0, buffer.length, null);
19
+ if (!size) break;
20
+ chunks.push(buffer.subarray(0, size));
21
+ bytes += size;
22
+ if (bytes > maxBytes) return { tooLarge: true };
23
+ }
24
+ return { text: Buffer.concat(chunks).toString('utf8') };
25
+ } catch (error) { return { error }; }
26
+ finally { if (fd !== undefined) fs.closeSync(fd); }
27
+ }
28
+
29
+ // Parse the link fields of /proc/<pid>/stat: ppid, process group, session and
30
+ // start time. The comm field may contain spaces and parentheses, so field 3
31
+ // begins after its LAST ')'. Returns null when the text is malformed.
32
+ export function parseProcStatLink(text) {
33
+ if (typeof text !== 'string') return null;
34
+ const end = text.lastIndexOf(')');
35
+ if (end < 0) return null;
36
+ const fields = text.slice(end + 1).trim().split(/\s+/);
37
+ if (fields.length < 20) return null;
38
+ const [ppid, pgrp, session] = [fields[1], fields[2], fields[3]].map(Number);
39
+ const start = fields[19];
40
+ if (![ppid, pgrp, session].every(Number.isSafeInteger) || typeof start !== 'string' || !/^\d+$/.test(start)) return null;
41
+ return { ppid, pgrp, session, start };
42
+ }
43
+
44
+ // Read /proc/<pid>/cgroup verbatim (cgroup membership stays readable for
45
+ // non-dumpable processes). Normalized like sampleProcessCgroup: the bare path
46
+ // for the unified hierarchy, raw content otherwise. Null when unreadable.
47
+ export function readProcCgroup(fs, path) {
48
+ const read = readSmallFile(fs, `${path}/cgroup`);
49
+ if (read.error || read.tooLarge) return null;
50
+ const raw = String(read.text ?? '').trim();
51
+ if (!raw) return null;
52
+ const unified = raw.split('\n').find(line => line.startsWith('0::'));
53
+ const group = unified ? unified.slice(3) : raw;
54
+ return group || null;
55
+ }
56
+
57
+ const isTicks = value => typeof value === 'string' && /^\d+$/.test(value);
58
+
59
+ /**
60
+ * Decide whether an unreadable same-uid process could be an escaped descendant
61
+ * of the recorded worker leader, using only evidence that stays readable for
62
+ * non-dumpable (hardened, e.g. Codex) processes: /proc/<pid>/stat (ppid,
63
+ * process group, session, start time) and /proc/<pid>/cgroup.
64
+ *
65
+ * Returns 'predates' (started strictly before the worker: cannot descend from
66
+ * it), 'unrelated' (its readable ancestry reaches another live process that
67
+ * is not ours, e.g. a different conductor's worker), 'vanished' (it
68
+ * disappeared mid-scan), or 'possible-descendant' (fail closed: ancestry
69
+ * reaches the worker leader or the worker's original session/process group,
70
+ * shares the worker's private cgroup, is reparented to init/a subreaper with
71
+ * no other link, or any link in the chain is unreadable).
72
+ */
73
+ export function classifyUnreadableWorkerProcess({ fs, procRoot, pid, uid, workerPid,
74
+ workerPgid, workerSession, workerStart, workerCgroup, selfCgroup,
75
+ now = () => performance.now(), deadline = now() + 100, maxDepth = MAX_WORKER_ANCESTRY_DEPTH } = {}) {
76
+ const path = `${procRoot}/${pid}`;
77
+ const first = readSmallFile(fs, `${path}/stat`);
78
+ if (first.error) return vanished(first.error) ? 'vanished' : 'possible-descendant';
79
+ const link = !first.tooLarge && parseProcStatLink(first.text);
80
+ if (!link) return 'possible-descendant';
81
+ if (!isTicks(workerStart)) return 'possible-descendant';
82
+ if (BigInt(link.start) < BigInt(workerStart)) return 'predates';
83
+ // A process still in the worker's original session or process group is
84
+ // treated as a possible descendant even when its ppid chain is broken
85
+ // (an orphan that never called setsid keeps both).
86
+ if ((Number.isSafeInteger(workerPgid) && workerPgid > 0 && link.pgrp === workerPgid)
87
+ || (Number.isSafeInteger(workerSession) && workerSession > 0 && link.session === workerSession)) {
88
+ return 'possible-descendant';
89
+ }
90
+ // A shared cgroup links the process to the worker only when the worker's
91
+ // own cgroup is private: a degraded worker shares its session cgroup with
92
+ // every sibling, so that match proves nothing and ancestry decides.
93
+ if (workerCgroup) {
94
+ const candidate = readProcCgroup(fs, path);
95
+ if (candidate !== null && candidate === workerCgroup && workerCgroup !== selfCgroup) return 'possible-descendant';
96
+ }
97
+ let current = link.ppid;
98
+ for (let depth = 0; ; depth++) {
99
+ if (now() >= deadline) return 'possible-descendant';
100
+ if (depth >= maxDepth) return 'possible-descendant';
101
+ if (current === workerPid) {
102
+ // A pid number alone is not proof: verify the generation, since the
103
+ // number may have been reused after the leader exited.
104
+ const leader = readSmallFile(fs, `${procRoot}/${current}/stat`);
105
+ if (leader.error || leader.tooLarge) return 'possible-descendant';
106
+ const leaderLink = parseProcStatLink(leader.text);
107
+ if (!leaderLink) return 'possible-descendant';
108
+ if (leaderLink.start === workerStart) return 'possible-descendant';
109
+ current = leaderLink.ppid;
110
+ continue;
111
+ }
112
+ // Reparented to init (or the kernel): with no other link this is the
113
+ // honest unknown case, never proof of absence.
114
+ if (!Number.isSafeInteger(current) || current <= 1) return 'possible-descendant';
115
+ let owner;
116
+ try { owner = fs.statSync(`${procRoot}/${current}`).uid; }
117
+ catch { return 'possible-descendant'; }
118
+ // Another identity's tree (including container shims and managers owned
119
+ // by a different uid) can never be our worker's descendant. A setuid
120
+ // escape across uids is out of scope for this same-uid scan.
121
+ if (owner !== uid) return 'unrelated';
122
+ const next = readSmallFile(fs, `${procRoot}/${current}/stat`);
123
+ if (next.error || next.tooLarge) return 'possible-descendant';
124
+ const nextLink = parseProcStatLink(next.text);
125
+ if (!nextLink) return 'possible-descendant';
126
+ // This ancestor predates the worker, so it cannot descend from it — and
127
+ // neither can anything reached through it (a sibling subtree, another
128
+ // conductor's parallel worker, a session manager).
129
+ if (BigInt(nextLink.start) < BigInt(workerStart)) return 'unrelated';
130
+ current = nextLink.ppid;
131
+ }
132
+ }