@aiwg/cli 2026.10.3 → 2026.10.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (279) hide show
  1. package/README.md +2 -2
  2. package/agentic/code/providers/model-capabilities.v1.json +96 -4
  3. package/bin/aiwg.mjs +50 -27
  4. package/dist/src/agents/grounding/knowledge-base.js +54 -6
  5. package/dist/src/artifacts/browser-export.js +6 -0
  6. package/dist/src/artifacts/checksum-manifest.js +1 -0
  7. package/dist/src/artifacts/citation-parser.js +173 -92
  8. package/dist/src/artifacts/citation-table-schema.js +59 -0
  9. package/dist/src/artifacts/cli.js +69 -18
  10. package/dist/src/artifacts/corpus-tools/snapshot.js +1 -1
  11. package/dist/src/artifacts/corpus-tools/vision-extract.js +2 -0
  12. package/dist/src/artifacts/corpus-views/ref-parser.js +3 -1
  13. package/dist/src/artifacts/corpus-views/renderers.js +3 -3
  14. package/dist/src/artifacts/discovery-eval.js +6 -4
  15. package/dist/src/artifacts/fortemi-core-query-adapter.js +36 -3
  16. package/dist/src/artifacts/fortemi-core-sync.js +166 -40
  17. package/dist/src/artifacts/framework-capabilities.js +171 -0
  18. package/dist/src/artifacts/framework-index-paths.js +73 -0
  19. package/dist/src/artifacts/framework-source-digest.js +75 -0
  20. package/dist/src/artifacts/index-builder.js +286 -131
  21. package/dist/src/artifacts/index-files.js +132 -18
  22. package/dist/src/artifacts/index-preparation.js +130 -0
  23. package/dist/src/artifacts/index-refresh.js +591 -0
  24. package/dist/src/artifacts/index-worker.mjs +11 -0
  25. package/dist/src/artifacts/prebuilt-build-lock.js +47 -4
  26. package/dist/src/artifacts/prebuilt-deployment.js +46 -0
  27. package/dist/src/artifacts/prebuilt-relations.js +80 -0
  28. package/dist/src/artifacts/query-engine.js +410 -88
  29. package/dist/src/artifacts/relations-builder.js +168 -0
  30. package/dist/src/artifacts/relations.js +148 -0
  31. package/dist/src/artifacts/stats.js +5 -2
  32. package/dist/src/artifacts/types.js +30 -10
  33. package/dist/src/artifacts/watcher.js +1 -1
  34. package/dist/src/auth/credential-store.js +2 -1
  35. package/dist/src/catalog/cli.d.mts +5 -0
  36. package/dist/src/channel/manager.d.mts +20 -0
  37. package/dist/src/channel/manager.mjs +19 -2
  38. package/dist/src/cli/command-log.js +2 -3
  39. package/dist/src/cli/handlers/artifacts.js +3 -0
  40. package/dist/src/cli/handlers/auth.js +3 -0
  41. package/dist/src/cli/handlers/decision.js +19 -12
  42. package/dist/src/cli/handlers/diagnose.js +4 -3
  43. package/dist/src/cli/handlers/effect.js +4 -2
  44. package/dist/src/cli/handlers/evidence.js +3 -0
  45. package/dist/src/cli/handlers/help.js +1 -0
  46. package/dist/src/cli/handlers/index.js +3 -1
  47. package/dist/src/cli/handlers/init.js +4 -1
  48. package/dist/src/cli/handlers/lint.js +5 -0
  49. package/dist/src/cli/handlers/models.js +104 -8
  50. package/dist/src/cli/handlers/ralph-launcher.js +2 -0
  51. package/dist/src/cli/handlers/refresh.js +12 -0
  52. package/dist/src/cli/handlers/resource-versions.js +3 -0
  53. package/dist/src/cli/handlers/rules.js +195 -0
  54. package/dist/src/cli/handlers/run.js +4 -0
  55. package/dist/src/cli/handlers/runtime-info.js +1 -2
  56. package/dist/src/cli/handlers/script-runner.js +3 -2
  57. package/dist/src/cli/handlers/session.js +19 -9
  58. package/dist/src/cli/handlers/sessions.js +137 -43
  59. package/dist/src/cli/handlers/setup-manifest.js +39 -44
  60. package/dist/src/cli/handlers/setup.js +41 -0
  61. package/dist/src/cli/handlers/steward.js +11 -0
  62. package/dist/src/cli/handlers/subcommands.js +57 -2
  63. package/dist/src/cli/handlers/uhp.js +3 -0
  64. package/dist/src/cli/handlers/use.js +294 -55
  65. package/dist/src/cli/handlers/utilities.js +26 -10
  66. package/dist/src/cli/handlers/version.js +8 -0
  67. package/dist/src/cli/router.js +15 -1
  68. package/dist/src/cli/services/deployment-verification.js +19 -9
  69. package/dist/src/cli/services/provider-prepare-coordinator.js +24 -0
  70. package/dist/src/cli/skill-usage.js +2 -3
  71. package/dist/src/config/aiwg-config.js +87 -37
  72. package/dist/src/config/cli.js +111 -5
  73. package/dist/src/config/decision-config.js +106 -0
  74. package/dist/src/config/dispatch-thresholds-runtime.d.mts +8 -0
  75. package/dist/src/config/gitignore.js +50 -0
  76. package/dist/src/config/index-graph-overrides.js +36 -0
  77. package/dist/src/config/project-artifacts-health.d.mts +37 -0
  78. package/dist/src/config/project-artifacts-runtime.d.mts +16 -0
  79. package/dist/src/config/user-config-dir.d.mts +9 -0
  80. package/dist/src/config/user-config.js +2 -1
  81. package/dist/src/config/user-registry.js +23 -15
  82. package/dist/src/config/user-state.js +7 -0
  83. package/dist/src/decision/ask.js +203 -43
  84. package/dist/src/decision/context-live-qualification.js +2 -2
  85. package/dist/src/decision/driver.js +13 -8
  86. package/dist/src/decision/ensemble/aggregate.js +23 -10
  87. package/dist/src/decision/ensemble-study/multifact-study.js +6 -2
  88. package/dist/src/decision/entry.js +2 -15
  89. package/dist/src/decision/graph-live-qualification.js +1 -1
  90. package/dist/src/decision/graph-run.js +9 -0
  91. package/dist/src/decision/heldout/collector.js +5 -13
  92. package/dist/src/decision/heldout/contract.js +30 -7
  93. package/dist/src/decision/jev-credentials.js +15 -1
  94. package/dist/src/decision/ledger.js +131 -28
  95. package/dist/src/decision/offload-activation.js +137 -0
  96. package/dist/src/decision/offload.js +550 -0
  97. package/dist/src/decision/qualification/load.js +4 -6
  98. package/dist/src/decision/savings.js +28 -0
  99. package/dist/src/effects/types.js +5 -1
  100. package/dist/src/effects/verifiers/git.js +2 -1
  101. package/dist/src/effects/verifiers/tracker.js +26 -14
  102. package/dist/src/extensions/commands/definitions.js +19 -0
  103. package/dist/src/extensions/project-local-remove.js +36 -1
  104. package/dist/src/gates/providers/loader.js +61 -17
  105. package/dist/src/installation/manager-command.d.mts +18 -0
  106. package/dist/src/installation/manager-command.mjs +14 -2
  107. package/dist/src/installation/manager.d.mts +49 -0
  108. package/dist/src/installation/manager.mjs +78 -3
  109. package/dist/src/issues/cli.js +4 -4
  110. package/dist/src/issues/local.js +24 -5
  111. package/dist/src/jobs/runner.js +1 -1
  112. package/dist/src/lint/cli.js +27 -1
  113. package/dist/src/lint/loader.js +2 -1
  114. package/dist/src/lint/reporters.js +25 -1
  115. package/dist/src/lint/rule-activation.js +43 -0
  116. package/dist/src/lint/runner.js +137 -62
  117. package/dist/src/mcp/adapters/codex-runtime.js +7 -1
  118. package/dist/src/mcp/claude-install.d.mts +4 -0
  119. package/dist/src/mcp/claude-install.mjs +41 -0
  120. package/dist/src/mcp/cli.d.mts +1 -0
  121. package/dist/src/mcp/cli.mjs +36 -11
  122. package/dist/src/mcp/config-file.d.mts +28 -0
  123. package/dist/src/mcp/config-layers.d.mts +36 -0
  124. package/dist/src/mcp/credentials.d.mts +51 -0
  125. package/dist/src/mcp/grok-build-config.d.mts +6 -0
  126. package/dist/src/mcp/grok-build-config.mjs +1 -1
  127. package/dist/src/mcp/helpers.mjs +3 -2
  128. package/dist/src/mcp/muse-native-config.d.mts +13 -0
  129. package/dist/src/mcp/muse-native-config.mjs +1 -0
  130. package/dist/src/mcp/omp-config.d.mts +4 -0
  131. package/dist/src/mcp/registry.js +3 -93
  132. package/dist/src/mcp/registry.mjs +3 -87
  133. package/dist/src/mcp/server-config.d.mts +3 -0
  134. package/dist/src/mcp/server-config.mjs +89 -0
  135. package/dist/src/mcp/server.mjs +37 -375
  136. package/dist/src/mcp/test-simple.mjs +2 -1
  137. package/dist/src/mcp/toml-editor.d.mts +4 -0
  138. package/dist/src/mcp/toml-string.d.mts +1 -0
  139. package/dist/src/mcp/toml-strip-mcp.d.mts +1 -0
  140. package/dist/src/mcp/tool-filters.d.mts +46 -0
  141. package/dist/src/mcp/tools/decision.mjs +12 -11
  142. package/dist/src/mcp/tools/discovery.mjs +62 -55
  143. package/dist/src/mcp/tools/orchestration.mjs +12 -2
  144. package/dist/src/mcp/tools/worker-invoke.mjs +1340 -23
  145. package/dist/src/mcp/tools/worker-shell-control.mjs +52 -21
  146. package/dist/src/mcp/tools/worker-stdin-runtime.mjs +3 -3
  147. package/dist/src/mission-protocol/codecs.js +8 -0
  148. package/dist/src/models/config-loader.js +7 -2
  149. package/dist/src/models/model-capabilities.v1.json +96 -4
  150. package/dist/src/models/model-discovery.js +76 -11
  151. package/dist/src/models/provider-policy.js +101 -7
  152. package/dist/src/models/qualification-digest.d.mts +2 -0
  153. package/dist/src/models/resolver.js +12 -10
  154. package/dist/src/models/worker-direct-readiness.js +65 -44
  155. package/dist/src/models/worker-dispatch-offer.js +3 -3
  156. package/dist/src/models/worker-health-readiness.js +16 -3
  157. package/dist/src/models/worker-installation.d.mts +39 -0
  158. package/dist/src/models/worker-installation.mjs +135 -28
  159. package/dist/src/process/bounded-child-process.d.mts +84 -0
  160. package/dist/src/process/bounded-child-process.mjs +43 -0
  161. package/dist/src/process/cli-command.d.mts +16 -0
  162. package/dist/src/process/signal-child.d.mts +6 -0
  163. package/dist/src/process/signal-child.mjs +7 -0
  164. package/dist/src/providers/deployed-skill-roots.js +20 -0
  165. package/dist/src/providers/grok-build-acp.js +3 -2
  166. package/dist/src/providers/grok-build-headless.js +12 -4
  167. package/dist/src/providers/grok-build-inspect.js +1 -0
  168. package/dist/src/providers/omp-agent.d.mts +8 -0
  169. package/dist/src/providers/omp-diagnostics.d.mts +1 -0
  170. package/dist/src/providers/omp-paths.d.mts +13 -0
  171. package/dist/src/providers/skill-ownership.d.mts +9 -0
  172. package/dist/src/providers/skill-ownership.mjs +88 -0
  173. package/dist/src/rules/activation.d.mts +21 -0
  174. package/dist/src/rules/activation.mjs +62 -0
  175. package/dist/src/rules/inline-budget.d.mts +26 -0
  176. package/dist/src/rules/inline-budget.mjs +66 -0
  177. package/dist/src/rules/inventory.js +129 -0
  178. package/dist/src/rules/pin-state.js +73 -0
  179. package/dist/src/rules/pins.d.mts +14 -0
  180. package/dist/src/rules/pins.mjs +112 -0
  181. package/dist/src/rules/rule-frontmatter.schema.json +197 -0
  182. package/dist/src/serve/mission-conductor.js +38 -12
  183. package/dist/src/serve/worker-invocation.js +30 -10
  184. package/dist/src/serve/worker-stdin-frame.d.mts +10 -0
  185. package/dist/src/sessions/adapter-registry.js +23 -19
  186. package/dist/src/sessions/adapters/claude.js +39 -54
  187. package/dist/src/sessions/adapters/codex.js +95 -67
  188. package/dist/src/sessions/adapters/copilot.js +4 -4
  189. package/dist/src/sessions/adapters/cursor.js +40 -57
  190. package/dist/src/sessions/adapters/deepseek-harness.js +15 -12
  191. package/dist/src/sessions/adapters/factory.js +31 -27
  192. package/dist/src/sessions/adapters/generic.js +10 -10
  193. package/dist/src/sessions/adapters/grok-build.js +11 -11
  194. package/dist/src/sessions/adapters/grokbot.js +24 -13
  195. package/dist/src/sessions/adapters/hermes.js +27 -29
  196. package/dist/src/sessions/adapters/muse-workspace-scan.js +31 -11
  197. package/dist/src/sessions/adapters/muse.js +150 -93
  198. package/dist/src/sessions/adapters/omp.js +28 -14
  199. package/dist/src/sessions/adapters/openclaw.js +89 -79
  200. package/dist/src/sessions/adapters/opencode.js +27 -24
  201. package/dist/src/sessions/adapters/openhuman.js +18 -60
  202. package/dist/src/sessions/adapters/pi.js +29 -14
  203. package/dist/src/sessions/adapters/warp.js +3 -3
  204. package/dist/src/sessions/adapters/windsurf.js +19 -48
  205. package/dist/src/sessions/batch-contracts.js +16 -8
  206. package/dist/src/sessions/batch-import.js +252 -12
  207. package/dist/src/sessions/contracts.js +50 -3
  208. package/dist/src/sessions/coverage-preflight.js +27 -2
  209. package/dist/src/sessions/export-plan.js +4 -3
  210. package/dist/src/sessions/import-exclusions.js +72 -0
  211. package/dist/src/sessions/import-lease.js +34 -18
  212. package/dist/src/sessions/import-limits.js +37 -0
  213. package/dist/src/sessions/importer.js +60 -38
  214. package/dist/src/sessions/lifecycle.js +45 -0
  215. package/dist/src/sessions/policy.js +145 -26
  216. package/dist/src/sessions/readers.js +106 -53
  217. package/dist/src/sessions/refresh.js +105 -0
  218. package/dist/src/sessions/repository.js +151 -54
  219. package/dist/src/sessions/workspace-discovery.js +292 -60
  220. package/dist/src/sessions/workspace-matching.js +107 -0
  221. package/dist/src/skills/importer.js +1 -0
  222. package/dist/src/smiths/context-pipeline/aiwg-md.js +11 -0
  223. package/dist/src/smiths/context-pipeline/claude-hook.js +3 -26
  224. package/dist/src/smiths/context-pipeline/finalization.js +6 -5
  225. package/dist/src/smiths/context-pipeline/generator.js +21 -4
  226. package/dist/src/smiths/context-pipeline/parallelism-section.js +7 -15
  227. package/dist/src/smiths/mcpsmith/generator.js +1 -1
  228. package/dist/src/smiths/toolsmith/runtime-discovery.d.mts +10 -0
  229. package/dist/src/smiths/toolsmith/runtime-discovery.mjs +76 -18
  230. package/dist/src/storage/backends/fs.js +18 -76
  231. package/dist/src/storage/backends/obsidian.js +1 -0
  232. package/dist/src/tracker/forge-http.js +2 -1
  233. package/dist/src/update/checker.d.mts +2 -0
  234. package/dist/src/update/service.d.mts +22 -0
  235. package/dist/src/utils/contained-filesystem.d.mts +37 -0
  236. package/dist/src/utils/contained-filesystem.mjs +683 -0
  237. package/dist/src/utils/temporary-directory.js +36 -0
  238. package/dist/src/writing/example-generator.js +1 -1
  239. package/package.json +15 -2
  240. package/schemas/models/model-catalog.v1.schema.json +1 -1
  241. package/schemas/models/provider-model-capabilities.v1.schema.json +6 -0
  242. package/tools/_resolve-impl.mjs +13 -15
  243. package/tools/agents/deploy-agents.mjs +49 -21
  244. package/tools/agents/providers/base.mjs +104 -134
  245. package/tools/agents/providers/claude.mjs +8 -24
  246. package/tools/agents/providers/codex.mjs +18 -11
  247. package/tools/agents/providers/cursor.mjs +19 -18
  248. package/tools/agents/providers/grok-build.mjs +3 -1
  249. package/tools/agents/providers/hermes.mjs +57 -47
  250. package/tools/agents/providers/openclaw.mjs +9 -4
  251. package/tools/agents/providers/pi.mjs +5 -0
  252. package/tools/agents/providers/provider-prepare-channel.mjs +5 -0
  253. package/tools/agents/providers/provider-prepare-coordinator.mjs +247 -0
  254. package/tools/agents/providers/provider-prepare-worker.mjs +84 -0
  255. package/tools/agents/providers/rule-delivery.mjs +1270 -0
  256. package/tools/agents/providers/warp.mjs +20 -26
  257. package/tools/cli/decision-offload-report.mjs +40 -0
  258. package/tools/cli/workspace-status.mjs +862 -0
  259. package/tools/plugin/package-plugins.mjs +186 -8
  260. package/tools/plugin/plugin-links.mjs +114 -0
  261. package/tools/providers/antigravity-transport.mjs +3 -1
  262. package/tools/providers/deepseek-harness-transport.mjs +6 -4
  263. package/tools/providers/omp-transport.mjs +4 -2
  264. package/tools/ralph-external/lib/consecutive-failure-circuit-breaker.mjs +1748 -0
  265. package/tools/ralph-external/lib/launcher-recovery-evidence.mjs +55 -0
  266. package/tools/ralph-external/lib/omp-adapter.mjs +1 -1
  267. package/tools/ralph-external/lib/pi-adapter.mjs +0 -4
  268. package/tools/ralph-external/lib/provider-adapter.mjs +59 -1
  269. package/tools/ralph-external/lib/recovery-probe.mjs +72 -0
  270. package/tools/ralph-external/lib/session-environment.mjs +28 -0
  271. package/tools/ralph-external/lib/systemd-client.mjs +49 -0
  272. package/tools/ralph-external/lib/worker-health.mjs +1 -1
  273. package/tools/ralph-external/lib/worker-isolation.mjs +717 -0
  274. package/tools/ralph-external/lib/worker-overflow.mjs +50 -0
  275. package/tools/ralph-external/process-monitor.mjs +1017 -0
  276. package/tools/ralph-external/session-launcher.mjs +5057 -0
  277. package/tools/scripts/resource-preflight.mjs +541 -0
  278. package/tools/skills/deploy-skills-codex.mjs +2 -1
  279. package/vscode-extension/schemas/aiwg.config.v1.json +1194 -231
