@aiwg/cli 2026.10.0 → 2026.10.3

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 (168) hide show
  1. package/agentic/code/providers/capability-matrix.yaml +270 -0
  2. package/agentic/code/providers/model-capabilities.v1.json +3091 -24
  3. package/bin/aiwg.mjs +86 -21
  4. package/dist/src/a2a/codecs.js +9 -0
  5. package/dist/src/artifacts/corpus-tools/vision-extract.js +105 -15
  6. package/dist/src/artifacts/repair.js +9 -4
  7. package/dist/src/channel/manager.mjs +59 -10
  8. package/dist/src/cli/child-process-result.js +40 -0
  9. package/dist/src/cli/env.js +38 -16
  10. package/dist/src/cli/flag-parsing.js +236 -0
  11. package/dist/src/cli/handlers/decision.js +297 -2
  12. package/dist/src/cli/handlers/diagnose.js +9 -2
  13. package/dist/src/cli/handlers/feedback.js +14 -5
  14. package/dist/src/cli/handlers/init.js +245 -30
  15. package/dist/src/cli/handlers/mc.js +23 -0
  16. package/dist/src/cli/handlers/models.js +69 -6
  17. package/dist/src/cli/handlers/ralph.js +3 -0
  18. package/dist/src/cli/handlers/refresh.js +16 -0
  19. package/dist/src/cli/handlers/regenerate.js +14 -0
  20. package/dist/src/cli/handlers/runtime-info.js +24 -9
  21. package/dist/src/cli/handlers/script-runner.js +16 -6
  22. package/dist/src/cli/handlers/session.js +396 -62
  23. package/dist/src/cli/handlers/sessions.js +146 -40
  24. package/dist/src/cli/handlers/setup-manifest.js +439 -39
  25. package/dist/src/cli/handlers/setup.js +139 -39
  26. package/dist/src/cli/handlers/subcommands.js +3 -1
  27. package/dist/src/cli/handlers/team.js +18 -8
  28. package/dist/src/cli/handlers/use.js +13 -0
  29. package/dist/src/cli/handlers/utilities.js +106 -23
  30. package/dist/src/cli/prompt-utils.js +72 -0
  31. package/dist/src/cli/provider-resolution.js +11 -0
  32. package/dist/src/config/aiwg-config.js +268 -4
  33. package/dist/src/config/cli.js +146 -19
  34. package/dist/src/config/dispatch-thresholds-runtime.mjs +38 -0
  35. package/dist/src/config/project-artifacts-health.mjs +14 -3
  36. package/dist/src/config/user-config.js +36 -10
  37. package/dist/src/decision/adapters/jev.js +80 -28
  38. package/dist/src/decision/ask.js +352 -0
  39. package/dist/src/decision/driver.js +4 -0
  40. package/dist/src/decision/entry.js +38 -6
  41. package/dist/src/decision/heldout/collector.js +10 -1
  42. package/dist/src/decision/index.js +3 -0
  43. package/dist/src/decision/interop.js +60 -8
  44. package/dist/src/decision/jev-credentials.js +121 -0
  45. package/dist/src/decision/ledger.js +839 -0
  46. package/dist/src/decision/validate.js +6 -3
  47. package/dist/src/effects/dispatch-verifier.js +252 -0
  48. package/dist/src/effects/index.js +1 -0
  49. package/dist/src/features/installer.js +2 -2
  50. package/dist/src/flow/graph-metadata.js +5 -0
  51. package/dist/src/gates/providers/loader.js +3 -1
  52. package/dist/src/installation/manager-command.mjs +1 -1
  53. package/dist/src/installation/manager.mjs +17 -2
  54. package/dist/src/lint/runner.js +2 -2
  55. package/dist/src/mcp/adapters/codex-runtime.js +360 -56
  56. package/dist/src/mcp/cli.mjs +587 -77
  57. package/dist/src/mcp/config-file.mjs +279 -0
  58. package/dist/src/mcp/config-layers.mjs +348 -0
  59. package/dist/src/mcp/credentials.mjs +484 -0
  60. package/dist/src/mcp/grok-build-config.mjs +16 -26
  61. package/dist/src/mcp/helpers.mjs +106 -2
  62. package/dist/src/mcp/omp-config.mjs +50 -29
  63. package/dist/src/mcp/profiles.js +97 -23
  64. package/dist/src/mcp/profiles.mjs +99 -27
  65. package/dist/src/mcp/registry.js +264 -57
  66. package/dist/src/mcp/registry.mjs +198 -45
  67. package/dist/src/mcp/server.mjs +403 -20
  68. package/dist/src/mcp/toml-string.mjs +9 -0
  69. package/dist/src/mcp/toml-strip-mcp.mjs +135 -0
  70. package/dist/src/mcp/tool-filters.mjs +357 -0
  71. package/dist/src/mcp/tools/agentic-sandbox.mjs +3 -1
  72. package/dist/src/mcp/tools/decision.mjs +58 -0
  73. package/dist/src/mcp/tools/orchestration.mjs +38 -0
  74. package/dist/src/mcp/tools/subsystems.mjs +39 -8
  75. package/dist/src/mcp/tools/worker-invoke.mjs +1539 -0
  76. package/dist/src/mcp/tools/worker-shell-control.mjs +258 -0
  77. package/dist/src/mcp/tools/worker-stdin-runtime.mjs +79 -0
  78. package/dist/src/memory/project-registry.js +2 -2
  79. package/dist/src/mission-protocol/codecs.js +639 -1
  80. package/dist/src/mission-protocol/types.js +110 -0
  81. package/dist/src/mission-protocol/worker-status.js +2 -0
  82. package/dist/src/models/config-loader.js +70 -13
  83. package/dist/src/models/grok-build-models.js +93 -41
  84. package/dist/src/models/model-capabilities.v1.json +3091 -24
  85. package/dist/src/models/model-discovery.js +862 -63
  86. package/dist/src/models/provider-policy.js +625 -11
  87. package/dist/src/models/qualification-digest.mjs +17 -0
  88. package/dist/src/models/resolver.js +259 -0
  89. package/dist/src/models/worker-direct-readiness.js +529 -0
  90. package/dist/src/models/worker-dispatch-offer.js +169 -0
  91. package/dist/src/models/worker-health-readiness.js +131 -0
  92. package/dist/src/models/worker-installation.mjs +879 -0
  93. package/dist/src/models/wrapper-route.js +18 -0
  94. package/dist/src/ops/registry.js +9 -14
  95. package/dist/src/plugins/standalone-packager.js +4 -3
  96. package/dist/src/process/bounded-child-process.mjs +387 -0
  97. package/dist/src/process/cli-command.mjs +75 -0
  98. package/dist/src/providers/capability-matrix.js +8 -0
  99. package/dist/src/providers/capability-matrix.yaml +270 -0
  100. package/dist/src/providers/grok-build-headless.js +43 -21
  101. package/dist/src/providers/provider-definitions.js +9 -1
  102. package/dist/src/providers/provider-definitions.mjs +9 -2
  103. package/dist/src/serve/executor-registry.js +16 -24
  104. package/dist/src/serve/fleet-mission-conductor.js +9 -0
  105. package/dist/src/serve/mission-conductor.js +9 -0
  106. package/dist/src/serve/shared-host-scheduler.js +2 -0
  107. package/dist/src/serve/worker-invocation.js +538 -0
  108. package/dist/src/serve/worker-stdin-frame.mjs +107 -0
  109. package/dist/src/sessions/adapter-registry.js +45 -0
  110. package/dist/src/sessions/adapters/generic.js +37 -3
  111. package/dist/src/sessions/adapters/grokbot-locator.js +111 -0
  112. package/dist/src/sessions/adapters/grokbot.js +423 -24
  113. package/dist/src/sessions/adapters/muse-workspace-scan.js +108 -0
  114. package/dist/src/sessions/adapters/muse.js +68 -29
  115. package/dist/src/sessions/adapters/pi.js +1 -0
  116. package/dist/src/sessions/batch-contracts.js +13 -1
  117. package/dist/src/sessions/batch-import.js +23 -42
  118. package/dist/src/sessions/candidates.js +47 -11
  119. package/dist/src/sessions/contracts.js +35 -0
  120. package/dist/src/sessions/coverage-preflight.js +134 -0
  121. package/dist/src/sessions/discovery.js +3 -0
  122. package/dist/src/sessions/grok-build-cli.js +67 -5
  123. package/dist/src/sessions/importer.js +24 -8
  124. package/dist/src/sessions/index.js +2 -0
  125. package/dist/src/sessions/repository.js +54 -13
  126. package/dist/src/sessions/workspace-discovery.js +99 -37
  127. package/dist/src/skills/adapters/clawhub.js +10 -8
  128. package/dist/src/skills/adapters/openclaw.js +10 -7
  129. package/dist/src/smiths/context-pipeline/parallelism-section.js +192 -3
  130. package/dist/src/smiths/context-pipeline/workspace-context.js +116 -9
  131. package/dist/src/smiths/hook-bridge/codex-translator.js +6 -5
  132. package/dist/src/smiths/mcpsmith/example.js +1 -1
  133. package/dist/src/smiths/toolsmith/runtime-discovery.mjs +10 -14
  134. package/dist/src/storage/backends/logseq.js +68 -7
  135. package/dist/src/storage/fortemi-qualification-receipt.js +2 -2
  136. package/dist/src/testing/generators/test-code-generator.js +57 -30
  137. package/dist/src/uhp/client.js +11 -1
  138. package/dist/src/uhp/mission.js +5 -0
  139. package/dist/src/update/checker.mjs +5 -5
  140. package/dist/src/update/notifier.mjs +54 -2
  141. package/dist/src/writing/fidelity.js +18 -1
  142. package/package.json +18 -1
  143. package/schemas/mission-v1.schema.json +2429 -0
  144. package/schemas/models/model-catalog.v1.schema.json +301 -0
  145. package/schemas/models/model-config-compatibility.v1.schema.json +281 -0
  146. package/schemas/models/model-policy.v1.schema.json +14 -0
  147. package/schemas/models/provider-model-capabilities.v1.schema.json +1066 -0
  148. package/tools/_resolve-impl.mjs +39 -0
  149. package/tools/agents/deploy-agents.mjs +15 -2
  150. package/tools/agents/providers/base.mjs +63 -43
  151. package/tools/agents/providers/grokbot.mjs +11 -12
  152. package/tools/agents/providers/hermes.mjs +6 -3
  153. package/tools/agents/providers/omp.mjs +5 -2
  154. package/tools/agents/providers/openclaw-translator.mjs +19 -5
  155. package/tools/providers/deepseek-harness-transport.mjs +358 -0
  156. package/tools/providers/omp-transport.mjs +192 -0
  157. package/tools/ralph-external/lib/claude-adapter.mjs +389 -0
  158. package/tools/ralph-external/lib/codex-adapter.mjs +432 -0
  159. package/tools/ralph-external/lib/deepseek-harness-adapter.mjs +397 -0
  160. package/tools/ralph-external/lib/factory-adapter.mjs +503 -0
  161. package/tools/ralph-external/lib/muse-adapter.mjs +364 -0
  162. package/tools/ralph-external/lib/omp-adapter.mjs +315 -0
  163. package/tools/ralph-external/lib/opencode-adapter.mjs +575 -0
  164. package/tools/ralph-external/lib/openhuman-adapter.mjs +75 -0
  165. package/tools/ralph-external/lib/pi-adapter.mjs +333 -0
  166. package/tools/ralph-external/lib/provider-adapter.mjs +690 -0
  167. package/tools/ralph-external/lib/worker-health.mjs +439 -0
  168. package/vscode-extension/schemas/aiwg.config.v1.json +1953 -0
