@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.
- package/agentic/code/providers/capability-matrix.yaml +270 -0
- package/agentic/code/providers/model-capabilities.v1.json +3091 -24
- package/bin/aiwg.mjs +86 -21
- package/dist/src/a2a/codecs.js +9 -0
- package/dist/src/artifacts/corpus-tools/vision-extract.js +105 -15
- package/dist/src/artifacts/repair.js +9 -4
- package/dist/src/channel/manager.mjs +59 -10
- package/dist/src/cli/child-process-result.js +40 -0
- package/dist/src/cli/env.js +38 -16
- package/dist/src/cli/flag-parsing.js +236 -0
- package/dist/src/cli/handlers/decision.js +297 -2
- package/dist/src/cli/handlers/diagnose.js +9 -2
- package/dist/src/cli/handlers/feedback.js +14 -5
- package/dist/src/cli/handlers/init.js +245 -30
- package/dist/src/cli/handlers/mc.js +23 -0
- package/dist/src/cli/handlers/models.js +69 -6
- package/dist/src/cli/handlers/ralph.js +3 -0
- package/dist/src/cli/handlers/refresh.js +16 -0
- package/dist/src/cli/handlers/regenerate.js +14 -0
- package/dist/src/cli/handlers/runtime-info.js +24 -9
- package/dist/src/cli/handlers/script-runner.js +16 -6
- package/dist/src/cli/handlers/session.js +396 -62
- package/dist/src/cli/handlers/sessions.js +146 -40
- package/dist/src/cli/handlers/setup-manifest.js +439 -39
- package/dist/src/cli/handlers/setup.js +139 -39
- package/dist/src/cli/handlers/subcommands.js +3 -1
- package/dist/src/cli/handlers/team.js +18 -8
- package/dist/src/cli/handlers/use.js +13 -0
- package/dist/src/cli/handlers/utilities.js +106 -23
- package/dist/src/cli/prompt-utils.js +72 -0
- package/dist/src/cli/provider-resolution.js +11 -0
- package/dist/src/config/aiwg-config.js +268 -4
- package/dist/src/config/cli.js +146 -19
- package/dist/src/config/dispatch-thresholds-runtime.mjs +38 -0
- package/dist/src/config/project-artifacts-health.mjs +14 -3
- package/dist/src/config/user-config.js +36 -10
- package/dist/src/decision/adapters/jev.js +80 -28
- package/dist/src/decision/ask.js +352 -0
- package/dist/src/decision/driver.js +4 -0
- package/dist/src/decision/entry.js +38 -6
- package/dist/src/decision/heldout/collector.js +10 -1
- package/dist/src/decision/index.js +3 -0
- package/dist/src/decision/interop.js +60 -8
- package/dist/src/decision/jev-credentials.js +121 -0
- package/dist/src/decision/ledger.js +839 -0
- package/dist/src/decision/validate.js +6 -3
- package/dist/src/effects/dispatch-verifier.js +252 -0
- package/dist/src/effects/index.js +1 -0
- package/dist/src/features/installer.js +2 -2
- package/dist/src/flow/graph-metadata.js +5 -0
- package/dist/src/gates/providers/loader.js +3 -1
- package/dist/src/installation/manager-command.mjs +1 -1
- package/dist/src/installation/manager.mjs +17 -2
- package/dist/src/lint/runner.js +2 -2
- package/dist/src/mcp/adapters/codex-runtime.js +360 -56
- package/dist/src/mcp/cli.mjs +587 -77
- package/dist/src/mcp/config-file.mjs +279 -0
- package/dist/src/mcp/config-layers.mjs +348 -0
- package/dist/src/mcp/credentials.mjs +484 -0
- package/dist/src/mcp/grok-build-config.mjs +16 -26
- package/dist/src/mcp/helpers.mjs +106 -2
- package/dist/src/mcp/omp-config.mjs +50 -29
- package/dist/src/mcp/profiles.js +97 -23
- package/dist/src/mcp/profiles.mjs +99 -27
- package/dist/src/mcp/registry.js +264 -57
- package/dist/src/mcp/registry.mjs +198 -45
- package/dist/src/mcp/server.mjs +403 -20
- package/dist/src/mcp/toml-string.mjs +9 -0
- package/dist/src/mcp/toml-strip-mcp.mjs +135 -0
- package/dist/src/mcp/tool-filters.mjs +357 -0
- package/dist/src/mcp/tools/agentic-sandbox.mjs +3 -1
- package/dist/src/mcp/tools/decision.mjs +58 -0
- package/dist/src/mcp/tools/orchestration.mjs +38 -0
- package/dist/src/mcp/tools/subsystems.mjs +39 -8
- package/dist/src/mcp/tools/worker-invoke.mjs +1539 -0
- package/dist/src/mcp/tools/worker-shell-control.mjs +258 -0
- package/dist/src/mcp/tools/worker-stdin-runtime.mjs +79 -0
- package/dist/src/memory/project-registry.js +2 -2
- package/dist/src/mission-protocol/codecs.js +639 -1
- package/dist/src/mission-protocol/types.js +110 -0
- package/dist/src/mission-protocol/worker-status.js +2 -0
- package/dist/src/models/config-loader.js +70 -13
- package/dist/src/models/grok-build-models.js +93 -41
- package/dist/src/models/model-capabilities.v1.json +3091 -24
- package/dist/src/models/model-discovery.js +862 -63
- package/dist/src/models/provider-policy.js +625 -11
- package/dist/src/models/qualification-digest.mjs +17 -0
- package/dist/src/models/resolver.js +259 -0
- package/dist/src/models/worker-direct-readiness.js +529 -0
- package/dist/src/models/worker-dispatch-offer.js +169 -0
- package/dist/src/models/worker-health-readiness.js +131 -0
- package/dist/src/models/worker-installation.mjs +879 -0
- package/dist/src/models/wrapper-route.js +18 -0
- package/dist/src/ops/registry.js +9 -14
- package/dist/src/plugins/standalone-packager.js +4 -3
- package/dist/src/process/bounded-child-process.mjs +387 -0
- package/dist/src/process/cli-command.mjs +75 -0
- package/dist/src/providers/capability-matrix.js +8 -0
- package/dist/src/providers/capability-matrix.yaml +270 -0
- package/dist/src/providers/grok-build-headless.js +43 -21
- package/dist/src/providers/provider-definitions.js +9 -1
- package/dist/src/providers/provider-definitions.mjs +9 -2
- package/dist/src/serve/executor-registry.js +16 -24
- package/dist/src/serve/fleet-mission-conductor.js +9 -0
- package/dist/src/serve/mission-conductor.js +9 -0
- package/dist/src/serve/shared-host-scheduler.js +2 -0
- package/dist/src/serve/worker-invocation.js +538 -0
- package/dist/src/serve/worker-stdin-frame.mjs +107 -0
- package/dist/src/sessions/adapter-registry.js +45 -0
- package/dist/src/sessions/adapters/generic.js +37 -3
- package/dist/src/sessions/adapters/grokbot-locator.js +111 -0
- package/dist/src/sessions/adapters/grokbot.js +423 -24
- package/dist/src/sessions/adapters/muse-workspace-scan.js +108 -0
- package/dist/src/sessions/adapters/muse.js +68 -29
- package/dist/src/sessions/adapters/pi.js +1 -0
- package/dist/src/sessions/batch-contracts.js +13 -1
- package/dist/src/sessions/batch-import.js +23 -42
- package/dist/src/sessions/candidates.js +47 -11
- package/dist/src/sessions/contracts.js +35 -0
- package/dist/src/sessions/coverage-preflight.js +134 -0
- package/dist/src/sessions/discovery.js +3 -0
- package/dist/src/sessions/grok-build-cli.js +67 -5
- package/dist/src/sessions/importer.js +24 -8
- package/dist/src/sessions/index.js +2 -0
- package/dist/src/sessions/repository.js +54 -13
- package/dist/src/sessions/workspace-discovery.js +99 -37
- package/dist/src/skills/adapters/clawhub.js +10 -8
- package/dist/src/skills/adapters/openclaw.js +10 -7
- package/dist/src/smiths/context-pipeline/parallelism-section.js +192 -3
- package/dist/src/smiths/context-pipeline/workspace-context.js +116 -9
- package/dist/src/smiths/hook-bridge/codex-translator.js +6 -5
- package/dist/src/smiths/mcpsmith/example.js +1 -1
- package/dist/src/smiths/toolsmith/runtime-discovery.mjs +10 -14
- package/dist/src/storage/backends/logseq.js +68 -7
- package/dist/src/storage/fortemi-qualification-receipt.js +2 -2
- package/dist/src/testing/generators/test-code-generator.js +57 -30
- package/dist/src/uhp/client.js +11 -1
- package/dist/src/uhp/mission.js +5 -0
- package/dist/src/update/checker.mjs +5 -5
- package/dist/src/update/notifier.mjs +54 -2
- package/dist/src/writing/fidelity.js +18 -1
- package/package.json +18 -1
- package/schemas/mission-v1.schema.json +2429 -0
- package/schemas/models/model-catalog.v1.schema.json +301 -0
- package/schemas/models/model-config-compatibility.v1.schema.json +281 -0
- package/schemas/models/model-policy.v1.schema.json +14 -0
- package/schemas/models/provider-model-capabilities.v1.schema.json +1066 -0
- package/tools/_resolve-impl.mjs +39 -0
- package/tools/agents/deploy-agents.mjs +15 -2
- package/tools/agents/providers/base.mjs +63 -43
- package/tools/agents/providers/grokbot.mjs +11 -12
- package/tools/agents/providers/hermes.mjs +6 -3
- package/tools/agents/providers/omp.mjs +5 -2
- package/tools/agents/providers/openclaw-translator.mjs +19 -5
- package/tools/providers/deepseek-harness-transport.mjs +358 -0
- package/tools/providers/omp-transport.mjs +192 -0
- package/tools/ralph-external/lib/claude-adapter.mjs +389 -0
- package/tools/ralph-external/lib/codex-adapter.mjs +432 -0
- package/tools/ralph-external/lib/deepseek-harness-adapter.mjs +397 -0
- package/tools/ralph-external/lib/factory-adapter.mjs +503 -0
- package/tools/ralph-external/lib/muse-adapter.mjs +364 -0
- package/tools/ralph-external/lib/omp-adapter.mjs +315 -0
- package/tools/ralph-external/lib/opencode-adapter.mjs +575 -0
- package/tools/ralph-external/lib/openhuman-adapter.mjs +75 -0
- package/tools/ralph-external/lib/pi-adapter.mjs +333 -0
- package/tools/ralph-external/lib/provider-adapter.mjs +690 -0
- package/tools/ralph-external/lib/worker-health.mjs +439 -0
- 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
|
+
}
|