@@ -0,0 +1,717 @@
1
+ /**
2
+ * Worker process isolation for Tier 0 direct dispatch (FR-OG-108, #2954).
3
+ *
4
+ * Direct workers must never share the conductor's cgroup: on the titan host
5
+ * an oomd kill of one shared cgroup took the conductor session with it.
6
+ * This module builds `systemd-run --user` launch arguments (own unit or
7
+ * scope with `MemoryMax` and `MemorySwapMax=0`, PATH/HOME passthrough,
8
+ * stdin `/dev/null`) and, where no systemd user manager exists, returns an
9
+ * explicit DEGRADED diagnostic instead of silently launching unconfined.
10
+ *
11
+ * Pure argument construction is fully unit-tested with synthetic inputs. The
12
+ * single integration test executes a real `systemd-run --user` only when a
13
+ * user manager is reachable and skips otherwise. No worker is ever launched
14
+ * by importing this module.
15
+ *
16
+ * Callers (conductors, bridges, future #2888 templates) use this flow:
17
+ *
18
+ * ```js
19
+ * import { checkWorkerDispatch } from '../../../scripts/resource-preflight.mjs';
20
+ * import { detectWorkerIsolation, launchIsolatedWorker } from './worker-isolation.mjs';
21
+ *
22
+ * const gate = checkWorkerDispatch({ config });
23
+ * // Over threshold -> queue, pick another qualified host, or reject. Never launch.
24
+ * const outcome = launchIsolatedWorker({ name, memoryMaxGb, command, args, cwd, logFile, gate });
25
+ * ```
26
+ *
27
+ * `prepareWorkerInvocation` (provider-adapter.mjs) deliberately stays
28
+ * launch-free: it prepares and validates the descriptor, while the conductor
29
+ * owns the gate-then-launch sequence above. No mandatory enforcement is
30
+ * wired into #2887/#2888 internals by this change.
31
+ */
32
+
33
+ import { spawn as nodeSpawn, spawnSync } from 'node:child_process';
34
+ import { fileURLToPath } from 'node:url';
35
+ import { constants, openSync, closeSync, writeFileSync, fsyncSync, renameSync, lstatSync, realpathSync, unlinkSync } from 'node:fs';
36
+ import path from 'node:path';
37
+ import { sampleWorkerIdentity, createWorkerIdentityCapture } from '../process-monitor.mjs';
38
+ import { checkWorkerDispatch } from '../../scripts/resource-preflight.mjs';
39
+ import { buildSystemdClientEnv, resolveSystemdClient, resolveSystemdRun, CONNECTION_NAMES, unitEnvironmentNames, isClientControlVariable } from './systemd-client.mjs';
40
+
41
+ const UNIT_SAFE = /[^A-Za-z0-9:_.@-]/g;
42
+ const transportBootstrapVariables = ['NODE_OPTIONS', 'NODE_PATH', 'LD_PRELOAD', 'LD_LIBRARY_PATH', 'LD_AUDIT', 'LD_DEBUG', 'LD_DEBUG_OUTPUT'];
43
+
44
+ /** Derive a unit-safe `aiwg-worker-<name>-<epoch>` unit name. */
45
+ export function proposeWorkerUnitName(name, nowSeconds = Math.floor(Date.now() / 1000)) {
46
+ const stem = String(name ?? 'worker').replace(UNIT_SAFE, '-').replace(/^-+|-+$/g, '').slice(0, 64) || 'worker';
47
+ return `aiwg-worker-${stem}-${nowSeconds}`;
48
+ }
49
+
50
+ function assertAbsolute(label, value) {
51
+ if (typeof value !== 'string' || !value.startsWith('/') || value.includes('\0')) {
52
+ throw new Error(`Worker isolation requires an absolute NUL-free ${label}`);
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Detect the worker isolation mode without launching anything.
58
+ * `{ spawn }` is injectable so tests never touch the real user manager.
59
+ * Returns `{ mode: 'systemd-unit' | 'degraded', reason, detail? }`.
60
+ */
61
+ export function detectWorkerIsolation({ spawn = spawnSync } = {}) {
62
+ if (process.platform !== 'linux') {
63
+ return { mode: 'degraded', reason: `systemd user isolation requires Linux (this host: ${process.platform})` };
64
+ }
65
+ let runner;
66
+ try {
67
+ runner = spawn(resolveSystemdRun(), ['--version'], { encoding: 'utf8', timeout: 10000, env: buildSystemdClientEnv({ envNames: [] }) });
68
+ } catch (error) {
69
+ return { mode: 'degraded', reason: `systemd-run is unavailable: ${error.message}` };
70
+ }
71
+ if (runner.error || runner.status !== 0) {
72
+ return { mode: 'degraded', reason: 'systemd-run is unavailable (no --version response)' };
73
+ }
74
+ let manager;
75
+ try {
76
+ manager = spawn(resolveSystemdClient('systemctl'), ['--user', 'show', '-p', 'Version', '--value'], { encoding: 'utf8', timeout: 10000, env: buildSystemdClientEnv({ envNames: [] }) });
77
+ } catch (error) {
78
+ return { mode: 'degraded', reason: `systemd user manager is unreachable: ${error.message}` };
79
+ }
80
+ if (manager.error || manager.status !== 0) {
81
+ return { mode: 'degraded', reason: 'systemd user manager is unreachable (systemctl --user failed)' };
82
+ }
83
+ return { mode: 'systemd-unit', reason: 'systemd user manager is reachable', detail: String(manager.stdout ?? '').trim() };
84
+ }
85
+
86
+ /**
87
+ * Build the `systemd-run --user` argv for one isolated worker. Pure and
88
+ * fully unit-tested. `kind` is `'unit'` (default, matches the field
89
+ * prototype: own unit retained for post-exit status inspection) or `'scope'`.
90
+ * Failed units require explicit `systemctl --user reset-failed <unit>` cleanup
91
+ * after Result/ExecMainStatus have been recorded; --collect would erase them.
92
+ * `env` supplies passthrough values (defaults to the conductor's current
93
+ * `PATH`/`HOME`); only the allowlisted `envNames` are forwarded, and their
94
+ * values are never written into argv. Each is requested with bare
95
+ * `--setenv=NAME` (no `=value`), which (systemd >= 230; confirmed on this
96
+ * host's systemd 259) imports the value from systemd-run's *own* invoking
97
+ * process environment rather than rendering it into the unit's argv — so a
98
+ * secret value never appears in `/proc/<systemd-run-pid>/cmdline`. That
99
+ * import is additive to whatever the user manager already injects into every
100
+ * unit (`systemctl --user show-environment`: LANG, XDG_*, SSH_AUTH_SOCK,
101
+ * etc. on an interactive manager), so `managerEnvNames` — the caller's own
102
+ * live snapshot of that list — drives a single `-p UnsetEnvironment=...`
103
+ * removing every manager-injected name that is not on the allowlist, plus
104
+ * all client-only connection names. Bootstrap controls are refused.
105
+ * Scopes ignore UnsetEnvironment, so launchIsolatedWorker strips connection
106
+ * names with the private wrapper used for attached units.
107
+ *
108
+ * `attached: true` (#2892 B2 / #2895) is `kind: 'unit'` plus `--wait --pipe
109
+ * --quiet`: systemd-run itself stays attached for the lifetime of the unit,
110
+ * connecting the unit's stdin/stdout/stderr directly to systemd-run's own
111
+ * (i.e. to whatever stdio the *caller* gave the systemd-run child process),
112
+ * and systemd-run's own exit mirrors the unit's exit status. This is how an
113
+ * MCP stdio worker can be launched resource-confined while still giving the
114
+ * conductor a live, bidirectional stdio stream to speak JSON-RPC over — the
115
+ * same resource ceiling (`MemoryMax`/`MemorySwapMax`) and environment-
116
+ * allowlist contract as the fire-and-forget `'unit'` path, just attached
117
+ * instead of detached, plus the transport-bootstrap hardening
118
+ * (`KillMode=control-group`/`ExitType=cgroup`/`UMask=0077`, refusal of
119
+ * `NODE_OPTIONS`/`LD_PRELOAD`/etc. in the allowlist) `launchIsolatedWorker`
120
+ * layers on top. Attached is therefore incompatible with `stdinPath`/
121
+ * `stdoutPath`/`stderrPath`/`logFile` (those redirect to files; `--pipe`
122
+ * redirects to the systemd-run client's own stdio) — passing any of them
123
+ * with `attached: true` is refused rather than silently ignored.
124
+ *
125
+ * `pipe`/`wait` are separate boolean flags (independent of `attached`) for a
126
+ * synchronous verification caller that wants `--pipe`/`--wait` (and, when
127
+ * both are set, the mandatory `--quiet` below) without the attached/async
128
+ * child contract — see `verification-isolation.mjs`.
129
+ *
130
+ * `stdinPath`, when given, feeds the worker's stdin from that file
131
+ * (`StandardInputFile=`) instead of the default `StandardInput=null` — this
132
+ * is how a resolved #2887 invocation's brief reaches the worker. `stdoutPath`
133
+ * / `stderrPath`, when given, route stdout/stderr to distinct files; either
134
+ * falls back to the combined `logFile` when omitted, so existing combined-log
135
+ * callers are unaffected. Both use systemd's `truncate:` output mode
136
+ * (systemd >= 248) rather than `file:` (which appends with O_APPEND), so a
137
+ * pre-existing file at that path is replaced, not appended to.
138
+ */
139
+ export function buildWorkerIsolationArgs({
140
+ unit,
141
+ memoryMaxGb,
142
+ command,
143
+ args = [],
144
+ cwd,
145
+ logFile,
146
+ stdinPath,
147
+ stdoutPath,
148
+ stderrPath,
149
+ env = process.env,
150
+ envNames = ['PATH', 'HOME'],
151
+ managerEnvNames = [],
152
+ kind = 'unit',
153
+ extraProperties = [],
154
+ pipe = false,
155
+ wait = false,
156
+ memoryMax,
157
+ attached = false,
158
+ } = {}) {
159
+ if (typeof unit !== 'string' || !/^[A-Za-z0-9:_.@-]+$/.test(unit)) {
160
+ throw new Error('Worker isolation requires a unit-safe unit name');
161
+ }
162
+ const hasMemoryMaxGb = memoryMaxGb !== undefined;
163
+ if (hasMemoryMaxGb && (!Number.isFinite(memoryMaxGb) || memoryMaxGb <= 0)) {
164
+ throw new Error('Worker isolation requires a positive memoryMaxGb ceiling');
165
+ }
166
+ if (!hasMemoryMaxGb && memoryMax === undefined) {
167
+ throw new Error('Worker isolation requires a positive memoryMaxGb ceiling');
168
+ }
169
+ assertAbsolute('worker binary', command);
170
+ for (const token of args) {
171
+ if (typeof token !== 'string' || token.includes('\0')) throw new Error('Worker isolation argv must be NUL-free strings');
172
+ }
173
+ if (kind !== 'unit' && kind !== 'scope') throw new Error("Worker isolation kind must be 'unit' or 'scope'");
174
+ if (attached && (stdinPath !== undefined || stdoutPath !== undefined || stderrPath !== undefined || logFile !== undefined)) {
175
+ throw new Error('Worker isolation attached mode connects stdio directly through systemd-run and cannot also redirect stdinPath/stdoutPath/stderrPath/logFile');
176
+ }
177
+ if (cwd !== undefined) assertAbsolute('working directory', cwd);
178
+ if (logFile !== undefined) assertAbsolute('log file', logFile);
179
+ if (stdinPath !== undefined) assertAbsolute('stdin brief', stdinPath);
180
+ if (stdoutPath !== undefined) assertAbsolute('stdout path', stdoutPath);
181
+ if (stderrPath !== undefined) assertAbsolute('stderr path', stderrPath);
182
+ if (!Array.isArray(extraProperties) || extraProperties.some(entry => typeof entry !== 'string' || entry.includes('\0'))) {
183
+ throw new Error('Worker isolation extraProperties must be an array of NUL-free "Name=value" strings');
184
+ }
185
+
186
+ const argv = ['systemd-run', '--user', '--expand-environment=no'];
187
+ if (attached) {
188
+ if (kind !== 'unit' || logFile !== undefined) throw new Error('Attached worker requires a unit with separate pipe captures');
189
+ argv.push('--wait', '--pipe', '--quiet');
190
+ argv.push('-p', 'KillMode=control-group', '-p', 'ExitType=cgroup', '-p', 'UMask=0077',
191
+ '-p', `UnsetEnvironment=${transportBootstrapVariables.join(' ')}`);
192
+ }
193
+ if (kind === 'unit') argv.push(`--unit=${unit}`);
194
+ else argv.push('--scope');
195
+ // Attached launches already pass --wait --pipe --quiet above.
196
+ if (pipe && !attached) argv.push('--pipe');
197
+ if (wait && !attached) argv.push('--wait');
198
+ // --pipe connects the unit's stdio directly to systemd-run's own, but
199
+ // systemd-run still writes its *own* "Running as unit:.../Finished with
200
+ // result:..." status lines to that same stderr unless --quiet is given —
201
+ // verified empirically on this host. A synchronous verification caller
202
+ // needs the check's actual stderr, not systemd-run's narration mixed into
203
+ // it, so --quiet is mandatory whenever both --pipe and --wait are used.
204
+ if (pipe && wait && !attached) argv.push('--quiet');
205
+ const requestedNames = [];
206
+ for (const name of unitEnvironmentNames(envNames)) {
207
+ const value = env?.[name];
208
+ if (value === undefined) continue;
209
+ if (typeof value !== 'string' || value.includes('\0')) throw new Error(`Worker isolation environment value for ${name} is not a NUL-free string`);
210
+ requestedNames.push(name);
211
+ argv.push(`--setenv=${name}`);
212
+ }
213
+ if (kind === 'unit') {
214
+ const toUnset = [...new Set([...managerEnvNames, ...CONNECTION_NAMES])].filter(name => !requestedNames.includes(name));
215
+ if (toUnset.length > 0) argv.push('-p', `UnsetEnvironment=${toUnset.join(' ')}`);
216
+ }
217
+ // stdin is /dev/null unless a brief file is given; attached (supervised)
218
+ // launches rely on systemd-run --pipe to carry stdin, so leave it unset.
219
+ if (stdinPath !== undefined) argv.push('-p', `StandardInputFile=${stdinPath}`);
220
+ else if (!attached) argv.push('-p', 'StandardInput=null');
221
+ if (hasMemoryMaxGb) argv.push('-p', `MemoryMax=${memoryMaxGb}G`);
222
+ else if (memoryMax !== undefined) argv.push('-p', `MemoryMax=${memoryMax}`);
223
+ argv.push('-p', 'MemorySwapMax=0');
224
+ const outPath = stdoutPath ?? logFile;
225
+ const errPath = stderrPath ?? logFile;
226
+ if (!pipe && outPath !== undefined) argv.push('-p', `StandardOutput=truncate:${outPath}`);
227
+ if (!pipe && errPath !== undefined) argv.push('-p', `StandardError=truncate:${errPath}`);
228
+ // Additional conductor-supplied confinement properties (ReadOnlyPaths,
229
+ // ReadWritePaths, NoNewPrivileges, PrivateUsers, PrivateNetwork, ...),
230
+ // appended verbatim as repeated `-p Name=value` pairs. Callers are
231
+ // responsible for probing which of these this host's systemd/AppArmor
232
+ // policy actually enforces (see verification-isolation.mjs) — some
233
+ // properties silently no-op or fall back without a non-zero exit on
234
+ // kernels/policies that deny the namespace privilege they need.
235
+ for (const property of extraProperties) argv.push('-p', property);
236
+ if (cwd !== undefined) argv.push(`--working-directory=${cwd}`);
237
+ argv.push('--', command, ...args);
238
+ return argv;
239
+ }
240
+
241
+ /**
242
+ * Query the user manager's own inherited-environment variable names
243
+ * (`systemctl --user show-environment`), required so a `kind: 'unit'`
244
+ * launch can `UnsetEnvironment=` every manager-injected name that is not on
245
+ * the caller's allowlist (see `buildWorkerIsolationArgs`' doc comment).
246
+ * Extracted from `launchIsolatedWorker` so verification callers can reuse
247
+ * the exact same security-relevant query instead of re-deriving it.
248
+ */
249
+ export function resolveManagerEnvNames({ spawn = spawnSync } = {}) {
250
+ const shown = spawn(resolveSystemdClient('systemctl'), ['--user', 'show-environment'], { encoding: 'utf8', timeout: 10000, env: buildSystemdClientEnv({ envNames: [] }) });
251
+ if (shown.error || shown.status !== 0) throw new Error(shown.error?.message ?? (String(shown.stderr ?? '').trim() || `exit ${shown.status}`));
252
+ return String(shown.stdout ?? '').split('\n').map(line => line.slice(0, line.indexOf('='))).filter(Boolean);
253
+ }
254
+
255
+ /**
256
+ * Gate, then launch one worker in its own resource scope (FR-OG-107/108).
257
+ *
258
+ * - `gate` must be a `checkWorkerDispatch()` result. Missing results refuse
259
+ * launch. Explicit `gate: 'disabled'` requires worker_dispatch off in config.
260
+ * A failing gate returns
261
+ * `{ launched: false, blocked: true, failures }` and nothing launches.
262
+ * - Without a reachable systemd user manager the result is an explicit
263
+ * `{ launched: false, mode: 'degraded', diagnostic }` unless the caller
264
+ * passes `allowDegraded: true`, which launches the command directly with
265
+ * stdin `/dev/null` and records the degraded mode for the qualification
266
+ * record. Degraded launches never claim isolation.
267
+ * - Degraded launches return an async child/pid handle without a deadline.
268
+ * `spawn` (manager queries), `spawnAsync`, and `proposeUnit` are injectable.
269
+ * - `attached: true` (#2892 B2) launches with live stdio pipes instead of
270
+ * file redirection/`/dev/null`, for callers (e.g. an in-process MCP stdio
271
+ * client) that need to read/write the worker's stdin/stdout directly
272
+ * rather than poll for termination afterward. Incompatible with
273
+ * `stdinPath`/`stdoutPath`/`stderrPath`/`logFile` (refused, not ignored).
274
+ * Degraded-attached returns the usual `{ mode: 'degraded', child }`; the
275
+ * confined path returns `{ mode: 'systemd-unit', child }` where `child` is
276
+ * the live `systemd-run --pipe --wait` process itself — its stdio IS the
277
+ * unit's stdio, and its own `close` event fires with the unit's exit code.
278
+ */
279
+ /**
280
+ * Run the real process-start call, optionally gated by the D2 dispatch-
281
+ * commit hook (#2915 round 8). `spawnFn` performs the actual, otherwise
282
+ * irreversible side effect (the `spawn`/`spawnAsync` call) and returns the
283
+ * same result shape `launchIsolatedWorker` always returned.
284
+ *
285
+ * Dual-mode, deliberately: when `onDispatchCommit` is omitted (every
286
+ * existing caller — direct production callers, every test in this repo
287
+ * that constructs `launchIsolatedWorker` options itself), this returns
288
+ * `spawnFn()`'s result DIRECTLY and synchronously — `launchIsolatedWorker`
289
+ * is NOT declared `async`, and its sync return contract for every pre-D2
290
+ * caller is completely unaffected; nothing here ever makes an existing
291
+ * caller receive a Promise where it previously received a plain object.
292
+ * Only when a caller supplies the hook (today: `session-launcher.mjs`'s own
293
+ * gated launch paths) does this return a Promise, which that caller already
294
+ * awaits.
295
+ *
296
+ * When the hook IS given, it runs immediately before `spawnFn()` — after
297
+ * every preparation check `launchIsolatedWorker` performs for whichever
298
+ * branch (degraded/systemd-unit, attached/non-attached) is actually taken.
299
+ * If it throws, `spawnFn` is NEVER called — nothing starts — and the thrown
300
+ * error is converted into the same `{ launched: false, blocked: true,
301
+ * failures: [...] }` refusal shape every other pre-spawn refusal in this
302
+ * module already uses, rather than propagating as a rejected promise: a
303
+ * proven pre-spawn refusal is a RESULT here, consistent with the dispatch
304
+ * gate and degraded-without-allowDegraded refusals above it.
305
+ *
306
+ * `onAbort` (#2915 round 8 review 8, C3 follow-up), when given, runs ONLY
307
+ * on that same hook-throws path -- it exists so a caller that had to open
308
+ * resources (e.g. the non-attached degraded path's stdio file descriptors)
309
+ * BEFORE this call, so that opening them is itself part of "every
310
+ * preparation check" the hook boundary assumes already ran, can release
311
+ * them when the hook aborts the dispatch instead of leaking them.
312
+ */
313
+ function _runDispatchCommit(onDispatchCommit, spawnFn, onAbort) {
314
+ if (!onDispatchCommit) return spawnFn();
315
+ return (async () => {
316
+ try {
317
+ await onDispatchCommit();
318
+ } catch (error) {
319
+ if (onAbort) { try { onAbort(); } catch { /* best-effort cleanup only */ } }
320
+ return { launched: false, blocked: true,
321
+ failures: [{ signal: 'dispatch-commit', reason: error?.message ?? String(error) }] };
322
+ }
323
+ return spawnFn();
324
+ })();
325
+ }
326
+
327
+ export function launchIsolatedWorker({
328
+ name = 'worker',
329
+ memoryMaxGb,
330
+ command,
331
+ args = [],
332
+ cwd,
333
+ logFile,
334
+ stdinPath,
335
+ stdoutPath,
336
+ stderrPath,
337
+ env = process.env,
338
+ envNames = ['PATH', 'HOME'],
339
+ gate,
340
+ config,
341
+ allowDegraded = false,
342
+ isolation,
343
+ kind = 'unit',
344
+ attached = false,
345
+ spawn = spawnSync,
346
+ spawnAsync = nodeSpawn,
347
+ proposeUnit = proposeWorkerUnitName,
348
+ stdin = 'closed',
349
+ ownedGroup = false,
350
+ onSpawnAttempt,
351
+ runDir,
352
+ // #2915 round 8 (D2): INTERNAL dispatch-commit hook — never documented as
353
+ // a public launch option; only `session-launcher.mjs`'s own gated launch
354
+ // paths (and, through `planDirectWorkerLaunch`'s borrowed-admission
355
+ // branch, `src/mcp/tools/worker-invoke.mjs`'s two dispatch boundaries)
356
+ // ever pass it. See `_runDispatchCommit`'s doc comment for the exact
357
+ // contract and why every existing caller that omits it is byte-for-byte
358
+ // unaffected.
359
+ onDispatchCommit,
360
+ } = {}) {
361
+ const resolvedGate = gate === 'disabled' && !config?.parallelism?.worker_dispatch
362
+ ? checkWorkerDispatch({ config })
363
+ : gate && typeof gate.ok === 'boolean' && typeof gate.enabled === 'boolean' ? gate : undefined;
364
+ if (!resolvedGate || (config?.parallelism?.worker_dispatch && resolvedGate.enabled === false)) {
365
+ return { launched: false, blocked: true, failures: [{ signal: 'gate', reason: 'worker launch requires a dispatch gate result; disabled is allowed only with worker_dispatch off' }] };
366
+ }
367
+ if (!resolvedGate.ok) {
368
+ return { launched: false, blocked: true, failures: resolvedGate.failures ?? [], gate: resolvedGate };
369
+ }
370
+ const detected = isolation ?? detectWorkerIsolation({ spawn });
371
+ if (detected.mode === 'degraded' && !allowDegraded) {
372
+ return {
373
+ launched: false,
374
+ mode: 'degraded',
375
+ diagnostic: `worker isolation unavailable: ${detected.reason}; pass allowDegraded:true to launch unconfined with stdin /dev/null (recorded as degraded)`,
376
+ gate: resolvedGate,
377
+ };
378
+ }
379
+ if (detected.mode === 'degraded') {
380
+ assertAbsolute('worker binary', command);
381
+ if (cwd !== undefined) assertAbsolute('working directory', cwd);
382
+ if (stdinPath !== undefined) assertAbsolute('stdin brief', stdinPath);
383
+ if (stdoutPath !== undefined) assertAbsolute('stdout path', stdoutPath);
384
+ if (stderrPath !== undefined) assertAbsolute('stderr path', stderrPath);
385
+ if (!Array.isArray(args) || args.some(token => typeof token !== 'string' || token.includes('\0'))) {
386
+ throw new Error('Worker isolation argv must be NUL-free strings');
387
+ }
388
+ // Degraded mode has no systemd unit to confine the environment, so this
389
+ // module builds the allowlisted environment itself and passes it as the
390
+ // child's *entire* environment (the `env` option replaces, never merges
391
+ // with, the current process environment) — the functional equivalent of
392
+ // `env -i NAME=value ...`. The conductor's own environment (secrets,
393
+ // NODE_OPTIONS, etc.) is therefore never inherited, allowlisted or not.
394
+ // This applies equally to the plain direct launch below and to the
395
+ // attached transport wrapper, which receives this same allowedEnv as its
396
+ // own process environment rather than the caller's raw `env`.
397
+ const allowedEnv = {};
398
+ for (const key of envNames) {
399
+ const value = env?.[key];
400
+ if (value !== undefined) allowedEnv[key] = value;
401
+ }
402
+ // #2892 F6: the transport-bootstrap refusal and the private wrapper
403
+ // routing both apply to EVERY attached launch, with or without a
404
+ // `runDir` — a caller that supplies no `runDir` (e.g. the MCP bridge,
405
+ // which has no need for the wrapper's own identity/exit record files)
406
+ // must still never spawn the worker directly with an unwrapped,
407
+ // potentially bootstrap-altering environment. `runDir` only toggles
408
+ // whether the wrapper is ALSO given `--run-dir`/`--invocation-id` to
409
+ // write those record files; it never toggles whether the wrapper runs
410
+ // at all.
411
+ if (attached && envNames.some(isClientControlVariable)) {
412
+ throw new Error('Worker environment cannot alter trusted transport bootstrap');
413
+ }
414
+ // Finish descriptor, argv and environment preparation before consuming
415
+ // dispatch authority. Keep the no-hook path synchronous.
416
+ const fds = [];
417
+ const closePreparedFds = () => {
418
+ for (const fd of fds) if (typeof fd === 'number') closeSync(fd);
419
+ };
420
+ let deferredClose = false;
421
+ try {
422
+ let launchCommand, launchArgs, launchOptions;
423
+ if (attached) {
424
+ launchCommand = process.execPath;
425
+ launchArgs = [fileURLToPath(import.meta.url), '--worker-environment', JSON.stringify(envNames),
426
+ ...(runDir ? ['--run-dir', runDir, '--invocation-id', name] : []), '--', command, ...args];
427
+ launchOptions = { cwd, stdio: [stdin === 'closed' ? 'ignore' : 'pipe', 'pipe', 'pipe'],
428
+ env: allowedEnv, ...(ownedGroup ? { detached: true } : {}) };
429
+ } else {
430
+ // Open every input/output before commit; a preparation failure never
431
+ // consumes dispatch authority. Output truncation matches the renderer.
432
+ const stdinFd = stdinPath !== undefined ? openSync(stdinPath, 'r') : 'ignore';
433
+ fds.push(stdinFd);
434
+ const stdoutFd = stdoutPath !== undefined ? openSync(stdoutPath, 'w') : 'pipe';
435
+ fds.push(stdoutFd);
436
+ const stderrFd = stderrPath !== undefined ? openSync(stderrPath, 'w') : 'pipe';
437
+ fds.push(stderrFd);
438
+ launchCommand = command;
439
+ launchArgs = args;
440
+ launchOptions = { cwd, env: allowedEnv, stdio: [stdinFd, stdoutFd, stderrFd], detached: ownedGroup };
441
+ }
442
+ const result = _runDispatchCommit(onDispatchCommit, () => {
443
+ const child = spawnAsync(launchCommand, launchArgs, launchOptions);
444
+ onSpawnAttempt?.({ mode: 'degraded', child, pid: child.pid });
445
+ const outcome = {
446
+ launched: Number.isInteger(child.pid) && child.pid > 0,
447
+ mode: 'degraded', child, pid: child.pid,
448
+ ...(attached ? { transportSupervisor: true } : {}),
449
+ diagnostic: `launched without resource confinement: ${detected.reason}`,
450
+ gate: resolvedGate,
451
+ };
452
+ // Preserve launch errors even when callers attach listeners later.
453
+ child.once('error', error => { outcome.launched = false; outcome.error = error.message; });
454
+ return outcome;
455
+ });
456
+ if (onDispatchCommit) {
457
+ const pending = result.finally(closePreparedFds);
458
+ deferredClose = true;
459
+ return pending;
460
+ }
461
+ return result;
462
+ } finally {
463
+ // Numeric stdio descriptors are duplicated into the child. Close our
464
+ // copies on every preparation, hook-refusal and spawn-error path.
465
+ if (!deferredClose) closePreparedFds();
466
+ }
467
+ }
468
+ // The unit path's security contract (see buildWorkerIsolationArgs' doc
469
+ // comment) depends on knowing exactly which names the user manager would
470
+ // otherwise inject, so it can unset every one of them that is not
471
+ // allowlisted. Without a trustworthy answer to that query, launching would
472
+ // either omit `UnsetEnvironment` (silently falling back to the padded,
473
+ // non-exact environment this fix exists to remove) or guess — both are
474
+ // refused in favor of an explicit failure.
475
+ let managerEnvNames, systemdRun;
476
+ try {
477
+ managerEnvNames = resolveManagerEnvNames({ spawn });
478
+ systemdRun = resolveSystemdRun();
479
+ } catch (error) {
480
+ return {
481
+ launched: false,
482
+ mode: 'systemd-unit',
483
+ diagnostic: `unable to determine the user manager's inherited environment or resolve the trusted systemd-run client (required to isolate the worker's environment exactly): ${error.message}`,
484
+ gate: resolvedGate,
485
+ };
486
+ }
487
+ const unit = proposeUnit(name);
488
+ // A private host-owned wrapper strips manager/client connection variables
489
+ // before spawning the real worker. Environment values never enter argv.
490
+ const wrapped = attached || kind === 'scope';
491
+ const isolatedCommand = wrapped ? process.execPath : command;
492
+ const isolatedArgs = wrapped ? [fileURLToPath(import.meta.url), '--worker-environment', JSON.stringify(unitEnvironmentNames(envNames)),
493
+ ...(runDir ? ['--run-dir', runDir, '--invocation-id', name] : []), '--', command, ...args] : args;
494
+ const argv = buildWorkerIsolationArgs({ unit, memoryMaxGb, command: isolatedCommand, args: isolatedArgs,
495
+ cwd, logFile, stdinPath, stdoutPath, stderrPath, env, envNames, managerEnvNames, kind, attached });
496
+ // #2915 round 8 (D2): same dispatch-commit boundary as the degraded path
497
+ // above — every systemd-unit preparation check (gate, manager-environment
498
+ // query, argv construction) has already run by this point.
499
+ if (attached) {
500
+ const launchArgs = argv.slice(1);
501
+ const launchOptions = { cwd, env: buildSystemdClientEnv({ env, envNames }),
502
+ stdio: [stdin === 'closed' ? 'ignore' : 'pipe', 'pipe', 'pipe'], detached: ownedGroup };
503
+ return _runDispatchCommit(onDispatchCommit, () => {
504
+ onSpawnAttempt?.({ mode: 'systemd-unit', unit });
505
+ const child = spawnAsync(systemdRun, launchArgs, launchOptions);
506
+ const outcome = { launched: Number.isInteger(child.pid) && child.pid > 0, mode: 'systemd-unit', unit, child, gate: resolvedGate };
507
+ child.once('error', error => { outcome.launched = false; outcome.error = error.message; });
508
+ return outcome;
509
+ });
510
+ }
511
+ const launchArgs = argv.slice(1);
512
+ const launchOptions = { encoding: 'utf8', timeout: 30000, env: buildSystemdClientEnv({ env, envNames }) };
513
+ return _runDispatchCommit(onDispatchCommit, () => {
514
+ onSpawnAttempt?.({ mode: 'systemd-unit', unit });
515
+ const child = spawn(systemdRun, launchArgs, launchOptions);
516
+ if (child.error || child.status !== 0) {
517
+ const stderr = String(child.stderr ?? '').trim();
518
+ return {
519
+ launched: false,
520
+ mode: 'systemd-unit',
521
+ unit,
522
+ // #2915 round 8 review 8 (C2): a failed or timed-out `systemd-run`
523
+ // submission does not PROVE the transient unit was never created
524
+ // (e.g. a 30s wait timeout after the unit itself started) -- carry
525
+ // this forward as dispatch-UNCERTAIN, never as "never began", so any
526
+ // caller deriving stop evidence from this result (`resolveStopEvidence`'s
527
+ // `dispatchState`) resolves to `unresolved`, never a confirmed stop.
528
+ ...(onDispatchCommit ? { dispatchState: 'submission-uncertain' } : {}),
529
+ diagnostic: `systemd-run launch failed: ${child.error?.message ?? stderr ?? `exit ${child.status}`}`,
530
+ gate: resolvedGate,
531
+ };
532
+ }
533
+ let pid;
534
+ try {
535
+ const shown = spawn(resolveSystemdClient('systemctl'), ['--user', 'show', '-p', 'MainPID', '--value', unit], { encoding: 'utf8', timeout: 10000, env: buildSystemdClientEnv({ envNames: [] }) });
536
+ const parsed = Number(String(shown.stdout ?? '').trim());
537
+ if (Number.isInteger(parsed) && parsed > 0) pid = parsed;
538
+ } catch {
539
+ pid = undefined;
540
+ }
541
+ return { launched: true, mode: 'systemd-unit', unit, pid, gate: resolvedGate };
542
+ });
543
+ }
544
+
545
+ /**
546
+ * Read retained service state after launch/exit without reaping or mutation.
547
+ * `memoryMax` is bytes. Query failures throw rather than reporting success.
548
+ */
549
+ export function getWorkerIsolationStatus(unit, { spawn = spawnSync, timeoutMs = 10000 } = {}) {
550
+ if (typeof unit !== 'string' || !/^[A-Za-z0-9:_.@-]+$/.test(unit)) {
551
+ throw new Error('Worker isolation requires a unit-safe unit name');
552
+ }
553
+ const shown = spawn(resolveSystemdClient('systemctl'), ['--user', 'show', unit, '-p', 'ActiveState',
554
+ '-p', 'Result', '-p', 'ExecMainStatus', '-p', 'ExecMainCode', '-p', 'MainPID', '-p', 'MemoryMax'], { encoding: 'utf8', timeout: timeoutMs, env: buildSystemdClientEnv({ envNames: [] }) });
555
+ if (shown.error || shown.status !== 0) {
556
+ throw new Error(`worker status unavailable: ${shown.error?.message ?? String(shown.stderr ?? '').trim()}`);
557
+ }
558
+ const fields = Object.fromEntries(String(shown.stdout ?? '').trim().split('\n').map(line => {
559
+ const index = line.indexOf('=');
560
+ return [line.slice(0, index), line.slice(index + 1)];
561
+ }));
562
+ if (!fields.ActiveState || !fields.Result || !fields.ExecMainStatus || !fields.MemoryMax) {
563
+ throw new Error('worker status unavailable: missing systemd properties');
564
+ }
565
+ return {
566
+ active: ['active', 'activating', 'deactivating', 'reloading'].includes(fields.ActiveState),
567
+ result: fields.Result,
568
+ execMainStatus: Number(fields.ExecMainStatus),
569
+ memoryMax: fields.MemoryMax === 'infinity' ? Infinity : Number(fields.MemoryMax),
570
+ ...(fields.MainPID ? { mainPid: Number(fields.MainPID) } : {}),
571
+ ...(fields.ExecMainCode ? { execMainCode: Number(fields.ExecMainCode) } : {}),
572
+ };
573
+ }
574
+
575
+ /**
576
+ * Request the unit's cgroup be torn down (`KillMode=control-group` is already
577
+ * set at launch, so this kills the transport, the worker and any descendants
578
+ * together). This is the actual stop authority for `systemd-unit` mode: a
579
+ * PID/PGID signal may still be unresolved (identity races the unit's start),
580
+ * while `systemctl --user stop` targets the unit by the name the launcher
581
+ * already holds. It does not itself confirm anything — issuing the stop
582
+ * request and having it take effect are different claims. Callers must
583
+ * recheck `getWorkerIsolationStatus(unit)` afterward and treat an
584
+ * unconfirmed stop as uncertain, never as a confirmed termination. Idempotent
585
+ * on an already-stopped/unknown unit — `systemctl --user stop` on a unit
586
+ * that no longer exists is not an error worth surfacing to the caller.
587
+ */
588
+ export function stopWorkerUnit(unit, { spawn = spawnSync, timeoutMs = 10000 } = {}) {
589
+ if (typeof unit !== 'string' || !/^[A-Za-z0-9:_.@-]+$/.test(unit)) {
590
+ throw new Error('Worker isolation requires a unit-safe unit name');
591
+ }
592
+ const result = spawn(resolveSystemdClient('systemctl'), ['--user', 'stop', unit], { encoding: 'utf8', timeout: timeoutMs, env: buildSystemdClientEnv({ envNames: [] }) });
593
+ return { ok: !result.error && result.status === 0,
594
+ diagnostic: result.error ? result.error.message : String(result.stderr ?? '').trim() || null };
595
+ }
596
+
597
+ /**
598
+ * Read the kernel-reported `ControlGroup` systemd placed this unit's
599
+ * processes under. This is the containment authority for `systemd-unit`
600
+ * mode worker identity (#2895 round 2, MAJOR a): a candidate pid is only
601
+ * trustworthy if its own `/proc/<pid>/cgroup` matches this value, never
602
+ * because a worker-writable record merely names a pid that happens to
603
+ * resample successfully. Returns null (never a forged match) when the
604
+ * property is unavailable.
605
+ */
606
+ export function readUnitControlGroup(unit, { spawn = spawnSync } = {}) {
607
+ if (typeof unit !== 'string' || !/^[A-Za-z0-9:_.@-]+$/.test(unit)) return null;
608
+ try {
609
+ const shown = spawn(resolveSystemdClient('systemctl'), ['--user', 'show', unit, '-p', 'ControlGroup', '--value'], { encoding: 'utf8', timeout: 10000, env: buildSystemdClientEnv({ envNames: [] }) });
610
+ if (shown.error || shown.status !== 0) return null;
611
+ const value = String(shown.stdout ?? '').trim();
612
+ return value || null;
613
+ } catch { return null; }
614
+ }
615
+
616
+ export { checkWorkerDispatch };
617
+
618
+ /** Supervise the private transport's worker, with injectable kernel sampling. */
619
+ export function runWorkerTransport({ command, args, environment, record,
620
+ spawn = nodeSpawn, sampleIdentity = sampleWorkerIdentity,
621
+ now = () => Number(process.hrtime.bigint()) / 1e6,
622
+ setExitCode = code => { process.exitCode = code; },
623
+ propagateSignal = signal => process.kill(process.pid, signal) }) {
624
+ record('worker-start.json', { supervisor: sampleIdentity(process.pid), worker: null });
625
+ const worker = spawn(command, args, { env: environment, detached: true, stdio: 'inherit' });
626
+ // We spawned this detached child: spawn waits through setsid/exec, so one owned leader sample suffices.
627
+ const immediate = sampleIdentity(worker.pid);
628
+ let identity = immediate?.pid === worker.pid && immediate?.pid === immediate?.pgid
629
+ && immediate?.uid === process.getuid?.() && immediate?.parentPid === process.pid ? immediate : null;
630
+ // Transport lifetime, rather than startup latency, bounds this capture.
631
+ const capture = createWorkerIdentityCapture({ now, timeoutMs: Infinity });
632
+ let identityTimer;
633
+ let identityRecorded = false;
634
+ const recordIdentity = () => {
635
+ if (identityRecorded) return;
636
+ identityRecorded = true;
637
+ clearTimeout(identityTimer);
638
+ record('worker-identity.json', { supervisor: sampleIdentity(process.pid), worker: identity,
639
+ identityObservation: identity ? 'stable-owned-group' : 'no-stable-owned-group' });
640
+ };
641
+ const observeIdentity = (current = sampleIdentity(worker.pid)) => {
642
+ identity ??= capture.observe(current);
643
+ if (identity) recordIdentity();
644
+ else identityTimer = setTimeout(observeIdentity, 5);
645
+ };
646
+ worker.once('error', error => {
647
+ try {
648
+ recordIdentity();
649
+ record('worker-exit.json', { worker: identity, exitCode: null, signal: null, errorCode: error.code ?? 'SPAWN_ERROR', endedAt: new Date().toISOString() });
650
+ } finally { setExitCode(127); }
651
+ });
652
+ worker.once('exit', (code, signal) => {
653
+ // Bookkeeping must never replace the true wait status.
654
+ try {
655
+ recordIdentity();
656
+ record('worker-exit.json', { worker: identity, exitCode: code, signal, errorCode: null, endedAt: new Date().toISOString() });
657
+ } finally { if (signal) propagateSignal(signal); else setExitCode(code); }
658
+ });
659
+ observeIdentity(immediate);
660
+ return worker;
661
+ }
662
+
663
+ // Private attached transport entry, never a public CLI or legacy Ralph route.
664
+ // The systemd unit owns this supervisor and its worker; cgroup exit evidence
665
+ // includes descendants. Only the actual worker receives the allowed values.
666
+ if (process.argv[1] === fileURLToPath(import.meta.url) && process.argv[2] === '--worker-environment') {
667
+ process.umask(0o077);
668
+ const names = JSON.parse(process.argv[3]);
669
+ if (!Array.isArray(names) || names.some(name => typeof name !== 'string' || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(name))) throw new Error('Invalid private worker environment');
670
+ let commandOffset = 5;
671
+ let runDir;
672
+ let invocationId;
673
+ if (process.argv[4] === '--run-dir' && process.argv[6] === '--invocation-id') {
674
+ runDir = process.argv[5]; invocationId = process.argv[7]; commandOffset = 9;
675
+ assertAbsolute('run directory', runDir);
676
+ const stat = lstatSync(runDir);
677
+ if (!/^[a-zA-Z0-9-]+$/.test(invocationId) || !stat.isDirectory() || stat.uid !== process.getuid()
678
+ || (stat.mode & 0o077) || realpathSync(runDir) !== runDir || runDir.split('/').includes('..')) throw new Error('Invalid private worker run');
679
+ }
680
+ if (process.argv[commandOffset - 1] !== '--') throw new Error('Invalid private worker command');
681
+ const [command, ...args] = process.argv.slice(commandOffset);
682
+ assertAbsolute('worker binary', command);
683
+ // A publish collision (a pre-existing target, planted or otherwise) is
684
+ // recorded as a diagnostic anomaly file; it is never allowed to propagate
685
+ // as a thrown error. The worker's real wait() status (propagated via
686
+ // `process.kill`/`process.exitCode` by the caller below) must never be
687
+ // replaced by an uncaught exception from this bookkeeping write.
688
+ const recordAnomaly = (filename, temporary, error) => {
689
+ try { unlinkSync(temporary); } catch { /* best effort cleanup only */ }
690
+ try {
691
+ const anomalyTarget = path.join(runDir, `${filename}.transport-anomaly.json`);
692
+ const anomalyTemp = path.join(runDir, `.${filename}.transport-anomaly-${process.pid}`);
693
+ const fd = openSync(anomalyTemp, constants.O_WRONLY | constants.O_CREAT | constants.O_TRUNC | constants.O_NOFOLLOW, 0o600);
694
+ try { writeFileSync(fd, JSON.stringify({ invocationId, filename, error: error?.message ?? String(error), at: new Date().toISOString() })); fsyncSync(fd); }
695
+ finally { closeSync(fd); }
696
+ renameSync(anomalyTemp, anomalyTarget);
697
+ } catch { /* anomaly recording is diagnostics-only and must never throw */ }
698
+ };
699
+ const record = (filename, value) => {
700
+ if (!runDir) return;
701
+ const target = path.join(runDir, filename);
702
+ const temporary = path.join(runDir, `.${filename}-${process.pid}`);
703
+ const fd = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
704
+ try { writeFileSync(fd, JSON.stringify({ invocationId, ...value })); fsyncSync(fd); } finally { closeSync(fd); }
705
+ // Only this trusted transport writes these fresh records. A pre-existing
706
+ // target is a recorded anomaly, not a thrown error — callers must always
707
+ // reach their own post-record status propagation regardless.
708
+ try { lstatSync(target); recordAnomaly(filename, temporary, new Error('Worker transport record already exists')); return; }
709
+ catch (error) {
710
+ if (error.code !== 'ENOENT') { recordAnomaly(filename, temporary, error); return; }
711
+ }
712
+ try { renameSync(temporary, target); }
713
+ catch (error) { recordAnomaly(filename, temporary, error); }
714
+ };
715
+ const environment = Object.fromEntries(names.filter(name => process.env[name] !== undefined).map(name => [name, process.env[name]]));
716
+ runWorkerTransport({ command, args, environment, record });
717
+ }