@@ -0,0 +1,1539 @@
1
+ /**
2
+ * AIWG MCP Worker-Invoke Bridge (#2892)
3
+ *
4
+ * MCP-only conductor: resolve and invoke a worker (run/status/cancel)
5
+ * WITHOUT any Mission/MC session, daemon, admission store, or activation
6
+ * marker. Reuses, rather than reimplements, the existing Tier 0 primitives:
7
+ *
8
+ * - #2887 resolveWorkerInvocation / prepareWorkerInvocation
9
+ * (src/serve/worker-invocation.ts, via the CLI's own loadWorkerInputs)
10
+ * - #2954 planDirectWorkerLaunch + launchIsolatedWorker
11
+ * (tools/ralph-external/session-launcher.mjs, lib/worker-isolation.mjs)
12
+ * - #2890 ProviderAdapter.parseWorkerFinal + assembleWorkerResult
13
+ * (tools/ralph-external/lib/provider-adapter.mjs, mission-protocol/codecs.ts)
14
+ *
15
+ * This module is the FIRST MCP-side consumer of that pipeline end-to-end,
16
+ * without a shell/CLI round trip and without Mission/MC state. It is
17
+ * intentionally conservative where the pipeline cannot yet produce a fully
18
+ * attested identity: verification is always left `pending` here (never
19
+ * self-accepted) — a separate verifier is required before a direct result is
20
+ * treated as accepted, per the brief's "worker verification ... remains
21
+ * required."
22
+ *
23
+ * ---------------------------------------------------------------------------
24
+ * Hardening vs. the #2896 reference bridge review (this issue moves +
25
+ * redesigns that bridge; see .aiwg/working/s1-scratch/brief-2892.md):
26
+ *
27
+ * 1. BLOCKER (arbitrary path read / config root choice): every path this
28
+ * tool accepts (`brief`, `workdir`, `final_output`) must resolve
29
+ * (realpath, no symlink escape) beneath the served project root the
30
+ * MCP server was started for. `project_dir` is NOT a client-facing
31
+ * field at all — there is exactly one root, fixed at toolset
32
+ * registration time, never per-request. Each path is additionally
33
+ * checked against the existing in-process repo-access semantics
34
+ * (`src/policy/repo-access.ts` `checkRepoAccess`) when a workspace
35
+ * manifest is configured. See `assertServedPath` / `resolveServedProjectRoot`.
36
+ * 2. BLOCKER (client-settable degraded isolation): there is no
37
+ * `allow_degraded` (or similarly named) field in the tool's input
38
+ * schema. Degraded isolation is an operator opt-in read once from the
39
+ * server environment at registration time (`AIWG_MCP_WORKER_ALLOW_DEGRADED=1`)
40
+ * and is reported in every result (`execution.isolation`). See
41
+ * `resolveServerAllowDegraded`.
42
+ * 3. MAJOR (unbounded registry / permanent lockout): `createInvocationRegistry`
43
+ * counts only LIVE (non-terminal) runs against `maxActive`; terminal
44
+ * entries are evicted by TTL and, independently, by an over-capacity
45
+ * oldest-terminal-first sweep. Capacity is therefore never exhausted by
46
+ * the accumulation of *completed* runs — only by concurrently *live* ones.
47
+ * 4. Toolset wildcard: `workers` is registered under its own explicit
48
+ * opt-in toolset name and is excluded from `AIWG_MCP_TOOLSETS=all`
49
+ * (see `src/mcp/tools/subsystems.mjs`).
50
+ * 5. Minor (leaking temp dirs on uncertain runs): the #2896 reference
51
+ * bridge wrote a scratch plan.json to hand an invocation to a spawned
52
+ * launcher subprocess over IPC, and had to remember to clean that
53
+ * directory up on every exit path including `uncertain` ones. This
54
+ * design calls `planDirectWorkerLaunch`/`resolveWorkerInvocation`
55
+ * in-process instead of through a relay subprocess, so there is no
56
+ * scratch plan directory. Shell stdin framing uses a private runtime
57
+ * file, removed on refusal or confirmed termination. Unused
58
+ * final_output reservations are also removed on pre-launch refusal.
59
+ * ---------------------------------------------------------------------------
60
+ *
61
+ * Fix pass (.aiwg/working/s1-scratch/fix-2892-r1.md, round 1 — two
62
+ * independent reviews, probe-confirmed):
63
+ *
64
+ * B1. The served root used to be resolved LAZILY, on the first request
65
+ * (`resolveServedProjectRoot(options.projectRoot)` defaulted its
66
+ * argument to `process.cwd()` at CALL time, and the toolset was always
67
+ * registered regardless). Fixed: the root is resolved ONCE, eagerly,
68
+ * inside `registerWorkerInvokeToolset` itself, from the caller-supplied
69
+ * `options.projectRoot` only; `resolveServedProjectRoot` no longer has
70
+ * a `process.cwd()` default at all, and the toolset refuses to
71
+ * register (logs a diagnostic, returns without calling
72
+ * `server.registerTool`) when no explicit root is supplied. See
73
+ * `src/mcp/server.mjs` (resolves via `findProjectRoot()` before calling
74
+ * `registerOptInToolsets`) and `src/mcp/tools/subsystems.mjs`
75
+ * (`registerOptInToolsets` threads `options` through).
76
+ * B2. The `mcp` transport used to construct a bare SDK `StdioClientTransport`
77
+ * directly, bypassing the #2954 resource gate and `launchIsolatedWorker`
78
+ * entirely, AND the SDK always merges its own `getDefaultEnvironment()`
79
+ * (HOME/LOGNAME/PATH/SHELL/TERM/USER from the CONDUCTOR's own
80
+ * `process.env`) into whatever `env` is supplied — silently widening
81
+ * the qualified allowlist. Fixed: an MCP worker server process now
82
+ * goes through the exact same gate-then-launch pipeline as a shell
83
+ * worker (`planDirectWorkerLaunch` → `launchIsolatedWorker`), using the
84
+ * new `attached: true` mode (`--pipe --wait` under systemd-run; a live
85
+ * stdio pipe in degraded mode) so the conductor gets a real child
86
+ * process whose stdio IS the MCP transport — never the SDK's own
87
+ * spawn. See `ChildProcessTransport` / `defaultLaunchMcpWorkerProcess`.
88
+ * B3. `assertServedPath` used to return the REQUESTED (non-canonical) path
89
+ * after validating containment against the canonical ancestor — a
90
+ * TOCTOU gap between validation and use. Fixed: it now returns the
91
+ * canonical path (canonicalized existing ancestor + literal
92
+ * not-yet-existing tail). `workdir` must now be a strict subdirectory
93
+ * of the served root (never the root itself, never `.git`/`.aiwg`);
94
+ * `final_output` must resolve inside the validated `workdir` and is
95
+ * reserved via an exclusive (`O_CREAT|O_EXCL|O_NOFOLLOW`) create before
96
+ * any launch. `readBoundedFile` opens with `O_NOFOLLOW`, verifies
97
+ * `nlink === 1`/regular-file/size-bound via `fstat`, and re-verifies
98
+ * containment from the opened fd's own `/proc/self/fd/<n>` target
99
+ * (not the pre-open path string) before reading.
100
+ * M1. Admission used to check capacity/identity (reads) BEFORE
101
+ * `await validatedRequest(input)`, but only RESERVE them (the mutating
102
+ * `registry.create()`) AFTER that await — any number of concurrent
103
+ * `run` calls could pass the checks before any of them reserved.
104
+ * Fixed: `request_id` is required for `run`, and capacity+identity
105
+ * reservation (`registry.create()`) happens synchronously, with no
106
+ * `await` between the checks and the reservation — atomic with
107
+ * respect to any other concurrent `run()` invocation.
108
+ * M2. An unconfirmed ("uncertain") worker used to be marked TERMINAL
109
+ * (`markTerminal`), freeing its capacity reservation without any stop
110
+ * proof, and `cancel` only ever flipped a flag without delivering
111
+ * anything anywhere. Fixed: uncertain outcomes use `registry.update`
112
+ * (stays LIVE, capacity held) instead of `markTerminal`; `cancel`
113
+ * invokes a live `entry.control.stop()` handle registered by `run()`
114
+ * for the duration of the in-flight transport call (MCP: sends
115
+ * `notifications/cancelled` over the live client; shell: stops the
116
+ * isolated unit / kills the child) and only reports `cancelDelivered`
117
+ * when that actually succeeds. A later completion for a run whose
118
+ * `cancelRequested` flag is set is recorded as `cancelled`, never
119
+ * `completed`.
120
+ * M3. No live schema-drift check existed before sending a brief to an MCP
121
+ * worker tool. Fixed: on every connect, `defaultCallMcpWorker` lists
122
+ * tools and compares the live tool's input/output schema against the
123
+ * #2887 installation-owner-trusted schema object (`mcpSchemas`),
124
+ * canonically (`canonicalSchemaDigest`, sorted-key — robust to the
125
+ * SDK's own zod-validation round trip reordering keys without
126
+ * changing meaning); any mismatch (or the tool no longer being
127
+ * advertised at all) refuses before any brief is sent.
128
+ * M4. `seenRequestIds` (replay protection) was a plain `Map` that NEVER
129
+ * shrank. Fixed: `createInvocationRegistry` now sweeps it on the same
130
+ * cadence as the main registry (TTL + over-capacity oldest-first),
131
+ * restricted to request ids whose invocation entry has already left
132
+ * the main registry — a request id is never evicted while its
133
+ * invocation is still live, uncertain, or terminal-but-retained.
134
+ * M5. `tryAssembleDirectResult` re-read the brief at result time without
135
+ * ever comparing it to what was pinned at launch (`invocation.briefDigest`),
136
+ * and the `parseWorkerFinal` failure path forwarded the raw parser
137
+ * error message into client-visible diagnostics — a duplicate-key
138
+ * probe showed this can reflect attacker-controlled text straight
139
+ * back out. Fixed: the brief's digest is re-verified against the
140
+ * launch-time pin before assembling a result (mismatch →
141
+ * `OG_BRIEF_EVIDENCE_CONFLICT`, never silently assembled); diagnostics
142
+ * for a parse failure use the error's own bounded `.code` (a
143
+ * fixed-vocabulary `WorkerContractError`/`WorkerPolicyError` token)
144
+ * instead of `.message`.
145
+ * D1. The registry's default `maxActive` is now
146
+ * `min(DEFAULT_MAX_ACTIVE, resolveParallelism(config.parallelism).max_parallel_subagents)`
147
+ * (the existing project-wide parallelism resolver); the terminal TTL
148
+ * is operator-configurable via `AIWG_MCP_WORKER_TTL_MS`, falling back
149
+ * to the code default.
150
+ * D2. The per-`serverRef` MCP call queue (`'per-instance'`/`'serialized'`
151
+ * sessionIsolation) is now explicitly bounded (refuses rather than
152
+ * queuing unboundedly once `MAX_MCP_SERVER_QUEUE_DEPTH` calls are
153
+ * already waiting) and rechecks the server's registry qualification
154
+ * immediately before each queued launch, not just at admission time.
155
+ * ---------------------------------------------------------------------------
156
+ *
157
+ * @issues #2892, #2896 (review reference, not reused as-is)
158
+ */
159
+
160
+ import { z } from 'zod';
161
+ import { createHash, randomUUID } from 'node:crypto';
162
+ import {
163
+ realpath, readlink as fsReadlink, open as fsOpen, lstat, unlink,
164
+ } from 'node:fs/promises';
165
+ import { existsSync, readFileSync, constants as fsConstants } from 'node:fs';
166
+ import { execFile } from 'node:child_process';
167
+ import { promisify } from 'node:util';
168
+ import path from 'node:path';
169
+ import { fileURLToPath, pathToFileURL } from 'node:url';
170
+ import { mcpError, mcpJson } from '../helpers.mjs';
171
+ import { buildShellStopControl, waitForUnitTermination } from './worker-shell-control.mjs';
172
+ import { launchPreparedShellWorker, sweepWorkerStdin } from './worker-stdin-runtime.mjs';
173
+
174
+ const execFileAsync = promisify(execFile);
175
+
176
+ /**
177
+ * Find the AIWG package root by walking up from THIS file's own location
178
+ * (never from `AIWG_ROOT`/`process.cwd()`, which name the *served project*,
179
+ * not necessarily where this module's own package lives). Depth-independent
180
+ * on purpose: this file ships at `src/mcp/tools/worker-invoke.mjs` in a dev
181
+ * checkout and at `dist/src/mcp/tools/worker-invoke.mjs` in the published
182
+ * package — a fixed-depth relative specifier into `tools/` (which ships
183
+ * unchanged at the package root in both cases) would be correct in one and
184
+ * wrong in the other. Mirrors `tools/_resolve-impl.mjs`'s own `findPackageRoot`.
185
+ */
186
+ function findOwnPackageRoot() {
187
+ let dir = path.dirname(fileURLToPath(import.meta.url));
188
+ for (let i = 0; i < 12; i += 1) {
189
+ const pkgPath = path.join(dir, 'package.json');
190
+ if (existsSync(pkgPath)) {
191
+ try {
192
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
193
+ if (pkg.name === 'aiwg' || pkg.name === '@aiwg/cli') return dir;
194
+ } catch { /* keep walking */ }
195
+ }
196
+ const parent = path.dirname(dir);
197
+ if (parent === dir) break;
198
+ dir = parent;
199
+ }
200
+ throw new Error('Could not locate the AIWG package root from worker-invoke.mjs');
201
+ }
202
+
203
+ async function importImpl(relativeFromSrc) {
204
+ const root = findOwnPackageRoot();
205
+ const { importImpl: delegate } = await import(pathToFileURL(path.join(root, 'tools/_resolve-impl.mjs')).href);
206
+ return delegate(import.meta.url, relativeFromSrc);
207
+ }
208
+
209
+ export const WORKERS_TOOLSET_NAME = 'workers';
210
+ export const DEFAULT_MAX_ACTIVE = 8;
211
+ export const DEFAULT_MAX_RETAINED = 128;
212
+ export const DEFAULT_TERMINAL_TTL_MS = 10 * 60 * 1000;
213
+ /** #2892 D2: bound on how many calls may wait behind one 'per-instance'/
214
+ * 'serialized' MCP server's queue before new ones are refused outright. */
215
+ export const MAX_MCP_SERVER_QUEUE_DEPTH = 32;
216
+ /** #2892 B2: `launchIsolatedWorker`'s systemd-unit path requires a positive
217
+ * `memoryMaxGb` ceiling even for an attached (`--pipe --wait`) launch — the
218
+ * MCP worker SERVER process is a separate resource-confined unit from the
219
+ * conductor, and no project-level config currently supplies a per-worker
220
+ * ceiling for it. Shell workers use this fallback in systemd mode only.
221
+ * Operator-overridable via `registerWorkerInvokeToolset`'s
222
+ * `mcpWorkerMemoryMaxGb` option; this is the fallback. */
223
+ export const DEFAULT_MCP_WORKER_MEMORY_MAX_GB = 2;
224
+ /** Raw per-frame budget, including malformed/unterminated frames. */
225
+ export const MAX_MCP_FRAME_BYTES = 10 * 1024 * 1024;
226
+ /** #2892 B3: a worker's `workdir` may never be the conductor-managed control
227
+ * plane or VCS metadata directory, even as a strict subdirectory name. */
228
+ const RESERVED_WORKDIR_NAMES = new Set(['.git', '.aiwg']);
229
+
230
+ // ===========================================================================
231
+ // Defect 2: degraded isolation is a server-side-only operator opt-in.
232
+ // ===========================================================================
233
+
234
+ /** Read once at registration time, from the server's own environment — never
235
+ * from a request. There is no `allow_degraded` field on the tool schema. */
236
+ export function resolveServerAllowDegraded(env = process.env) {
237
+ const raw = env.AIWG_MCP_WORKER_ALLOW_DEGRADED;
238
+ return raw === '1' || raw === 'true';
239
+ }
240
+
241
+ /** #2892 D1: terminal-entry TTL is operator-configurable (server env, read
242
+ * once at registration — same posture as `resolveServerAllowDegraded`), code
243
+ * default as fallback. */
244
+ export function resolveServerTerminalTtlMs(env = process.env) {
245
+ const raw = Number(env.AIWG_MCP_WORKER_TTL_MS);
246
+ return Number.isFinite(raw) && raw > 0 ? raw : DEFAULT_TERMINAL_TTL_MS;
247
+ }
248
+
249
+ // ===========================================================================
250
+ // Defect 1 / B1: path containment beneath the served project root, pinned
251
+ // once at registration time — never re-resolved from `process.cwd()`.
252
+ // ===========================================================================
253
+
254
+ /** Canonicalize an EXPLICIT served-root candidate. Throws if it does not
255
+ * exist, or if no candidate is given at all (#2892 B1: there is no implicit
256
+ * `process.cwd()` fallback here — a caller with no explicit root must not
257
+ * call this at all; see `registerWorkerInvokeToolset`'s own refusal path). */
258
+ export async function resolveServedProjectRoot(candidate) {
259
+ if (typeof candidate !== 'string' || candidate.length === 0) {
260
+ throw new Error('resolveServedProjectRoot requires an explicit root; it never falls back to process.cwd()');
261
+ }
262
+ return realpath(path.resolve(candidate));
263
+ }
264
+
265
+ /**
266
+ * Walk up to the nearest existing ancestor, requiring every already-existing
267
+ * segment to be free of symlinks (mirrors #2887's own `inspectPath`), then
268
+ * require that ancestor's canonical path to sit inside `servedRootReal`.
269
+ * A target that does not exist yet (e.g. `final_output`) is allowed as long
270
+ * as its nearest existing ancestor directory is contained and symlink-free;
271
+ * `resolveWorkerInvocation` independently re-validates the target itself.
272
+ *
273
+ * #2892 B3: returns the CANONICAL path (the canonicalized existing ancestor
274
+ * joined with whatever tail segments do not exist yet — those cannot be
275
+ * symlinks, they do not exist, but are preserved literally), not the
276
+ * caller-supplied string. The previous version validated containment against
277
+ * the canonical ancestor but then returned `path.resolve(candidatePath)` —
278
+ * the requested, non-canonical path — leaving a TOCTOU gap between this
279
+ * check and whatever later opens that same path.
280
+ */
281
+ async function assertServedPath(servedRootReal, candidatePath, label) {
282
+ if (typeof candidatePath !== 'string' || !path.isAbsolute(candidatePath) || candidatePath.includes('\0')) {
283
+ throw new Error(`${label} must be an absolute, NUL-free path`);
284
+ }
285
+ const target = path.resolve(candidatePath);
286
+ let current = target;
287
+ while (true) {
288
+ try {
289
+ const canonical = await realpath(current);
290
+ if (canonical !== current) throw new Error(`${label} resolves through a symlink`);
291
+ const rel = path.relative(servedRootReal, canonical);
292
+ if (rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) {
293
+ throw new Error(`${label} escapes the served project root`);
294
+ }
295
+ const tail = path.relative(current, target);
296
+ return tail === '' ? canonical : path.join(canonical, tail);
297
+ } catch (error) {
298
+ if (error.code !== 'ENOENT') throw error;
299
+ const parent = path.dirname(current);
300
+ if (parent === current) throw new Error(`${label} has no resolvable ancestor beneath the served root`);
301
+ current = parent;
302
+ }
303
+ }
304
+ }
305
+
306
+ /** #2892 F1: the default location for conductor-prepared writer worktrees
307
+ * — identical to #2889's own `prepareWorkerWorktree` default
308
+ * (`tools/ralph-external/lib/worktree-preparation.mjs`), so an operator who
309
+ * never overrides either stays consistent between the two. */
310
+ const DEFAULT_WORKER_WORKTREES_SUBDIR = ['.aiwg', 'working', 'worktrees'];
311
+
312
+ /** #2892 F1: resolve the conductor worktrees root ONCE, at registration —
313
+ * same posture as the served root itself (B1). An operator may point it
314
+ * elsewhere via `registerWorkerInvokeToolset`'s `workerWorktreesRoot`
315
+ * option; otherwise it defaults to `<servedRoot>/.aiwg/working/worktrees`.
316
+ * Canonicalized via realpath when the directory already exists (so a
317
+ * symlinked worktrees root cannot be used to smuggle a workdir in from
318
+ * outside it); a not-yet-created root resolves to its literal path —
319
+ * nothing can be "nested under" a directory that does not exist yet, so an
320
+ * unresolvable root never over-grants, it only ever under-grants. */
321
+ async function resolveWorkerWorktreesRoot(servedRootReal, candidate) {
322
+ const resolved = candidate !== undefined ? path.resolve(candidate) : path.join(servedRootReal, ...DEFAULT_WORKER_WORKTREES_SUBDIR);
323
+ try {
324
+ return await realpath(resolved);
325
+ } catch {
326
+ return resolved;
327
+ }
328
+ }
329
+
330
+ /** #2892 F1: list the served repository's own registered git worktrees
331
+ * (absolute, canonical paths), via `git worktree list --porcelain` run
332
+ * with a SANITIZED environment (only `PATH`/`HOME`; never the conductor's
333
+ * own `GIT_*` overrides, credential helpers, or pager settings) and
334
+ * `-c core.fsmonitor=false -c core.hooksPath=/dev/null`, so merely
335
+ * answering this read-only query can never execute a repo-local hook or
336
+ * fsmonitor helper. Never throws: a worktree-less served root (no `.git`
337
+ * at all, or `git` itself unavailable) simply has no registered worktrees
338
+ * — the caller falls back to the `workerWorktreesRoot` check instead. */
339
+ async function defaultListGitWorktrees(servedRootReal) {
340
+ try {
341
+ const { stdout } = await execFileAsync('git', [
342
+ '-c', 'core.fsmonitor=false', '-c', 'core.hooksPath=/dev/null',
343
+ '-C', servedRootReal, 'worktree', 'list', '--porcelain',
344
+ ], {
345
+ env: { PATH: process.env.PATH ?? '', HOME: process.env.HOME ?? '' },
346
+ timeout: 10000, maxBuffer: 1024 * 1024,
347
+ });
348
+ const entries = stdout.split('\n')
349
+ .filter(line => line.startsWith('worktree '))
350
+ .map(line => line.slice('worktree '.length).trim());
351
+ const canonical = await Promise.all(entries.map(entry => realpath(entry).catch(() => null)));
352
+ return canonical.filter(Boolean);
353
+ } catch {
354
+ return [];
355
+ }
356
+ }
357
+
358
+ /** #2892 F1 (fix-2892-r2, formerly B3): `workdir` must be a strict
359
+ * subdirectory of the served root AND a conductor-prepared git worktree —
360
+ * never an ordinary in-repo subdirectory, even one that is otherwise
361
+ * strict, symlink-free, and correctly contained. The round-1 fix only
362
+ * enforced containment (not the root itself, not `.git`/`.aiwg`); a probe
363
+ * showed any ordinary strict subdirectory such as `src/` was still
364
+ * accepted. Fixed: `workdir` is accepted only when EITHER (a) it is itself
365
+ * one of the served repository's own registered git worktrees (verified
366
+ * via `git worktree list` / `.git/worktrees` metadata — real git state a
367
+ * caller cannot forge merely by `mkdir`ing a directory), OR (b) it is
368
+ * nested under the explicitly configured (or defaulted)
369
+ * `workerWorktreesRoot` — the same directory #2889's own
370
+ * `prepareWorkerWorktree` already treats as exclusively conductor-managed.
371
+ * `.git`/`.aiwg` remain reserved (never acceptable as a bare workdir)
372
+ * whenever neither acceptance path applies. */
373
+ async function assertWorkdirIsConductorWorktree(servedRootReal, workdirCanonical, { worktreesRoot, listWorktrees = defaultListGitWorktrees } = {}) {
374
+ const rel = path.relative(servedRootReal, workdirCanonical);
375
+ if (rel === '') {
376
+ throw new Error('workdir must be a strict subdirectory of the served project root, not the root itself');
377
+ }
378
+ const registered = await listWorktrees(servedRootReal);
379
+ if (registered.includes(workdirCanonical)) return;
380
+ if (worktreesRoot) {
381
+ const underRoot = path.relative(worktreesRoot, workdirCanonical);
382
+ if (underRoot !== '..' && !underRoot.startsWith(`..${path.sep}`) && !path.isAbsolute(underRoot)) return;
383
+ }
384
+ const [first] = rel.split(path.sep);
385
+ if (RESERVED_WORKDIR_NAMES.has(first)) {
386
+ throw new Error(`workdir must not be inside the reserved '${first}' directory`);
387
+ }
388
+ throw new Error('workdir must be a conductor-prepared git worktree (registered via git worktree, or nested under the configured worker worktrees root), not an ordinary subdirectory');
389
+ }
390
+
391
+ /** #2892 B3: `final_output` must resolve inside the already-scoped `workdir`
392
+ * — not merely somewhere under the served root — so a worker's own claimed
393
+ * output always lands in its own conductor-prepared worktree. */
394
+ function assertWithinWorkdir(workdirCanonical, candidateCanonical) {
395
+ const rel = path.relative(workdirCanonical, candidateCanonical);
396
+ if (rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) {
397
+ throw new Error('final_output must resolve inside workdir');
398
+ }
399
+ }
400
+
401
+ /** #2892 B3: claim `final_output` before any launch via an exclusive create
402
+ * (`O_CREAT|O_EXCL|O_NOFOLLOW`) — refuses if anything (file, symlink, or
403
+ * directory) already sits at that exact path, closing the TOCTOU window
404
+ * between path validation and whatever later writes the worker's result
405
+ * there. The worker's own later `O_TRUNC` write reuses this same inode. */
406
+ async function reserveFinalOutput(finalOutputPath) {
407
+ const handle = await fsOpen(finalOutputPath,
408
+ fsConstants.O_CREAT | fsConstants.O_EXCL | fsConstants.O_WRONLY | fsConstants.O_NOFOLLOW, 0o600);
409
+ try { return await handle.stat(); } finally { await handle.close(); }
410
+ }
411
+
412
+ /** Defense in depth, on top of the hard root check above: when the project
413
+ * participates in a multi-repo workspace manifest, the path must also be
414
+ * allowed there. Single-project deployments (no workspace manifest) have
415
+ * nothing to consult and rely on the hard root boundary alone. */
416
+ async function assertRepoAccessAllowed(servedRoot, candidatePath, action, repoAccess) {
417
+ const manifestPath = repoAccess.findRepoAccessManifest(servedRoot);
418
+ if (!manifestPath) return;
419
+ const manifest = repoAccess.loadRepoAccessManifest(servedRoot);
420
+ const decision = repoAccess.checkRepoAccess(manifest, candidatePath, action, servedRoot);
421
+ if (!decision.allowed) {
422
+ throw new Error(`repo-access denies ${action} for this path: ${decision.reason}`);
423
+ }
424
+ }
425
+
426
+ // ===========================================================================
427
+ // Defect 3 / M4: TTL + live-only bounded invocation registry, with a
428
+ // matching bound on replay-protection memory.
429
+ // ===========================================================================
430
+
431
+ /**
432
+ * Terminal entries are pruned by TTL and, independently, by an
433
+ * over-`maxRetained` oldest-terminal-first sweep. `maxActive` is checked
434
+ * against LIVE entries only, so no accumulation of completed runs can ever
435
+ * lock out a new one — only concurrently *live* work can (an "uncertain" run
436
+ * — #2892 M2 — is LIVE, by design, until something proves it stopped).
437
+ *
438
+ * `seenRequestIds` (M4) tracks `{ invocationId, recordedAt }` and is swept on
439
+ * the same cadence, but only ever evicts a request id once its invocation
440
+ * entry has ALREADY left `entries` (pruned away) — replay protection for any
441
+ * live, uncertain, or still-retained-terminal invocation is never weakened
442
+ * by this sweep, only the unbounded-growth tail is.
443
+ */
444
+ export function createInvocationRegistry({
445
+ maxActive = DEFAULT_MAX_ACTIVE,
446
+ maxRetained = DEFAULT_MAX_RETAINED,
447
+ ttlMs = DEFAULT_TERMINAL_TTL_MS,
448
+ now = () => Date.now(),
449
+ } = {}) {
450
+ const entries = new Map();
451
+ const seenRequestIds = new Map(); // request_id -> { invocationId, recordedAt }
452
+
453
+ function terminalEntries() {
454
+ return [...entries.values()].filter(entry => entry.terminalAt !== null).sort((a, b) => a.terminalAt - b.terminalAt);
455
+ }
456
+ function pruneSeenRequestIds() {
457
+ const cutoff = now() - ttlMs;
458
+ // Only a request id whose invocation has already left `entries` is even
459
+ // a candidate for eviction — live/uncertain/retained-terminal replay
460
+ // protection is never weakened by this sweep.
461
+ const candidates = [...seenRequestIds.entries()]
462
+ .filter(([, v]) => !entries.has(v.invocationId))
463
+ .sort((a, b) => a[1].recordedAt - b[1].recordedAt);
464
+ for (const [requestId, v] of candidates) {
465
+ if (v.recordedAt <= cutoff) seenRequestIds.delete(requestId);
466
+ }
467
+ let over = seenRequestIds.size - maxRetained;
468
+ if (over > 0) {
469
+ for (const [requestId] of candidates) {
470
+ if (over <= 0) break;
471
+ if (seenRequestIds.delete(requestId)) over -= 1;
472
+ }
473
+ }
474
+ }
475
+ function prune() {
476
+ const cutoff = now() - ttlMs;
477
+ for (const entry of terminalEntries()) if (entry.terminalAt <= cutoff) entries.delete(entry.invocationId);
478
+ let over = entries.size - maxRetained;
479
+ if (over > 0) for (const entry of terminalEntries()) {
480
+ if (over <= 0) break;
481
+ entries.delete(entry.invocationId);
482
+ over -= 1;
483
+ }
484
+ pruneSeenRequestIds();
485
+ }
486
+ function liveCount() {
487
+ return [...entries.values()].filter(entry => entry.terminalAt === null).length;
488
+ }
489
+ return {
490
+ prune,
491
+ liveCount,
492
+ get size() { return entries.size; },
493
+ /** Test/diagnostic only — not consulted by any admission decision. */
494
+ get seenCount() { return seenRequestIds.size; },
495
+ canStart() { prune(); return liveCount() < maxActive; },
496
+ /** Never replay the same request_id, terminal or not (#2896 defect: "never blind replay"). */
497
+ claimRequestId(requestId) {
498
+ prune();
499
+ return seenRequestIds.get(requestId)?.invocationId ?? null;
500
+ },
501
+ create(partial) {
502
+ prune();
503
+ const invocationId = partial.invocationId ?? randomUUID();
504
+ const entry = { invocationId, terminalAt: null, createdAt: now(), ...partial, invocationId };
505
+ entries.set(invocationId, entry);
506
+ if (partial.requestId) seenRequestIds.set(partial.requestId, { invocationId, recordedAt: now() });
507
+ return entry;
508
+ },
509
+ get(invocationId) { prune(); return entries.get(invocationId); },
510
+ update(invocationId, patch) {
511
+ const entry = entries.get(invocationId);
512
+ if (!entry) return undefined;
513
+ Object.assign(entry, patch);
514
+ return entry;
515
+ },
516
+ markTerminal(invocationId, patch = {}) {
517
+ const entry = entries.get(invocationId);
518
+ if (!entry) return undefined;
519
+ if (entry.terminalAt === null) entry.terminalAt = now();
520
+ Object.assign(entry, patch);
521
+ // An entry becomes eligible for over-capacity eviction the moment it
522
+ // turns terminal; prune immediately rather than waiting for the next
523
+ // unrelated registry call (otherwise a long idle period after the last
524
+ // run leaves the registry transiently over `maxRetained`).
525
+ prune();
526
+ return entry;
527
+ },
528
+ values() { prune(); return [...entries.values()]; },
529
+ };
530
+ }
531
+
532
+ // ===========================================================================
533
+ // Reuse wiring — load TS-authored contract modules through the repo's own
534
+ // src/dist-aware resolver (tools/_resolve-impl.mjs), exactly as the Ralph
535
+ // provider adapter does for the same reason: this file ships unchanged at
536
+ // `(dist/)src/mcp/tools/worker-invoke.mjs` in both dev and the published
537
+ // package, so a bare relative `.js` specifier into `src/serve/...ts` would
538
+ // only resolve once `dist/src/...` has been built.
539
+ // ===========================================================================
540
+
541
+ async function loadServeWorkerInvocation() {
542
+ return importImpl('serve/worker-invocation.js');
543
+ }
544
+ async function loadModelsHandlerInternals() {
545
+ return importImpl('cli/handlers/models.js');
546
+ }
547
+ async function loadMissionProtocol() {
548
+ return importImpl('mission-protocol/index.js');
549
+ }
550
+ async function loadRepoAccessModule() {
551
+ return importImpl('policy/repo-access.js');
552
+ }
553
+ async function loadAiwgConfigModule() {
554
+ return importImpl('config/aiwg-config.js');
555
+ }
556
+
557
+ /** `tools/` ships unchanged at the package root in both dev and the
558
+ * published package (unlike `src/`, which only ships as `dist/src/`), so an
559
+ * AIWG_ROOT-absolute path is the robust way in rather than a relative
560
+ * specifier (whose depth from this file differs between `src/mcp/tools/`
561
+ * and `dist/src/mcp/tools/`). */
562
+ async function loadRalphExternal(relativeFromToolsRoot) {
563
+ const absolute = path.join(findOwnPackageRoot(), 'tools/ralph-external', relativeFromToolsRoot);
564
+ return import(pathToFileURL(absolute).href);
565
+ }
566
+
567
+ /** The CLI's own default loader (`ModelsWorkerServices.load`), reused
568
+ * in-process instead of shelling out to `aiwg models resolve`. */
569
+ async function defaultLoadWorkerInputs(servedRoot) {
570
+ const { loadWorkerInputs } = await loadModelsHandlerInternals();
571
+ return loadWorkerInputs({ cwd: servedRoot, args: ['resolve'], rawArgs: ['models', 'resolve'], frameworkRoot: findOwnPackageRoot() });
572
+ }
573
+
574
+ /** #2892 D1: the registry's default `maxActive` respects the project's own
575
+ * parallelism policy (`.aiwg/aiwg.config` `parallelism.max_parallel_subagents`,
576
+ * via the existing `resolveParallelism` resolver) — never more than the code
577
+ * default, possibly fewer. Best-effort: any failure (no config, malformed
578
+ * config, resolver error) falls back to `DEFAULT_MAX_ACTIVE` rather than
579
+ * blocking registration. */
580
+ async function resolveDefaultMaxActive(servedRootReal) {
581
+ try {
582
+ const { readAiwgConfig, resolveParallelism, emptyConfig } = await loadAiwgConfigModule();
583
+ const config = (await readAiwgConfig(servedRootReal)) ?? emptyConfig();
584
+ const resolved = resolveParallelism(config.parallelism);
585
+ const fromConfig = resolved?.max_parallel_subagents;
586
+ return Number.isFinite(fromConfig) && fromConfig > 0 ? Math.min(DEFAULT_MAX_ACTIVE, fromConfig) : DEFAULT_MAX_ACTIVE;
587
+ } catch {
588
+ return DEFAULT_MAX_ACTIVE;
589
+ }
590
+ }
591
+
592
+ // ===========================================================================
593
+ // Launch — shell transport reuses #2954's gate-then-launch composition.
594
+ // ===========================================================================
595
+
596
+ /** `planDirectWorkerLaunch` returns a blocked gate as `{ failures, gate }`
597
+ * only — it never sets `diagnostic` (that field is reserved for the
598
+ * degraded-isolation-unavailable case; see `worker-isolation.mjs`). Name
599
+ * the specific failing signal(s) here so a dispatch-gate refusal is
600
+ * distinguishable from every other blocked-launch reason in this bridge's
601
+ * own diagnostics, instead of collapsing to one generic string. */
602
+ function describeGateFailures(failures) {
603
+ if (!Array.isArray(failures) || failures.length === 0) return undefined;
604
+ return `worker dispatch gate blocked: ${failures.map(f => `${f.signal} (${f.reason})`).join('; ')}`;
605
+ }
606
+
607
+ async function defaultLaunchShellWorker(invocation, { memoryMaxGb = DEFAULT_MCP_WORKER_MEMORY_MAX_GB, ...options }) {
608
+ return launchPreparedShellWorker(invocation, { ...options, memoryMaxGb },
609
+ { loadRalphExternal, readBoundedFile, importImpl, describeGateFailures });
610
+ }
611
+
612
+ // ===========================================================================
613
+ // Launch — mcp transport (#2892 B2). The MCP worker SERVER process is
614
+ // launched through the SAME gate-then-launch pipeline as a shell worker
615
+ // (`planDirectWorkerLaunch` → `launchIsolatedWorker`, `attached: true`), and
616
+ // the MCP client speaks to it over the launched child's own stdio via
617
+ // `ChildProcessTransport` — never the SDK's `StdioClientTransport`, which
618
+ // spawns its own child and always merges `getDefaultEnvironment()`
619
+ // (HOME/LOGNAME/PATH/SHELL/TERM/USER from the CONDUCTOR's process.env) into
620
+ // whatever `env` is given, silently widening the qualified allowlist.
621
+ //
622
+ // `sessionIsolation: 'per-call'` opens a fresh connection per call; anything
623
+ // else ('per-instance'/'serialized') is additionally serialized through a
624
+ // bounded per-serverRef queue (#2892 D2) so no two concurrent calls ever
625
+ // share one live connection, and each queued call rechecks the server's own
626
+ // registry qualification immediately before ITS launch, not just at the
627
+ // time it was enqueued.
628
+ // ===========================================================================
629
+
630
+ /**
631
+ * A minimal MCP `Transport` over an already-spawned child process's own
632
+ * stdio, for a child launched by `launchIsolatedWorker({ attached: true })`.
633
+ * Mirrors the SDK's own `StdioClientTransport` framing (newline-delimited
634
+ * JSON-RPC via `ReadBuffer`/`serializeMessage`) but never spawns anything
635
+ * itself and never touches environment variables — the child already exists,
636
+ * already resource-confined, already running with exactly its allowlisted
637
+ * environment.
638
+ */
639
+ export class ChildProcessTransport {
640
+ constructor(child, { terminate = () => child.kill('SIGKILL') } = {}) {
641
+ this._child = child;
642
+ this._terminate = terminate;
643
+ this._failure = undefined;
644
+ this._closed = false;
645
+ this.onmessage = undefined;
646
+ this.onerror = undefined;
647
+ this.onclose = undefined;
648
+ // #2892 F2 (fix-2892-r2): the SDK's own `Client.callTool()`/`request()`
649
+ // generates its JSON-RPC request id internally and never returns it to
650
+ // the caller — the only place that real id is ever actually observable
651
+ // is here, at the transport, the instant the framed request is handed
652
+ // off to be written. A `notifications/cancelled` sent with any other
653
+ // value (the caller's own opaque `mcpBinding.requestId`, which has no
654
+ // relationship whatsoever to the wire protocol) means nothing to a
655
+ // worker trying to honor it.
656
+ this.lastRequestId = null;
657
+ // #2892 F3 (fix-2892-r2): the raw (plain `JSON.parse`-only, never
658
+ // zod-round-tripped) response body for each inbound message that has an
659
+ // `id`, keyed by that id. The SDK's own `ToolSchema`/`ListToolsResultSchema`
660
+ // zod validation reconstructs `inputSchema.properties`/
661
+ // `outputSchema.properties` via `z.record()`, whose generated parser
662
+ // writes each member with plain bracket assignment
663
+ // (`result[key] = value`) rather than `Object.defineProperty` —
664
+ // verified empirically against this installed SDK version: a member
665
+ // literally named `__proto__` silently vanishes from the zod-parsed
666
+ // object (the assignment invokes the inherited prototype SETTER
667
+ // instead of creating an own property), while `JSON.parse` itself
668
+ // always creates `__proto__` as an ordinary own data property. Two
669
+ // schemas that differ only in a `__proto__` member therefore digest
670
+ // IDENTICALLY once passed through `client.listTools()` — comparing
671
+ // against this raw map instead closes that gap. Bounded to a small
672
+ // cap since a long-lived connection could otherwise grow it unbounded.
673
+ this._rawResponses = new Map();
674
+ this._buffer = undefined;
675
+ }
676
+
677
+ async start() {
678
+ const { JSONRPCMessageSchema } = await import('@modelcontextprotocol/sdk/types.js');
679
+ this._messageSchema = JSONRPCMessageSchema;
680
+ const child = this._child;
681
+ child.stdout?.on('data', chunk => {
682
+ if (this._failure || this._closed) return;
683
+ try {
684
+ // Check each frame BEFORE concatenation or UTF-8 decoding.
685
+ let offset = 0;
686
+ let frameBytes = this._buffer?.length ?? 0;
687
+ while (offset < chunk.length) {
688
+ const newline = chunk.indexOf(10, offset);
689
+ const end = newline === -1 ? chunk.length : newline;
690
+ frameBytes += end - offset;
691
+ if (frameBytes > MAX_MCP_FRAME_BYTES) {
692
+ this._overflow('OG_WORKER_MCP_FRAME_TOO_LARGE');
693
+ return;
694
+ }
695
+ if (newline === -1) break;
696
+ frameBytes = 0;
697
+ offset = newline + 1;
698
+ }
699
+ this._buffer = this._buffer ? Buffer.concat([this._buffer, chunk]) : chunk;
700
+ this._drain();
701
+ } catch (error) { this.onerror?.(error); }
702
+ });
703
+ child.stdout?.on('error', error => this.onerror?.(error));
704
+ child.stdin?.on('error', error => this.onerror?.(error));
705
+ // The attached child's stderr is never consumed elsewhere; drain it so a
706
+ // chatty worker cannot block on a full pipe buffer.
707
+ child.stderr?.resume();
708
+ child.once('close', () => this._notifyClose());
709
+ child.once('error', error => this.onerror?.(error));
710
+ }
711
+
712
+ _notifyClose() {
713
+ if (this._closed) return;
714
+ this._closed = true;
715
+ this._buffer = undefined;
716
+ this._rawResponses.clear();
717
+ this.onclose?.();
718
+ }
719
+
720
+ _overflow(code) {
721
+ if (this._failure) return;
722
+ this._failure = Object.assign(new Error(code), { code });
723
+ this._buffer = undefined;
724
+ this._rawResponses.clear();
725
+ // Start stopping before pipe closure can exit the leader; onclose rejects
726
+ // pending SDK requests immediately while termination proof is awaited.
727
+ try {
728
+ this._termination = Promise.resolve(this._terminate()).then(result =>
729
+ ['confirmed', 'uncertain'].includes(result?.termination) ? result : { termination: 'uncertain', diagnostic: 'OG_WORKER_MCP_STOP_UNCONFIRMED' }).catch(() => ({ termination: 'uncertain', diagnostic: 'OG_WORKER_MCP_STOP_FAILED' }));
730
+ } catch { this._termination = Promise.resolve({ termination: 'uncertain', diagnostic: 'OG_WORKER_MCP_STOP_FAILED' }); }
731
+ this._child.stdout?.destroy();
732
+ this._child.stdin?.destroy();
733
+ try { this.onerror?.(this._failure); } finally { this._notifyClose(); }
734
+ }
735
+
736
+ // Reimplements the SDK's own `ReadBuffer`'s newline-delimited framing
737
+ // directly (rather than delegating to it) so this transport can parse
738
+ // each line TWICE from the exact same bytes: once with plain `JSON.parse`
739
+ // (own-property-preserving, retained in `_rawResponses` — see
740
+ // constructor doc) and once through the SDK's own `JSONRPCMessageSchema`
741
+ // (routed to `onmessage`, unchanged behavior for the Client/Protocol).
742
+ _drain() {
743
+ while (this._buffer) {
744
+ const index = this._buffer.indexOf('\n');
745
+ if (index === -1) return;
746
+ const line = this._buffer.toString('utf8', 0, index).replace(/\r$/, '');
747
+ this._buffer = this._buffer.subarray(index + 1);
748
+ this._handleLine(line);
749
+ if (this._buffer?.length === 0) this._buffer = undefined;
750
+ }
751
+ }
752
+
753
+ _handleLine(line) {
754
+ let raw;
755
+ try {
756
+ raw = JSON.parse(line);
757
+ } catch (error) { this.onerror?.(error); return; }
758
+ if (raw && typeof raw === 'object' && !Array.isArray(raw) && raw.id !== undefined && raw.id !== null) {
759
+ this._rawResponses.set(raw.id, raw);
760
+ if (this._rawResponses.size > 64) {
761
+ const oldest = this._rawResponses.keys().next().value;
762
+ this._rawResponses.delete(oldest);
763
+ }
764
+ }
765
+ let message;
766
+ try {
767
+ message = this._messageSchema.parse(raw);
768
+ } catch (error) { this.onerror?.(error); return; }
769
+ this.onmessage?.(message);
770
+ }
771
+
772
+ async send(message) {
773
+ if (this._failure) throw this._failure;
774
+ if (this._closed) throw new Error('worker MCP transport is closed');
775
+ // #2892 F2: a JSON-RPC REQUEST (never a notification or response) is the
776
+ // only message shape with both `method` and `id` — track the most
777
+ // recently sent one's id so an in-flight `stop()`/error-path cancel can
778
+ // reference the worker's own actual request, not a caller-supplied
779
+ // value unrelated to this connection's wire protocol.
780
+ if (message && typeof message === 'object' && typeof message.method === 'string' && message.id !== undefined) {
781
+ this.lastRequestId = message.id;
782
+ }
783
+ const { serializeMessage } = await import('@modelcontextprotocol/sdk/shared/stdio.js');
784
+ return new Promise((resolve, reject) => {
785
+ if (!this._child.stdin) { reject(new Error('worker MCP process has no stdin')); return; }
786
+ const json = serializeMessage(message);
787
+ if (this._child.stdin.write(json)) resolve();
788
+ else this._child.stdin.once('drain', resolve);
789
+ });
790
+ }
791
+
792
+ async close() {
793
+ const child = this._child;
794
+ const closed = new Promise(resolve => child.once('close', resolve));
795
+ try { child.stdin?.end(); } catch { /* already gone */ }
796
+ await Promise.race([closed, new Promise(resolve => setTimeout(resolve, 2000).unref?.())]);
797
+ if (child.exitCode === null && child.signalCode === null) {
798
+ try { child.kill('SIGTERM'); } catch { /* already gone */ }
799
+ await Promise.race([closed, new Promise(resolve => setTimeout(resolve, 2000).unref?.())]);
800
+ }
801
+ if (child.exitCode === null && child.signalCode === null) {
802
+ try { child.kill('SIGKILL'); } catch { /* already gone */ }
803
+ }
804
+ this._buffer = undefined;
805
+ }
806
+ }
807
+
808
+ /** Launch the MCP worker SERVER process through the gate-then-launch
809
+ * pipeline, attached, so its stdio can back a `ChildProcessTransport`.
810
+ * `cwd` is the resolved invocation's own worktree (`invocation.cwd`,
811
+ * #2887's `request.workdir`) — the dispatch gate samples free disk on it
812
+ * (`checkWorkerDispatch({ paths: [cwd] })`, see `planDirectWorkerLaunch`).
813
+ * Without it the gate has no filesystem to sample and fails closed with
814
+ * "free disk not detectable on this host" on EVERY host, regardless of
815
+ * isolation mode, because an MCP server registry entry does not itself
816
+ * carry a worktree path the way a resolved shell invocation does. A
817
+ * registry-declared `server.cwd` (none of today's callers set one) still
818
+ * wins when present, matching the pre-existing precedence for the actual
819
+ * spawn directory. */
820
+ async function defaultLaunchMcpWorkerProcess(server, { allowDegraded, config, name, cwd, memoryMaxGb = DEFAULT_MCP_WORKER_MEMORY_MAX_GB, live, readers, thresholdOverlay }) {
821
+ if (server?.type !== 'stdio') {
822
+ return { launched: false, blocked: true, diagnostic: 'Only stdio MCP worker servers are qualified for direct handoff' };
823
+ }
824
+ const { planDirectWorkerLaunch } = await loadRalphExternal('session-launcher.mjs');
825
+ const envNames = Object.keys(server.env ?? {});
826
+ const launch = await planDirectWorkerLaunch({
827
+ name, memoryMaxGb, command: server.command ?? '', args: server.args ?? [], cwd: server.cwd ?? cwd,
828
+ envNames, env: server.env ?? {}, config, allowDegraded, attached: true,
829
+ // See `defaultLaunchShellWorker`: test-only dispatch-gate passthroughs,
830
+ // always undefined for production registrations.
831
+ live, readers, thresholdOverlay,
832
+ // #2895's `attached` mode defaults `stdin: 'closed'` (stdio[0] 'ignore') —
833
+ // correct for its own verification-runner callers, which only read a
834
+ // worker's output. An MCP stdio server needs a live, writable stdin to
835
+ // receive JSON-RPC requests; 'open' is the one additive, minimal change
836
+ // this bridge needs on top of the reviewed #2895 attached launch (no
837
+ // change to #2895's own default behavior for other callers).
838
+ stdin: 'open',
839
+ });
840
+ if (!launch.launched && launch.diagnostic === undefined) {
841
+ return { ...launch, diagnostic: describeGateFailures(launch.failures) };
842
+ }
843
+ return launch;
844
+ }
845
+
846
+ const mcpServerQueues = new Map(); // serverRef -> { promise: Promise<void>, depth: number }
847
+ /** Test-only observability into the module-level queue map's size — never
848
+ * consulted by any admission/launch decision. */
849
+ export function __testOnlyMcpServerQueueCount() { return mcpServerQueues.size; }
850
+
851
+ /** #2892 D2: bounded per-serverRef serialization queue. Refuses outright
852
+ * (never silently queues unboundedly) once `MAX_MCP_SERVER_QUEUE_DEPTH`
853
+ * calls are already waiting on this serverRef. */
854
+ export function withServerQueue(serverRef, task) {
855
+ const state = mcpServerQueues.get(serverRef) ?? { promise: Promise.resolve(), depth: 0 };
856
+ if (state.depth >= MAX_MCP_SERVER_QUEUE_DEPTH) {
857
+ return Promise.resolve({
858
+ outcome: 'blocked',
859
+ diagnostic: `worker MCP server '${serverRef}' serialized queue is at capacity (${MAX_MCP_SERVER_QUEUE_DEPTH}); retry once in-flight calls complete`,
860
+ });
861
+ }
862
+ state.depth += 1;
863
+ mcpServerQueues.set(serverRef, state);
864
+ const settled = state.promise.then(task, task);
865
+ state.promise = settled.then(() => {}, () => {});
866
+ return settled.finally(() => {
867
+ state.depth -= 1;
868
+ // F5 (fix-2892-r2): prune this serverRef's queue entry once nothing is
869
+ // pending against it — otherwise every DISTINCT serverRef this process
870
+ // ever sees (e.g. 100 one-off completed servers) permanently retains a
871
+ // Map entry, even though its `depth` has long since returned to 0.
872
+ // Guarded by object identity so a racing newer `state` already
873
+ // installed in the map (depth > 0 again) is never deleted out from
874
+ // under it.
875
+ if (state.depth === 0 && mcpServerQueues.get(serverRef) === state) {
876
+ mcpServerQueues.delete(serverRef);
877
+ }
878
+ });
879
+ }
880
+
881
+ /** #2892 F5 (fix-2892-r2, formerly D2): a digest over the server definition
882
+ * fields that matter to the gate-then-launch pipeline (command, args, env
883
+ * variable NAMES — never values) — used to detect a changed registry
884
+ * definition between admission and a queued launch, which the pre-existing
885
+ * `enabled` check alone did not catch (a registry edit that keeps `enabled:
886
+ * true` but changes `command`/`args`/`env` still launched the STALE,
887
+ * admission-time server object). Env names are sorted (a set, not an
888
+ * ordered list) so insertion-order-only changes never false-positive. */
889
+ function serverDefinitionDigest(server) {
890
+ return canonicalSchemaDigest({
891
+ command: server?.command ?? null,
892
+ args: Array.isArray(server?.args) ? server.args : [],
893
+ envNames: Object.keys(server?.env ?? {}).sort(),
894
+ });
895
+ }
896
+
897
+ /** M3: canonical (sorted-key) digest for comparing a live MCP tool schema
898
+ * against the #2887 installation-owner-trusted schema object — robust to
899
+ * the SDK's own zod-validation round trip, which can reorder keys without
900
+ * changing meaning. Mirrors `workerConfigDigest`'s canonicalization
901
+ * (src/config/aiwg-config.ts) applied to a schema specifically. */
902
+ function canonicalSchemaDigest(schema) {
903
+ const canonical = node => {
904
+ if (Array.isArray(node)) return node.map(canonical);
905
+ if (node !== null && typeof node === 'object') {
906
+ return Object.fromEntries(Object.keys(node).sort().filter(key => node[key] !== undefined).map(key => [key, canonical(node[key])]));
907
+ }
908
+ return node ?? null;
909
+ };
910
+ return `sha256:${createHash('sha256').update(JSON.stringify(canonical(schema))).digest('hex')}`;
911
+ }
912
+
913
+ async function defaultCallMcpWorker(mcpBinding, server, options = {}) {
914
+ const {
915
+ sessionIsolation, mcpRegistry, allowDegraded = false, config, name = 'mcp-worker', registerControl,
916
+ memoryMaxGb, mcpSchemas, qualifiedServerDigest, cwd,
917
+ // See `defaultLaunchShellWorker`: test-only dispatch-gate passthroughs,
918
+ // always undefined for production registrations.
919
+ live, readers, thresholdOverlay,
920
+ } = options;
921
+ const run = async () => {
922
+ // D2/F5: recheck the server's own registry qualification immediately
923
+ // before THIS launch — a serialized/per-instance queue may have held
924
+ // this call for a while before its turn came up. Checking `enabled`
925
+ // alone is not requalification: an operator can edit a registry entry's
926
+ // command/args/env while leaving `enabled: true`, and the ORIGINAL
927
+ // (admission-time) `server` object passed into this function would
928
+ // still be the one that gets launched below unless the FRESH
929
+ // definition is also compared against what was qualified.
930
+ if (mcpRegistry) {
931
+ const requalified = await mcpRegistry.get(mcpBinding.serverRef);
932
+ if (!requalified || requalified.enabled === false) {
933
+ return { outcome: 'blocked', diagnostic: 'worker MCP server is no longer registered/enabled' };
934
+ }
935
+ if (qualifiedServerDigest && serverDefinitionDigest(requalified) !== qualifiedServerDigest) {
936
+ return { outcome: 'blocked', diagnostic: 'worker MCP server definition changed since qualification; resolve again' };
937
+ }
938
+ }
939
+ // Resolve fallible dependencies and construct the client before spawning.
940
+ const { stopOverflowWorker } = await loadRalphExternal('lib/worker-overflow.mjs');
941
+ const { Client } = await import('@modelcontextprotocol/sdk/client/index.js');
942
+ const client = new Client({ name: 'aiwg-mcp-worker-invoke', version: '1.0.0' }, { capabilities: {} });
943
+ // B2: gate + launch the MCP worker SERVER process through the same
944
+ // pipeline a shell worker uses — never a bare SDK StdioClientTransport.
945
+ const launch = await defaultLaunchMcpWorkerProcess(server, { allowDegraded, config, name, cwd, live, readers, thresholdOverlay, ...(memoryMaxGb !== undefined ? { memoryMaxGb } : {}) });
946
+ if (!launch.launched) {
947
+ return { outcome: 'blocked', diagnostic: launch.diagnostic ?? 'worker MCP process launch was refused' };
948
+ }
949
+ const child = launch.child;
950
+ const transport = new ChildProcessTransport(child, { terminate: () => stopOverflowWorker(launch) });
951
+ let disconnected = false;
952
+ let retainOverflowControl = false;
953
+ transport.onclose = () => { disconnected = true; };
954
+ const control = {
955
+ stop: async () => {
956
+ if (transport._failure) {
957
+ // The connection is closed after overflow. Reuse the owned launch's
958
+ // stop authority, including its termination proof, on explicit cancel.
959
+ await transport._termination;
960
+ return stopOverflowWorker(launch);
961
+ }
962
+ try {
963
+ // F2: the ACTUAL in-flight JSON-RPC request id (captured at the
964
+ // transport layer — see `ChildProcessTransport.send`), never the
965
+ // caller's own opaque `mcpBinding.requestId`, which the worker has
966
+ // no way to relate to anything it is actually processing.
967
+ await client.notification({ method: 'notifications/cancelled', params: { requestId: transport.lastRequestId, reason: 'operator-cancel' } });
968
+ return true;
969
+ } catch {
970
+ try { child.kill('SIGTERM'); return true; } catch { return false; }
971
+ }
972
+ },
973
+ };
974
+ try {
975
+ registerControl?.(control);
976
+ await client.connect(transport);
977
+ // M3: refuse before any brief is sent if the live tool schema has
978
+ // drifted from what was qualified. Compared CANONICALLY (sorted
979
+ // keys) on both sides, not via the raw `#2887 mcpToolSchemaDigest`
980
+ // pin directly — the SDK's own `listTools()` round-trips the wire
981
+ // JSON through its zod validators, which can legitimately reorder
982
+ // object keys without changing meaning; a byte-exact digest of the
983
+ // POST-validation object against a PRE-validation pin would false-
984
+ // positive on every call. `mcpSchemas` (the #2887 installation-
985
+ // owner-trusted schema objects, not a client-supplied digest) is the
986
+ // comparison's source of truth here.
987
+ const listed = await client.listTools();
988
+ const tool = listed?.tools?.find(t => t.name === mcpBinding.tool);
989
+ if (!tool) {
990
+ return { outcome: 'blocked', diagnostic: 'qualified MCP tool is no longer advertised by the server' };
991
+ }
992
+ // F3 (fix-2892-r2): digest the RAW wire response (plain `JSON.parse`,
993
+ // retained by the transport — see `ChildProcessTransport`'s doc
994
+ // comment), never `tool.inputSchema`/`tool.outputSchema` from the
995
+ // zod-validated `listed` object above — zod's own `z.record()`
996
+ // reconstruction of `properties` silently drops a member literally
997
+ // named `__proto__`, so two schemas differing only in that member
998
+ // would otherwise digest identically and the brief would still be
999
+ // sent. A raw response this transport cannot account for at all
1000
+ // (should be unreachable — `listed` only resolved because SOME
1001
+ // response arrived) fails closed rather than falling back to the
1002
+ // lossy object.
1003
+ const rawToolsList = transport._rawResponses.get(transport.lastRequestId);
1004
+ const rawTool = rawToolsList?.result?.tools?.find(t => t && t.name === mcpBinding.tool);
1005
+ if (!rawTool) {
1006
+ return { outcome: 'blocked', diagnostic: 'worker MCP tool schema could not be verified against the raw wire response' };
1007
+ }
1008
+ if (mcpSchemas?.input && canonicalSchemaDigest(rawTool.inputSchema) !== canonicalSchemaDigest(mcpSchemas.input)) {
1009
+ return { outcome: 'blocked', diagnostic: 'worker MCP tool input schema digest drifted from the qualified pin' };
1010
+ }
1011
+ if (mcpSchemas?.output) {
1012
+ // A qualified descriptor that requires an output schema is never
1013
+ // satisfied by a worker that stopped advertising one at all —
1014
+ // missing is drift, not "nothing to compare".
1015
+ if (!rawTool.outputSchema) {
1016
+ return { outcome: 'blocked', diagnostic: 'worker MCP tool no longer advertises an output schema the qualified descriptor requires' };
1017
+ }
1018
+ if (canonicalSchemaDigest(rawTool.outputSchema) !== canonicalSchemaDigest(mcpSchemas.output)) {
1019
+ return { outcome: 'blocked', diagnostic: 'worker MCP tool output schema digest drifted from the qualified pin' };
1020
+ }
1021
+ }
1022
+ const response = await client.callTool({ name: mcpBinding.tool, arguments: mcpBinding.arguments }, undefined, { timeout: mcpBinding.timeoutSeconds * 1000 });
1023
+ return { outcome: 'responded', response };
1024
+ } catch (error) {
1025
+ const isTimeout = error?.code === -32001 || /timeout/i.test(String(error?.message ?? ''));
1026
+ try { await client.notification({ method: 'notifications/cancelled', params: { requestId: transport.lastRequestId, reason: isTimeout ? 'timeout' : 'error' } }); } catch { /* delivery only, never stop proof */ }
1027
+ const termination = transport._failure ? await transport._termination : await stopOverflowWorker(launch);
1028
+ retainOverflowControl = !!transport._failure && termination?.termination !== 'confirmed';
1029
+ return { outcome: disconnected ? 'disconnected' : isTimeout ? 'timeout' : 'error', error: transport._failure ?? error,
1030
+ ...(transport._failure ? { termination: termination?.termination ?? 'uncertain',
1031
+ diagnostic: [transport._failure.code, termination?.diagnostic].filter(Boolean).join('; ') } : {}) };
1032
+ } finally {
1033
+ try { if (!retainOverflowControl) registerControl?.(null); }
1034
+ finally { try { await client.close(); } catch { /* already closed */ } }
1035
+ }
1036
+ };
1037
+ return sessionIsolation === 'per-call' ? run() : withServerQueue(mcpBinding.serverRef, run);
1038
+ }
1039
+
1040
+ // ===========================================================================
1041
+ // Health hook extension point (documented, no dependency on #2896 code).
1042
+ // ===========================================================================
1043
+
1044
+ /**
1045
+ * `healthHook`, when supplied to `registerWorkerInvokeToolset`, is called as
1046
+ * `healthHook(entry)` while building a `status` response and may return a
1047
+ * richer health object to merge in. This bridge reports only process/
1048
+ * transport-observed state (`entry.health` below); richer liveness signals
1049
+ * (quiet/stalled/hung classification) are intentionally out of scope here —
1050
+ * #2896 (or any later health-observer work) can supply them through this
1051
+ * hook without this module depending on that package.
1052
+ */
1053
+ function publicStatus(entry, healthHook) {
1054
+ const base = {
1055
+ invocationId: entry.invocationId,
1056
+ requestId: entry.requestId,
1057
+ status: entry.status ?? (entry.terminalAt === null ? 'running' : 'uncertain'),
1058
+ termination: entry.termination ?? 'running',
1059
+ cancelRequested: !!entry.cancelRequested,
1060
+ cancelDelivered: !!entry.cancelDelivered,
1061
+ isolationMode: entry.isolationMode ?? null,
1062
+ createdAt: entry.createdAt,
1063
+ terminalAt: entry.terminalAt,
1064
+ diagnostics: entry.diagnostics ?? [],
1065
+ result: entry.result ?? null,
1066
+ health: entry.health ?? null,
1067
+ };
1068
+ if (typeof healthHook === 'function') {
1069
+ try {
1070
+ const extra = healthHook(entry);
1071
+ if (extra && typeof extra === 'object') base.health = { ...base.health, ...extra };
1072
+ } catch { /* extension-point failures never break status reporting */ }
1073
+ }
1074
+ return base;
1075
+ }
1076
+
1077
+ // ===========================================================================
1078
+ // Tool registration.
1079
+ // ===========================================================================
1080
+
1081
+ const inputSchema = z.object({
1082
+ operation: z.enum(['run', 'status', 'cancel']),
1083
+ invocation_id: z.string().uuid().optional(),
1084
+ request_id: z.string().min(1).max(128).regex(/^[a-zA-Z0-9_.:-]+$/).optional(),
1085
+ role: z.enum(['efficiency', 'coding', 'reasoning']).optional(),
1086
+ task: z.string().min(1).max(256).optional(),
1087
+ group: z.string().min(1).regex(/^[a-z][a-z0-9-]{0,62}$/).optional(),
1088
+ brief: z.string().min(1).optional(),
1089
+ workdir: z.string().min(1).optional(),
1090
+ final_output: z.string().min(1).optional(),
1091
+ transport: z.enum(['shell', 'mcp']).default('shell'),
1092
+ }).strict();
1093
+
1094
+ /**
1095
+ * #2892 B1: `options.projectRoot` is REQUIRED — the caller (`server.mjs`,
1096
+ * via `subsystems.mjs`'s `registerOptInToolsets`) must resolve the served
1097
+ * project root itself (e.g. `findProjectRoot()`) before registration. This
1098
+ * function never falls back to `process.cwd()`, and refuses to register the
1099
+ * `worker-invoke` tool at all when no explicit root is supplied — a
1100
+ * worker-invoke tool with no real root to bind to is a bug, not a degraded
1101
+ * mode. `registerOptInToolsets` awaits this function's returned Promise, so
1102
+ * callers that need the tool registered before proceeding must `await` it.
1103
+ */
1104
+ export async function registerWorkerInvokeToolset(server, options = {}) {
1105
+ const allowDegraded = options.allowDegraded ?? resolveServerAllowDegraded();
1106
+ if (!options.projectRoot) {
1107
+ console.error("[AIWG MCP] 'workers' toolset refused: no explicit served project root was supplied at registration time (never falls back to process.cwd()).");
1108
+ return;
1109
+ }
1110
+ const servedRootReal = await resolveServedProjectRoot(options.projectRoot);
1111
+ await sweepWorkerStdin(options.stdinRuntimeRoot);
1112
+ // Pinned once, here, at registration — never re-resolved per request, and
1113
+ // never re-derived from `process.cwd()` (#2892 B1).
1114
+ const servedRoot = async () => servedRootReal;
1115
+ // #2892 F1: resolved once, at registration, same posture as the served
1116
+ // root itself.
1117
+ const workerWorktreesRootReal = await resolveWorkerWorktreesRoot(servedRootReal, options.workerWorktreesRoot);
1118
+ const listWorktreesImpl = options.listWorktrees ?? defaultListGitWorktrees;
1119
+ const maxActive = options.maxActive ?? await resolveDefaultMaxActive(servedRootReal);
1120
+ const ttlMs = options.ttlMs ?? resolveServerTerminalTtlMs();
1121
+ const registry = options.registry ?? createInvocationRegistry({ now: options.now, maxActive, ttlMs });
1122
+ const loadWorkerInputsImpl = options.loadWorkerInputs ?? defaultLoadWorkerInputs;
1123
+ const launchShellWorkerImpl = options.launchShellWorker ?? defaultLaunchShellWorker;
1124
+ const callMcpWorkerImpl = options.callMcpWorker ?? defaultCallMcpWorker;
1125
+ const waitForUnitImpl = options.waitForUnitTermination ?? waitForUnitTermination;
1126
+ const mcpWorkerMemoryMaxGb = options.mcpWorkerMemoryMaxGb; // undefined -> defaultLaunchMcpWorkerProcess's own fallback
1127
+ // Test-only: `planDirectWorkerLaunch`'s own dispatch-gate passthroughs
1128
+ // (`checkWorkerDispatch({ live, readers, thresholdOverlay })`), forwarded
1129
+ // through to the default shell/mcp launchers below so the REAL
1130
+ // gate-then-launch pipeline still runs against fixed synthetic host
1131
+ // signals instead of the current host's actual load/memory/PSI/disk.
1132
+ // Production registrations never set `options.gateInputs` — omitted,
1133
+ // every field here is `undefined` and the gate samples the real host.
1134
+ const gateInputs = options.gateInputs ?? {};
1135
+ const healthHook = options.healthHook;
1136
+ const now = options.now ?? (() => new Date());
1137
+ const repoAccess = options.repoAccess; // injected lazily below when absent
1138
+ const repoAccessModule = async () => repoAccess ?? loadRepoAccessModule();
1139
+
1140
+ async function validatedRequest(input) {
1141
+ const root = await servedRoot();
1142
+ const access = await repoAccessModule();
1143
+ const brief = await assertServedPath(root, input.brief, 'brief');
1144
+ await assertRepoAccessAllowed(root, brief, 'read', access);
1145
+ const workdir = await assertServedPath(root, input.workdir, 'workdir');
1146
+ await assertWorkdirIsConductorWorktree(root, workdir, { worktreesRoot: workerWorktreesRootReal, listWorktrees: listWorktreesImpl });
1147
+ await assertRepoAccessAllowed(root, workdir, 'write', access);
1148
+ const finalOutput = await assertServedPath(root, input.final_output, 'final_output');
1149
+ assertWithinWorkdir(workdir, finalOutput);
1150
+ await assertRepoAccessAllowed(root, finalOutput, 'write', access);
1151
+ return { role: input.role, task: input.task, group: input.group, brief, workdir, final_output: finalOutput, transport: input.transport };
1152
+ }
1153
+
1154
+ async function run(input) {
1155
+ // M1: request_id is required, and admission (capacity + replay check +
1156
+ // reservation) is entirely synchronous — no `await` between the checks
1157
+ // and `registry.create()` — so no concurrent `run()` call can observe a
1158
+ // stale "capacity available" / "request_id unused" read.
1159
+ if (!input.request_id) throw new Error('Worker invocation requires request_id (identity-bound atomic admission)');
1160
+ if (!input.role || !input.task || !input.group || !input.brief || !input.workdir || !input.final_output) {
1161
+ throw new Error('Incomplete worker invocation');
1162
+ }
1163
+ if (!registry.canStart()) throw new Error('Worker invocation capacity is reserved; no live run slot is available');
1164
+ const existing = registry.claimRequestId(input.request_id);
1165
+ if (existing) throw new Error('Worker request_id was already used; mint a fresh request_id instead of replaying');
1166
+ const entry = registry.create({ requestId: input.request_id, transport: input.transport, status: 'running', termination: 'running' });
1167
+ let request;
1168
+ let outputReservation;
1169
+ let mayHaveLaunched = false;
1170
+ let reservationCleanup;
1171
+ const cleanupReservation = () => reservationCleanup ??= (async () => {
1172
+ if (!outputReservation) return;
1173
+ try {
1174
+ const current = await lstat(request.final_output);
1175
+ // Best effort: the pathname can change between lstat and unlink.
1176
+ if (current.dev === outputReservation.dev && current.ino === outputReservation.ino && current.size === 0) {
1177
+ await unlink(request.final_output);
1178
+ }
1179
+ } catch (error) {
1180
+ if (error.code !== 'ENOENT') console.error('[worker-invoke] output reservation cleanup failed');
1181
+ }
1182
+ })();
1183
+ const markTerminal = async update => {
1184
+ if (!mayHaveLaunched) await cleanupReservation();
1185
+ return registry.markTerminal(entry.invocationId, update);
1186
+ };
1187
+ try {
1188
+ request = await validatedRequest(input);
1189
+ } catch (error) {
1190
+ // M1: release the capacity/identity reservation on validation failure
1191
+ // ("release on validation failure"), but preserve the pre-existing
1192
+ // malformed/escaping-input contract (an MCP tool error, not a status
1193
+ // envelope) by rethrowing after releasing — the outer handler below
1194
+ // converts this into `mcpError(...)`.
1195
+ registry.markTerminal(entry.invocationId, { status: 'blocked', termination: 'confirmed', diagnostics: [error.message] });
1196
+ throw error;
1197
+ }
1198
+ try {
1199
+ try {
1200
+ outputReservation = await reserveFinalOutput(request.final_output);
1201
+ } catch {
1202
+ return markTerminal({ status: 'blocked', termination: 'confirmed', diagnostics: ['final_output already exists or is not reservable as a fresh plain file'] });
1203
+ }
1204
+ const root = await servedRoot();
1205
+ const { snapshot, drivers } = await loadWorkerInputsImpl(root);
1206
+ const { resolveWorkerInvocation } = await loadServeWorkerInvocation();
1207
+ const plan = await resolveWorkerInvocation(snapshot, request, drivers, now().toISOString());
1208
+ if (plan.invocation?.readiness !== 'launchable') {
1209
+ return markTerminal({ status: 'blocked', termination: 'confirmed', diagnostics: plan.diagnostics ?? [] });
1210
+ }
1211
+ const invocation = plan.invocation;
1212
+ // F4 (fix-2892-r2, formerly part of M5): per operator decision, the
1213
+ // MCP path REQUIRES the structured WorkerDirectBrief — a probe showed
1214
+ // a plaintext brief (or a JSON brief swapped to plaintext between
1215
+ // launch and result assembly) was accepted, bypassing digest
1216
+ // verification entirely and completing anyway, because
1217
+ // `tryAssembleDirectResult` below treats a non-JSON brief as "nothing
1218
+ // to assemble against" and best-effort no-ops rather than refusing.
1219
+ // Fixed: for `mcp` transport ONLY, validate — AT ADMISSION, BEFORE any
1220
+ // launch — that the brief parses as a WorkerDirectBrief (#2890/#2879
1221
+ // codec), and pin ITS digest ourselves (never relying on whatever
1222
+ // `invocation.briefDigest` #2887's own resolver may or may not have
1223
+ // set for this transport). A non-structured or schema-invalid brief
1224
+ // refuses before any worker ever runs.
1225
+ let mcpBriefDigest;
1226
+ if (invocation.transport === 'mcp') {
1227
+ let briefBytes;
1228
+ try {
1229
+ briefBytes = await readBoundedFile(request.brief, 262144, root);
1230
+ } catch {
1231
+ return markTerminal({ status: 'blocked', termination: 'confirmed', diagnostics: ['OG_BRIEF_UNREADABLE'] });
1232
+ }
1233
+ try {
1234
+ const { assertValidWorkerPayload } = await loadMissionProtocol();
1235
+ assertValidWorkerPayload('workerDirectBrief', JSON.parse(briefBytes.toString('utf8')));
1236
+ } catch (error) {
1237
+ // Fixed-vocabulary diagnostic only — NEVER the raw JSON.parse/
1238
+ // validator message, exactly like M5's own `parseWorkerFinal`
1239
+ // diagnostic below. A duplicate-key/planted-marker probe showed
1240
+ // this exact kind of path can otherwise reflect attacker/worker-
1241
+ // controlled input text straight back out to the caller.
1242
+ return markTerminal({ status: 'blocked', termination: 'confirmed', diagnostics: [typeof error?.code === 'string' ? error.code : 'OG_BRIEF_NOT_STRUCTURED'] });
1243
+ }
1244
+ mcpBriefDigest = `sha256:${createHash('sha256').update(briefBytes).digest('hex')}`;
1245
+ }
1246
+ const deadlineMs = Math.min(invocation.health?.hardTimeoutSeconds ?? invocation.limits.wallSeconds, invocation.limits.wallSeconds) * 1000;
1247
+ let observation;
1248
+ let rawOutput;
1249
+ if (invocation.transport === 'shell') {
1250
+ mayHaveLaunched = true;
1251
+ const launch = await launchShellWorkerImpl(invocation, { allowDegraded, config: snapshot.config,
1252
+ name: `mcp-${entry.invocationId.slice(0, 8)}`, memoryMaxGb: options.shellWorkerMemoryMaxGb,
1253
+ driver: drivers[invocation.selected.cli], servedRoot: root, stdinRuntimeRoot: options.stdinRuntimeRoot, ...gateInputs });
1254
+ registry.update(entry.invocationId, { isolationMode: launch.mode ?? null });
1255
+ if (!launch.launched && !launch.launchAttempted) {
1256
+ mayHaveLaunched = false;
1257
+ return markTerminal({ status: 'blocked', termination: 'confirmed', diagnostics: [launch.diagnostic ?? 'worker launch was refused'] });
1258
+ }
1259
+ // M2: a live stop handle for the duration of this call, so an
1260
+ // explicit `cancel` can actually stop the isolated unit / kill the
1261
+ // child, not just record that cancellation was requested.
1262
+ const control = buildShellStopControl(launch, {
1263
+ waitForUnit: waitForUnitImpl,
1264
+ stopUnit: options.stopWorkerUnit,
1265
+ killUnit: options.killWorkerUnit,
1266
+ graceMs: options.shellStopGraceMs ?? (invocation.health?.terminateGraceSeconds ?? 1) * 1000,
1267
+ onConfirmed: async () => {
1268
+ if (!launch.launched) await cleanupReservation();
1269
+ await Promise.resolve().then(() => launch.cleanup?.()).catch(() => {});
1270
+ const live = registry.get(entry.invocationId);
1271
+ if (live?.terminalAt !== null) return;
1272
+ if (live.status === 'uncertain') {
1273
+ registry.markTerminal(entry.invocationId, { control: null, termination: 'confirmed',
1274
+ status: live.cancelRequested ? 'cancelled' : 'failed' });
1275
+ } else registry.update(entry.invocationId, { control: null, termination: 'confirmed' });
1276
+ },
1277
+ });
1278
+ if (registry.get(entry.invocationId)?.termination !== 'confirmed') {
1279
+ registry.update(entry.invocationId, { control });
1280
+ }
1281
+ // Honor a cancel that arrived while the launcher was still pending.
1282
+ if (registry.get(entry.invocationId)?.cancelRequested) await control.stop();
1283
+ const outcome = launch.launched ? await control.wait(deadlineMs) : await control.stopAndConfirm();
1284
+ observation = { transport: 'shell', status: outcome.termination === 'confirmed' && outcome.exitCode === 0 ? 'completed' : 'failed', termination: outcome.termination, exitCode: outcome.exitCode };
1285
+ if (outcome.termination !== 'confirmed') {
1286
+ // M2: stays LIVE — capacity is held until a stop is proven or the
1287
+ // conductor disposes of it explicitly, never freed on a mere
1288
+ // unconfirmed-termination timeout.
1289
+ return registry.update(entry.invocationId, { status: 'uncertain', termination: 'uncertain', diagnostics: ['worker termination could not be confirmed before the deadline'] });
1290
+ }
1291
+ await Promise.resolve().then(() => launch.cleanup?.()).catch(() => {
1292
+ console.error('[worker-invoke] stdin cleanup failed after confirmed termination');
1293
+ });
1294
+ if (!launch.launched) {
1295
+ mayHaveLaunched = false;
1296
+ return markTerminal({ control: null, status: 'failed', termination: 'confirmed',
1297
+ diagnostics: [launch.diagnostic ?? 'worker launch failed; termination confirmed'] });
1298
+ }
1299
+ if (outcome.timedOut || outcome.stopped) {
1300
+ return registry.markTerminal(entry.invocationId, { control: null, termination: 'confirmed',
1301
+ status: registry.get(entry.invocationId)?.cancelRequested ? 'cancelled' : 'failed',
1302
+ diagnostics: [outcome.timedOut ? 'worker deadline exceeded; termination confirmed' : 'worker stopped; termination confirmed'] });
1303
+ }
1304
+ try { rawOutput = await readBoundedFile(invocation.finalPath, invocation.maxOutputBytes, root); } catch {
1305
+ return registry.markTerminal(entry.invocationId, { status: 'failed', termination: 'confirmed', diagnostics: ['worker final output was not written'] });
1306
+ }
1307
+ } else {
1308
+ const { mcpRegistry, qualification } = drivers[invocation.selected.cli];
1309
+ const mcpServer = await mcpRegistry.get(invocation.mcp.serverRef);
1310
+ if (!mcpServer) return markTerminal({ status: 'blocked', termination: 'confirmed', diagnostics: ['worker MCP server is not registered'] });
1311
+ // F5: pin the qualified server's definition digest NOW, at
1312
+ // admission, so a queued launch can detect a registry edit that
1313
+ // happened while it was waiting (see `serverDefinitionDigest`).
1314
+ const qualifiedServerDigest = serverDefinitionDigest(mcpServer);
1315
+ mayHaveLaunched = true;
1316
+ const result = await callMcpWorkerImpl(invocation.mcp, mcpServer, {
1317
+ sessionIsolation: invocation.mcp.sessionIsolation, mcpRegistry, allowDegraded, config: snapshot.config,
1318
+ // #2892 (CI fix): the resolved invocation's own worktree
1319
+ // (`invocation.cwd`, #2887's `request.workdir`) — without it the
1320
+ // dispatch gate's free-disk sample has no filesystem to read and
1321
+ // fails closed on every host, never reaching isolation at all.
1322
+ // See `defaultLaunchMcpWorkerProcess`'s doc comment.
1323
+ cwd: invocation.cwd,
1324
+ name: `mcp-${entry.invocationId.slice(0, 8)}`, memoryMaxGb: mcpWorkerMemoryMaxGb, mcpSchemas: qualification?.mcpSchemas,
1325
+ qualifiedServerDigest, registerControl: control => registry.update(entry.invocationId, { control }),
1326
+ ...gateInputs,
1327
+ });
1328
+ if (result.outcome === 'blocked') {
1329
+ return registry.markTerminal(entry.invocationId, { status: 'blocked', termination: 'confirmed', diagnostics: [result.diagnostic ?? 'worker MCP launch was refused'] });
1330
+ }
1331
+ if (result.outcome !== 'responded') {
1332
+ // Overflow holds capacity and stop control until termination is proven.
1333
+ if (result.diagnostic?.includes('OG_WORKER_MCP_FRAME_TOO_LARGE')) {
1334
+ // An explicit cancel can have confirmed termination while this run
1335
+ // was awaiting its initial overflow evidence.
1336
+ if (entry.terminalAt !== null) return entry;
1337
+ const patch = { status: result.termination === 'confirmed' ? 'failed' : 'uncertain',
1338
+ termination: result.termination ?? 'uncertain', diagnostics: [result.diagnostic] };
1339
+ return result.termination === 'confirmed'
1340
+ ? registry.markTerminal(entry.invocationId, { ...patch, control: null })
1341
+ : registry.update(entry.invocationId, patch);
1342
+ }
1343
+ // M2: stays LIVE for the same reason as the shell timeout path above.
1344
+ return registry.update(entry.invocationId, { status: 'uncertain', termination: 'uncertain', diagnostics: [`worker ${result.outcome}; cancellation delivered, not confirmed stopped`] });
1345
+ }
1346
+ rawOutput = result.response;
1347
+ observation = { transport: 'mcp', status: result.response.isError ? 'failed' : 'completed', termination: 'confirmed', exitCode: result.response.isError ? 1 : null, isError: !!result.response.isError };
1348
+ if (result.response.isError) {
1349
+ return registry.markTerminal(entry.invocationId, { status: 'failed', termination: 'confirmed', diagnostics: ['worker reported isError'] });
1350
+ }
1351
+ }
1352
+ const { ProviderAdapter } = await loadRalphExternal('lib/provider-adapter.mjs');
1353
+ let claims;
1354
+ try {
1355
+ claims = await new (class extends ProviderAdapter {})().parseWorkerFinal(rawOutput, {
1356
+ format: invocation.transport === 'shell' ? 'jsonl' : 'mcp', observation, maxOutputBytes: invocation.maxOutputBytes,
1357
+ });
1358
+ } catch (error) {
1359
+ // M5: bounded, fixed-vocabulary diagnostic only — never the raw
1360
+ // parser error message, which can echo attacker/worker-controlled
1361
+ // input text back out (a duplicate-key probe reflected a planted
1362
+ // marker through this exact path before the fix).
1363
+ return registry.markTerminal(entry.invocationId, { status: 'failed', termination: 'confirmed', diagnostics: [typeof error?.code === 'string' ? error.code : 'OG_WORKER_FINAL_INVALID'] });
1364
+ }
1365
+ const assembled = await tryAssembleDirectResult({ request, invocation, observation, claims, root,
1366
+ briefDigest: mcpBriefDigest, requireStructuredBrief: invocation.transport === 'mcp' });
1367
+ if (assembled?.conflict) {
1368
+ // M5: the brief changed between launch and result assembly — never
1369
+ // silently assemble a result against a different brief than the one
1370
+ // the worker was actually launched against.
1371
+ return registry.markTerminal(entry.invocationId, { status: 'failed', termination: 'confirmed', diagnostics: ['OG_BRIEF_EVIDENCE_CONFLICT'] });
1372
+ }
1373
+ // M2: a cancel requested at ANY point during this run's lifetime —
1374
+ // even before a live `control` handle existed to deliver it to — means
1375
+ // this result is never trusted as `completed`, regardless of what the
1376
+ // worker itself claims.
1377
+ const liveEntry = registry.get(entry.invocationId);
1378
+ return registry.markTerminal(entry.invocationId, {
1379
+ status: liveEntry?.cancelRequested ? 'cancelled' : (claims.status === 'completed' ? 'completed' : 'failed'),
1380
+ termination: 'confirmed',
1381
+ result: assembled?.result ?? { claims },
1382
+ });
1383
+ } catch (error) {
1384
+ if (error.workerNotLaunched) mayHaveLaunched = false;
1385
+ if (!mayHaveLaunched) {
1386
+ return markTerminal({ status: 'blocked', termination: 'confirmed', diagnostics: [error.message] });
1387
+ }
1388
+ if (registry.get(entry.invocationId)?.termination === 'confirmed') {
1389
+ return registry.markTerminal(entry.invocationId, { control: null,
1390
+ status: registry.get(entry.invocationId)?.cancelRequested ? 'cancelled' : 'failed',
1391
+ termination: 'confirmed', diagnostics: [error.message] });
1392
+ }
1393
+ // An exception after launch is not proof that a worker stopped.
1394
+ return registry.update(entry.invocationId, { status: 'uncertain', termination: 'uncertain', diagnostics: [error.message] });
1395
+ }
1396
+ }
1397
+
1398
+ async function tryAssembleDirectResult({ request, invocation, observation, claims, root, briefDigest, requireStructuredBrief }) {
1399
+ // M5 (and F4 for mcp): `briefDigest` (fix-2892-r2) is THIS run's own
1400
+ // admission-time pin for the `mcp` transport — computed and validated
1401
+ // BEFORE launch, in `run()` — and always takes priority over
1402
+ // `invocation.briefDigest` (which #2887's own resolver populates for
1403
+ // EVERY transport, structured brief or not); falling back to the
1404
+ // latter preserves the shell path's pre-existing behavior unchanged.
1405
+ const pinnedDigest = briefDigest ?? invocation.briefDigest;
1406
+ try {
1407
+ const briefBytes = await readBoundedFile(request.brief, 262144, root);
1408
+ const text = briefBytes.toString('utf8');
1409
+ if (!text.trimStart().startsWith('{')) {
1410
+ // F4: `requireStructuredBrief` (true only for `mcp`, where THIS
1411
+ // run's own admission already proved the brief was structured
1412
+ // JSON) is the signal here — NOT merely "a digest happens to be
1413
+ // pinned", since #2887's resolver pins `invocation.briefDigest` for
1414
+ // every transport, plain-text brief or not. Without that
1415
+ // distinction, an ordinary plain-text SHELL brief (never required
1416
+ // to be structured) would be misreported as a changed-brief
1417
+ // conflict merely because a digest existed. For `mcp`, a brief
1418
+ // that is no longer JSON at all by result-assembly time IS itself
1419
+ // evidence of a changed brief — without this, swapping a JSON
1420
+ // brief to plaintext between launch and assembly bypassed digest
1421
+ // verification entirely and still completed.
1422
+ if (requireStructuredBrief) return { conflict: true };
1423
+ return null; // plain-text brief; no WorkerDirectBrief to assemble against
1424
+ }
1425
+ if (pinnedDigest) {
1426
+ const liveDigest = `sha256:${createHash('sha256').update(briefBytes).digest('hex')}`;
1427
+ if (liveDigest !== pinnedDigest) return { conflict: true };
1428
+ }
1429
+ const { assertValidWorkerPayload, assembleWorkerResult } = await loadMissionProtocol();
1430
+ const brief = JSON.parse(text);
1431
+ assertValidWorkerPayload('workerDirectBrief', brief);
1432
+ const execution = {
1433
+ status: observation.status === 'completed' ? 'completed' : 'failed',
1434
+ exitCode: observation.exitCode, requested: invocation.selected,
1435
+ observedModel: null, observedFamily: null, observedEffort: null,
1436
+ identityEvidence: [], identityQuality: 'unavailable',
1437
+ startedAt: new Date().toISOString(), endedAt: new Date().toISOString(), termination: observation.termination,
1438
+ usage: { inputTokens: null, outputTokens: null, cacheTokens: null, reasoningTokens: null, costUsd: null, quality: 'unknown', reservedTokens: 0, reservedUsd: null },
1439
+ failure: observation.status === 'completed' ? null : { class: 'unknown', message: 'Worker did not report a completed claim', retryable: false },
1440
+ };
1441
+ // Never self-accept: acceptance requires a separate verifier with an
1442
+ // independently-verified artifact digest. This bridge only proves
1443
+ // observed process/transport termination and parsed claims.
1444
+ const verification = { status: 'pending', verifierId: null, runnerRef: null, artifactDigest: null, evidence: [], findings: [], controlledContext: null };
1445
+ return { result: assembleWorkerResult({ brief, execution, worker: claims, verification }) };
1446
+ } catch {
1447
+ // F4: the same `requireStructuredBrief` reasoning as above — a brief
1448
+ // that was provably structured at admission (mcp only) but now fails
1449
+ // to re-parse/re-validate at all is a conflict, not a silent no-op.
1450
+ if (requireStructuredBrief) return { conflict: true };
1451
+ return null; // best-effort enrichment only; the bounded claims result above always stands
1452
+ }
1453
+ }
1454
+
1455
+ async function status(input) {
1456
+ const entry = registry.get(input.invocation_id);
1457
+ if (!entry) throw new Error('Unknown invocation_id');
1458
+ return publicStatus(entry, healthHook);
1459
+ }
1460
+
1461
+ async function cancel(input) {
1462
+ const entry = registry.get(input.invocation_id);
1463
+ if (!entry) throw new Error('Unknown invocation_id');
1464
+ registry.update(entry.invocationId, { cancelRequested: true });
1465
+ // M2: actually deliver the cancellation when a live control handle
1466
+ // exists (the run has an in-flight shell/MCP transport call right now);
1467
+ // `cancelDelivered` reports delivery accurately — it is set ONLY when
1468
+ // the stop signal was actually transmitted, never unconditionally.
1469
+ if (entry.terminalAt === null && entry.control) {
1470
+ let delivered = false;
1471
+ let evidence;
1472
+ try {
1473
+ const result = await entry.control.stop();
1474
+ evidence = result && typeof result === 'object' ? result : undefined;
1475
+ delivered = evidence ? evidence.delivered === true : !!result;
1476
+ } catch { delivered = false; }
1477
+ registry.update(entry.invocationId, { cancelDelivered: delivered });
1478
+ if (evidence?.termination === 'confirmed') {
1479
+ registry.markTerminal(entry.invocationId, { status: 'cancelled', termination: 'confirmed', control: null });
1480
+ }
1481
+ }
1482
+ // Cancellation delivery is recorded; it is NEVER treated as stop proof.
1483
+ // Overflow's retained control can separately prove termination through the
1484
+ // same stop authority used by the run; delivery alone never frees capacity.
1485
+ return publicStatus(registry.get(entry.invocationId), healthHook);
1486
+ }
1487
+
1488
+ server.registerTool('worker-invoke', {
1489
+ title: 'Invoke or Observe a Direct-Handoff Worker',
1490
+ description: 'Run, check, or cancel a qualified worker without any Mission/MC session — same policy/brief/claims path as the shell direct-handoff tier. Degraded isolation is a server-side operator setting only; there is no client-facing switch. Cancel delivery is not stop confirmation.',
1491
+ inputSchema,
1492
+ annotations: { readOnlyHint: false, destructiveHint: true, openWorldHint: false },
1493
+ }, async input => {
1494
+ try {
1495
+ if (input.operation === 'run') return mcpJson(publicStatus(await run(input), healthHook));
1496
+ if (input.operation === 'status') return mcpJson(await status(input));
1497
+ return mcpJson(await cancel(input));
1498
+ } catch (error) {
1499
+ return mcpError(`worker-invoke: ${error.message}`);
1500
+ }
1501
+ });
1502
+ }
1503
+
1504
+ /**
1505
+ * #2892 B3: open with `O_NOFOLLOW` (never follow a symlink planted at the
1506
+ * leaf after `assertServedPath` validated it), verify via `fstat` that the
1507
+ * opened file is a regular, unaliased (`nlink === 1`) file within the byte
1508
+ * bound, and re-verify containment from the OPENED fd's own
1509
+ * `/proc/self/fd/<n>` target — not the pre-open path string — before
1510
+ * reading. This closes the gap where a path swap between validation and
1511
+ * this read could otherwise exfiltrate an arbitrary file's bytes into the
1512
+ * parsed worker claims.
1513
+ */
1514
+ async function readBoundedFile(filePath, maxBytes, servedRootReal) {
1515
+ const handle = await fsOpen(filePath, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
1516
+ try {
1517
+ const info = await handle.stat();
1518
+ if (!info.isFile() || info.nlink !== 1 || info.size > maxBytes) {
1519
+ throw new Error('worker output exceeds the bounded size or is not a regular unaliased file');
1520
+ }
1521
+ if (servedRootReal !== undefined) {
1522
+ const target = await fsReadlink(`/proc/self/fd/${handle.fd}`);
1523
+ const rel = path.relative(servedRootReal, target);
1524
+ if (rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) {
1525
+ throw new Error('worker output resolved outside the served project root after open');
1526
+ }
1527
+ }
1528
+ const buffer = Buffer.alloc(info.size);
1529
+ let length = 0;
1530
+ while (length < buffer.length) {
1531
+ const { bytesRead } = await handle.read(buffer, length, buffer.length - length, null);
1532
+ if (!bytesRead) break;
1533
+ length += bytesRead;
1534
+ }
1535
+ return buffer.subarray(0, length);
1536
+ } finally {
1537
+ await handle.close();
1538
+ }
1539
+ }