@aiwg/cli 2026.10.1 → 2026.10.4
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/README.md +2 -2
- package/agentic/code/providers/capability-matrix.yaml +270 -0
- package/agentic/code/providers/model-capabilities.v1.json +3183 -24
- package/bin/aiwg.mjs +135 -47
- package/dist/src/a2a/codecs.js +9 -0
- package/dist/src/agents/grounding/knowledge-base.js +54 -6
- package/dist/src/artifacts/browser-export.js +6 -0
- package/dist/src/artifacts/checksum-manifest.js +1 -0
- package/dist/src/artifacts/citation-parser.js +173 -92
- package/dist/src/artifacts/citation-table-schema.js +59 -0
- package/dist/src/artifacts/cli.js +57 -18
- package/dist/src/artifacts/corpus-tools/snapshot.js +1 -1
- package/dist/src/artifacts/corpus-tools/vision-extract.js +107 -15
- package/dist/src/artifacts/corpus-views/ref-parser.js +3 -1
- package/dist/src/artifacts/corpus-views/renderers.js +3 -3
- package/dist/src/artifacts/discovery-eval.js +6 -4
- package/dist/src/artifacts/fortemi-core-query-adapter.js +36 -3
- package/dist/src/artifacts/fortemi-core-sync.js +166 -40
- package/dist/src/artifacts/framework-capabilities.js +171 -0
- package/dist/src/artifacts/framework-index-paths.js +73 -0
- package/dist/src/artifacts/framework-source-digest.js +75 -0
- package/dist/src/artifacts/index-builder.js +285 -131
- package/dist/src/artifacts/index-files.js +132 -18
- package/dist/src/artifacts/index-preparation.js +65 -0
- package/dist/src/artifacts/index-refresh.js +576 -0
- package/dist/src/artifacts/index-worker.mjs +9 -0
- package/dist/src/artifacts/prebuilt-build-lock.js +47 -4
- package/dist/src/artifacts/prebuilt-deployment.js +46 -0
- package/dist/src/artifacts/prebuilt-relations.js +80 -0
- package/dist/src/artifacts/query-engine.js +410 -88
- package/dist/src/artifacts/relations-builder.js +168 -0
- package/dist/src/artifacts/relations.js +148 -0
- package/dist/src/artifacts/repair.js +9 -4
- package/dist/src/artifacts/stats.js +5 -2
- package/dist/src/artifacts/types.js +30 -10
- package/dist/src/artifacts/watcher.js +1 -1
- package/dist/src/auth/credential-store.js +2 -1
- package/dist/src/catalog/cli.d.mts +5 -0
- package/dist/src/channel/manager.d.mts +20 -0
- package/dist/src/channel/manager.mjs +78 -12
- package/dist/src/cli/child-process-result.js +40 -0
- package/dist/src/cli/command-log.js +2 -3
- package/dist/src/cli/env.js +38 -16
- package/dist/src/cli/flag-parsing.js +236 -0
- package/dist/src/cli/handlers/artifacts.js +3 -0
- package/dist/src/cli/handlers/auth.js +3 -0
- package/dist/src/cli/handlers/decision.js +218 -5
- package/dist/src/cli/handlers/diagnose.js +13 -5
- package/dist/src/cli/handlers/effect.js +4 -2
- package/dist/src/cli/handlers/evidence.js +3 -0
- package/dist/src/cli/handlers/feedback.js +14 -5
- package/dist/src/cli/handlers/help.js +1 -0
- package/dist/src/cli/handlers/index.js +3 -1
- package/dist/src/cli/handlers/init.js +248 -30
- package/dist/src/cli/handlers/lint.js +5 -0
- package/dist/src/cli/handlers/mc.js +23 -0
- package/dist/src/cli/handlers/models.js +169 -10
- package/dist/src/cli/handlers/ralph-launcher.js +2 -0
- package/dist/src/cli/handlers/ralph.js +3 -0
- package/dist/src/cli/handlers/refresh.js +28 -0
- package/dist/src/cli/handlers/regenerate.js +14 -0
- package/dist/src/cli/handlers/resource-versions.js +3 -0
- package/dist/src/cli/handlers/rules.js +195 -0
- package/dist/src/cli/handlers/run.js +4 -0
- package/dist/src/cli/handlers/runtime-info.js +25 -11
- package/dist/src/cli/handlers/script-runner.js +19 -8
- package/dist/src/cli/handlers/session.js +406 -62
- package/dist/src/cli/handlers/sessions.js +243 -76
- package/dist/src/cli/handlers/setup-manifest.js +474 -79
- package/dist/src/cli/handlers/setup.js +180 -39
- package/dist/src/cli/handlers/steward.js +11 -0
- package/dist/src/cli/handlers/subcommands.js +60 -3
- package/dist/src/cli/handlers/team.js +18 -8
- package/dist/src/cli/handlers/uhp.js +3 -0
- package/dist/src/cli/handlers/use.js +307 -55
- package/dist/src/cli/handlers/utilities.js +125 -26
- package/dist/src/cli/handlers/version.js +8 -0
- package/dist/src/cli/prompt-utils.js +72 -0
- package/dist/src/cli/provider-resolution.js +11 -0
- package/dist/src/cli/router.js +15 -1
- package/dist/src/cli/services/deployment-verification.js +19 -9
- package/dist/src/cli/services/provider-prepare-coordinator.js +24 -0
- package/dist/src/cli/skill-usage.js +2 -3
- package/dist/src/config/aiwg-config.js +349 -35
- package/dist/src/config/cli.js +256 -23
- package/dist/src/config/decision-config.js +106 -0
- package/dist/src/config/dispatch-thresholds-runtime.d.mts +8 -0
- package/dist/src/config/dispatch-thresholds-runtime.mjs +38 -0
- package/dist/src/config/gitignore.js +50 -0
- package/dist/src/config/index-graph-overrides.js +36 -0
- package/dist/src/config/project-artifacts-health.d.mts +37 -0
- package/dist/src/config/project-artifacts-health.mjs +14 -3
- package/dist/src/config/project-artifacts-runtime.d.mts +16 -0
- package/dist/src/config/user-config-dir.d.mts +9 -0
- package/dist/src/config/user-config.js +38 -11
- package/dist/src/config/user-registry.js +23 -15
- package/dist/src/config/user-state.js +7 -0
- package/dist/src/decision/adapters/jev.js +75 -27
- package/dist/src/decision/ask.js +203 -43
- package/dist/src/decision/context-live-qualification.js +2 -2
- package/dist/src/decision/driver.js +14 -7
- package/dist/src/decision/ensemble/aggregate.js +23 -10
- package/dist/src/decision/ensemble-study/multifact-study.js +6 -2
- package/dist/src/decision/entry.js +25 -6
- package/dist/src/decision/graph-live-qualification.js +1 -1
- package/dist/src/decision/graph-run.js +9 -0
- package/dist/src/decision/heldout/collector.js +15 -14
- package/dist/src/decision/heldout/contract.js +30 -7
- package/dist/src/decision/index.js +1 -0
- package/dist/src/decision/interop.js +60 -8
- package/dist/src/decision/jev-credentials.js +15 -1
- package/dist/src/decision/ledger.js +942 -0
- package/dist/src/decision/offload-activation.js +137 -0
- package/dist/src/decision/offload.js +550 -0
- package/dist/src/decision/qualification/load.js +4 -6
- package/dist/src/decision/savings.js +28 -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/effects/types.js +5 -1
- package/dist/src/effects/verifiers/git.js +2 -1
- package/dist/src/effects/verifiers/tracker.js +26 -14
- package/dist/src/extensions/commands/definitions.js +19 -0
- package/dist/src/extensions/project-local-remove.js +36 -1
- package/dist/src/features/installer.js +2 -2
- package/dist/src/flow/graph-metadata.js +5 -0
- package/dist/src/gates/providers/loader.js +64 -18
- package/dist/src/installation/manager-command.d.mts +18 -0
- package/dist/src/installation/manager-command.mjs +15 -3
- package/dist/src/installation/manager.d.mts +49 -0
- package/dist/src/installation/manager.mjs +95 -5
- package/dist/src/issues/cli.js +4 -4
- package/dist/src/issues/local.js +24 -5
- package/dist/src/jobs/runner.js +1 -1
- package/dist/src/lint/cli.js +27 -1
- package/dist/src/lint/loader.js +2 -1
- package/dist/src/lint/reporters.js +25 -1
- package/dist/src/lint/rule-activation.js +43 -0
- package/dist/src/lint/runner.js +137 -62
- package/dist/src/mcp/adapters/codex-runtime.js +366 -56
- package/dist/src/mcp/claude-install.d.mts +4 -0
- package/dist/src/mcp/claude-install.mjs +41 -0
- package/dist/src/mcp/cli.d.mts +1 -0
- package/dist/src/mcp/cli.mjs +620 -85
- package/dist/src/mcp/config-file.d.mts +28 -0
- package/dist/src/mcp/config-file.mjs +279 -0
- package/dist/src/mcp/config-layers.d.mts +36 -0
- package/dist/src/mcp/config-layers.mjs +348 -0
- package/dist/src/mcp/credentials.d.mts +51 -0
- package/dist/src/mcp/credentials.mjs +484 -0
- package/dist/src/mcp/grok-build-config.d.mts +6 -0
- package/dist/src/mcp/grok-build-config.mjs +17 -27
- package/dist/src/mcp/helpers.mjs +109 -4
- package/dist/src/mcp/muse-native-config.d.mts +13 -0
- package/dist/src/mcp/muse-native-config.mjs +1 -0
- package/dist/src/mcp/omp-config.d.mts +4 -0
- 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 +248 -131
- package/dist/src/mcp/registry.mjs +169 -100
- package/dist/src/mcp/server-config.d.mts +3 -0
- package/dist/src/mcp/server-config.mjs +89 -0
- package/dist/src/mcp/server.mjs +127 -82
- package/dist/src/mcp/test-simple.mjs +2 -1
- package/dist/src/mcp/toml-editor.d.mts +4 -0
- package/dist/src/mcp/toml-string.d.mts +1 -0
- package/dist/src/mcp/toml-string.mjs +9 -0
- package/dist/src/mcp/toml-strip-mcp.d.mts +1 -0
- package/dist/src/mcp/toml-strip-mcp.mjs +135 -0
- package/dist/src/mcp/tool-filters.d.mts +46 -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 +22 -3
- package/dist/src/mcp/tools/discovery.mjs +62 -55
- package/dist/src/mcp/tools/orchestration.mjs +50 -2
- package/dist/src/mcp/tools/subsystems.mjs +39 -8
- package/dist/src/mcp/tools/worker-invoke.mjs +2856 -0
- package/dist/src/mcp/tools/worker-shell-control.mjs +289 -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 +647 -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 +76 -14
- package/dist/src/models/grok-build-models.js +93 -41
- package/dist/src/models/model-capabilities.v1.json +3183 -24
- package/dist/src/models/model-discovery.js +931 -67
- package/dist/src/models/provider-policy.js +719 -11
- package/dist/src/models/qualification-digest.d.mts +2 -0
- package/dist/src/models/qualification-digest.mjs +17 -0
- package/dist/src/models/resolver.js +261 -0
- package/dist/src/models/worker-direct-readiness.js +550 -0
- package/dist/src/models/worker-dispatch-offer.js +169 -0
- package/dist/src/models/worker-health-readiness.js +144 -0
- package/dist/src/models/worker-installation.d.mts +39 -0
- package/dist/src/models/worker-installation.mjs +986 -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.d.mts +84 -0
- package/dist/src/process/bounded-child-process.mjs +430 -0
- package/dist/src/process/cli-command.d.mts +16 -0
- package/dist/src/process/cli-command.mjs +75 -0
- package/dist/src/process/signal-child.d.mts +6 -0
- package/dist/src/process/signal-child.mjs +7 -0
- package/dist/src/providers/capability-matrix.js +8 -0
- package/dist/src/providers/capability-matrix.yaml +270 -0
- package/dist/src/providers/deployed-skill-roots.js +20 -0
- package/dist/src/providers/grok-build-acp.js +3 -2
- package/dist/src/providers/grok-build-headless.js +53 -23
- package/dist/src/providers/grok-build-inspect.js +1 -0
- package/dist/src/providers/omp-agent.d.mts +8 -0
- package/dist/src/providers/omp-diagnostics.d.mts +1 -0
- package/dist/src/providers/omp-paths.d.mts +13 -0
- package/dist/src/providers/provider-definitions.js +9 -1
- package/dist/src/providers/provider-definitions.mjs +9 -2
- package/dist/src/providers/skill-ownership.d.mts +9 -0
- package/dist/src/providers/skill-ownership.mjs +88 -0
- package/dist/src/rules/activation.d.mts +21 -0
- package/dist/src/rules/activation.mjs +62 -0
- package/dist/src/rules/inline-budget.d.mts +26 -0
- package/dist/src/rules/inline-budget.mjs +66 -0
- package/dist/src/rules/inventory.js +129 -0
- package/dist/src/rules/pin-state.js +73 -0
- package/dist/src/rules/pins.d.mts +14 -0
- package/dist/src/rules/pins.mjs +112 -0
- package/dist/src/rules/rule-frontmatter.schema.json +197 -0
- 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 +47 -12
- package/dist/src/serve/shared-host-scheduler.js +2 -0
- package/dist/src/serve/worker-invocation.js +558 -0
- package/dist/src/serve/worker-stdin-frame.d.mts +10 -0
- package/dist/src/serve/worker-stdin-frame.mjs +107 -0
- package/dist/src/sessions/adapter-registry.js +49 -0
- package/dist/src/sessions/adapters/claude.js +39 -54
- package/dist/src/sessions/adapters/codex.js +82 -67
- package/dist/src/sessions/adapters/copilot.js +4 -4
- package/dist/src/sessions/adapters/cursor.js +40 -57
- package/dist/src/sessions/adapters/deepseek-harness.js +15 -12
- package/dist/src/sessions/adapters/factory.js +31 -27
- package/dist/src/sessions/adapters/generic.js +43 -9
- package/dist/src/sessions/adapters/grok-build.js +11 -11
- package/dist/src/sessions/adapters/grokbot-locator.js +111 -0
- package/dist/src/sessions/adapters/grokbot.js +434 -24
- package/dist/src/sessions/adapters/hermes.js +27 -29
- package/dist/src/sessions/adapters/muse-workspace-scan.js +128 -0
- package/dist/src/sessions/adapters/muse.js +200 -104
- package/dist/src/sessions/adapters/omp.js +28 -14
- package/dist/src/sessions/adapters/openclaw.js +89 -79
- package/dist/src/sessions/adapters/opencode.js +27 -24
- package/dist/src/sessions/adapters/openhuman.js +18 -60
- package/dist/src/sessions/adapters/pi.js +30 -14
- package/dist/src/sessions/adapters/warp.js +3 -3
- package/dist/src/sessions/adapters/windsurf.js +19 -48
- package/dist/src/sessions/batch-contracts.js +25 -5
- package/dist/src/sessions/batch-import.js +72 -51
- package/dist/src/sessions/candidates.js +47 -11
- package/dist/src/sessions/contracts.js +75 -2
- package/dist/src/sessions/coverage-preflight.js +134 -0
- package/dist/src/sessions/discovery.js +3 -0
- package/dist/src/sessions/export-plan.js +4 -3
- package/dist/src/sessions/grok-build-cli.js +67 -5
- package/dist/src/sessions/import-exclusions.js +72 -0
- package/dist/src/sessions/import-lease.js +34 -18
- package/dist/src/sessions/import-limits.js +37 -0
- package/dist/src/sessions/importer.js +84 -46
- package/dist/src/sessions/index.js +2 -0
- package/dist/src/sessions/lifecycle.js +45 -0
- package/dist/src/sessions/policy.js +133 -25
- package/dist/src/sessions/readers.js +106 -53
- package/dist/src/sessions/refresh.js +106 -0
- package/dist/src/sessions/repository.js +199 -63
- package/dist/src/sessions/workspace-discovery.js +315 -88
- package/dist/src/sessions/workspace-matching.js +107 -0
- package/dist/src/skills/adapters/clawhub.js +10 -8
- package/dist/src/skills/adapters/openclaw.js +10 -7
- package/dist/src/skills/importer.js +1 -0
- package/dist/src/smiths/context-pipeline/aiwg-md.js +11 -0
- package/dist/src/smiths/context-pipeline/claude-hook.js +3 -26
- package/dist/src/smiths/context-pipeline/finalization.js +6 -5
- package/dist/src/smiths/context-pipeline/generator.js +21 -4
- package/dist/src/smiths/context-pipeline/parallelism-section.js +195 -14
- 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/mcpsmith/generator.js +1 -1
- package/dist/src/smiths/toolsmith/runtime-discovery.d.mts +10 -0
- package/dist/src/smiths/toolsmith/runtime-discovery.mjs +85 -31
- package/dist/src/storage/backends/fs.js +18 -76
- package/dist/src/storage/backends/logseq.js +68 -7
- package/dist/src/storage/backends/obsidian.js +1 -0
- package/dist/src/storage/fortemi-qualification-receipt.js +2 -2
- package/dist/src/testing/generators/test-code-generator.js +57 -30
- package/dist/src/tracker/forge-http.js +2 -1
- package/dist/src/uhp/client.js +11 -1
- package/dist/src/uhp/mission.js +5 -0
- package/dist/src/update/checker.d.mts +2 -0
- package/dist/src/update/checker.mjs +5 -5
- package/dist/src/update/notifier.mjs +54 -2
- package/dist/src/update/service.d.mts +22 -0
- package/dist/src/utils/contained-filesystem.d.mts +37 -0
- package/dist/src/utils/contained-filesystem.mjs +683 -0
- package/dist/src/utils/temporary-directory.js +36 -0
- package/dist/src/writing/example-generator.js +1 -1
- package/dist/src/writing/fidelity.js +18 -1
- package/package.json +32 -2
- 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 +1072 -0
- package/tools/_resolve-impl.mjs +39 -2
- package/tools/agents/deploy-agents.mjs +64 -23
- package/tools/agents/providers/base.mjs +153 -166
- package/tools/agents/providers/claude.mjs +8 -24
- package/tools/agents/providers/codex.mjs +18 -11
- package/tools/agents/providers/cursor.mjs +19 -18
- package/tools/agents/providers/grok-build.mjs +3 -1
- package/tools/agents/providers/grokbot.mjs +11 -12
- package/tools/agents/providers/hermes.mjs +62 -49
- package/tools/agents/providers/omp.mjs +5 -2
- package/tools/agents/providers/openclaw-translator.mjs +19 -5
- package/tools/agents/providers/openclaw.mjs +9 -4
- package/tools/agents/providers/pi.mjs +5 -0
- package/tools/agents/providers/provider-prepare-channel.mjs +5 -0
- package/tools/agents/providers/provider-prepare-coordinator.mjs +247 -0
- package/tools/agents/providers/provider-prepare-worker.mjs +84 -0
- package/tools/agents/providers/rule-delivery.mjs +1270 -0
- package/tools/agents/providers/warp.mjs +20 -26
- package/tools/cli/decision-offload-report.mjs +40 -0
- package/tools/cli/workspace-status.mjs +862 -0
- package/tools/plugin/package-plugins.mjs +186 -8
- package/tools/plugin/plugin-links.mjs +114 -0
- package/tools/providers/antigravity-transport.mjs +3 -1
- package/tools/providers/deepseek-harness-transport.mjs +360 -0
- package/tools/providers/omp-transport.mjs +194 -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/consecutive-failure-circuit-breaker.mjs +1748 -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/launcher-recovery-evidence.mjs +55 -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 +329 -0
- package/tools/ralph-external/lib/provider-adapter.mjs +748 -0
- package/tools/ralph-external/lib/recovery-probe.mjs +72 -0
- package/tools/ralph-external/lib/session-environment.mjs +28 -0
- package/tools/ralph-external/lib/systemd-client.mjs +49 -0
- package/tools/ralph-external/lib/worker-health.mjs +439 -0
- package/tools/ralph-external/lib/worker-isolation.mjs +717 -0
- package/tools/ralph-external/lib/worker-overflow.mjs +50 -0
- package/tools/ralph-external/process-monitor.mjs +1017 -0
- package/tools/ralph-external/session-launcher.mjs +5057 -0
- package/tools/scripts/resource-preflight.mjs +541 -0
- package/tools/skills/deploy-skills-codex.mjs +2 -1
- package/vscode-extension/schemas/aiwg.config.v1.json +2916 -0
|
@@ -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
|
+
}
|