@aiwg/cli 2026.10.10 → 2026.10.12

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 (156) hide show
  1. package/agentic/code/providers/model-capabilities.v1.json +78 -14
  2. package/agentic/code/providers/model-catalog.v1.json +62 -12
  3. package/dist/src/artifacts/binding-target-identity.js +51 -0
  4. package/dist/src/artifacts/checksum-manifest.js +12 -0
  5. package/dist/src/artifacts/index-builder.js +11 -13
  6. package/dist/src/artifacts/query-engine.js +5 -11
  7. package/dist/src/cli/handlers/doctor-help.js +32 -0
  8. package/dist/src/cli/handlers/gates.js +19 -18
  9. package/dist/src/cli/handlers/models-help.js +7 -1
  10. package/dist/src/cli/handlers/models.js +3 -3
  11. package/dist/src/cli/handlers/script-runner.js +8 -5
  12. package/dist/src/cli/handlers/sessions-intake.js +104 -0
  13. package/dist/src/cli/handlers/sessions-tail.js +172 -0
  14. package/dist/src/cli/handlers/sessions.js +287 -31
  15. package/dist/src/cli/handlers/skill-lint.js +10 -3
  16. package/dist/src/cli/handlers/steward-format.js +100 -0
  17. package/dist/src/cli/handlers/steward-models.js +167 -0
  18. package/dist/src/cli/handlers/steward.js +14 -248
  19. package/dist/src/cli/handlers/use-bundle-counts.js +61 -0
  20. package/dist/src/cli/handlers/use.js +18 -28
  21. package/dist/src/cli/handlers/utilities.js +1 -24
  22. package/dist/src/config/aiwg-config.js +7 -0
  23. package/dist/src/config/authorization-cli.js +89 -0
  24. package/dist/src/config/cli-help.js +1 -0
  25. package/dist/src/config/cli.js +10 -1
  26. package/dist/src/config/worker-context.js +337 -0
  27. package/dist/src/decision/context-pruning.js +3 -2
  28. package/dist/src/decision/ensemble/contract.js +15 -4
  29. package/dist/src/decision/ensemble-study/artifacts.js +5 -0
  30. package/dist/src/decision/ensemble-study/score.js +4 -3
  31. package/dist/src/decision/feature-export.js +3 -9
  32. package/dist/src/decision/heldout/collector.js +2 -2
  33. package/dist/src/decision/issue-triage/runtime.js +3 -9
  34. package/dist/src/decision/qualification/gates.js +2 -1
  35. package/dist/src/decision/qualification/release.js +2 -3
  36. package/dist/src/decision/routing/report.js +2 -0
  37. package/dist/src/decision/sdlc-screening.js +3 -5
  38. package/dist/src/decision/sensitivity/comparative.js +5 -3
  39. package/dist/src/gates/driver.js +6 -77
  40. package/dist/src/gates/evaluate.js +4 -11
  41. package/dist/src/gates/floors.js +4 -4
  42. package/dist/src/gates/index.js +3 -3
  43. package/dist/src/gates/lattice.js +26 -0
  44. package/dist/src/gates/registry.js +2 -1
  45. package/dist/src/gates/report.js +19 -7
  46. package/dist/src/issues/audit-cli.js +120 -0
  47. package/dist/src/issues/audit-contract.js +64 -0
  48. package/dist/src/issues/audit-local.js +82 -0
  49. package/dist/src/issues/audit.js +220 -0
  50. package/dist/src/lint/rule-activation.js +15 -1
  51. package/dist/src/mcp/tools/worker-invoke.mjs +108 -10
  52. package/dist/src/mission-protocol/canonical-codecs.js +270 -0
  53. package/dist/src/mission-protocol/codecs.js +22 -550
  54. package/dist/src/mission-protocol/health-trace-codecs.js +281 -0
  55. package/dist/src/mission-protocol/health-trace-types.js +43 -0
  56. package/dist/src/mission-protocol/types.js +2 -42
  57. package/dist/src/mission-protocol/worker-contract-json.js +10 -0
  58. package/dist/src/mission-protocol/worker-review-gate.js +100 -0
  59. package/dist/src/mission-protocol/worker-review-types.js +2 -0
  60. package/dist/src/models/catalog-policy.js +280 -0
  61. package/dist/src/models/index.js +1 -1
  62. package/dist/src/models/model-capabilities.v1.json +78 -14
  63. package/dist/src/models/model-catalog.v1.json +62 -12
  64. package/dist/src/models/policy-resources.js +28 -0
  65. package/dist/src/models/provider-policy.js +10 -539
  66. package/dist/src/models/resolver.js +26 -8
  67. package/dist/src/models/router.js +15 -2
  68. package/dist/src/models/tiers.js +13 -0
  69. package/dist/src/models/types.js +0 -6
  70. package/dist/src/models/worker-ceilings.js +59 -0
  71. package/dist/src/models/worker-invocation-policy.js +243 -0
  72. package/dist/src/models/worker-plan.js +16 -20
  73. package/dist/src/models/worker-review-lineage.js +55 -0
  74. package/dist/src/models/worker-selection-policy.js +1 -1
  75. package/dist/src/models/wrapper-route.js +6 -3
  76. package/dist/src/policy/authorization.js +30 -1
  77. package/dist/src/policy/hitl-config.js +63 -0
  78. package/dist/src/policy/hitl-receipt.js +57 -0
  79. package/dist/src/policy/hitl-types.js +4 -0
  80. package/dist/src/policy/hitl-validation.js +39 -0
  81. package/dist/src/policy/hitl.js +117 -0
  82. package/dist/src/process/aiwg-probe-command.d.mts +15 -0
  83. package/dist/src/process/aiwg-probe-command.mjs +37 -0
  84. package/dist/src/rules/activation.d.mts +1 -1
  85. package/dist/src/rules/activation.mjs +0 -2
  86. package/dist/src/rules/gate-coverage.js +51 -0
  87. package/dist/src/rules/gate-references.js +16 -0
  88. package/dist/src/rules/rule-frontmatter.schema.json +7 -1
  89. package/dist/src/serve/worker-command-request.js +21 -4
  90. package/dist/src/serve/worker-invocation-preparation.js +3 -1
  91. package/dist/src/serve/worker-invocation.js +21 -10
  92. package/dist/src/sessions/adapters/deepseek-harness.js +1 -0
  93. package/dist/src/sessions/adapters/grok-build-native.js +269 -0
  94. package/dist/src/sessions/adapters/grok-build.js +57 -15
  95. package/dist/src/sessions/adapters/grokbot.js +7 -0
  96. package/dist/src/sessions/adapters/hermes-state-db.js +252 -0
  97. package/dist/src/sessions/adapters/hermes.js +42 -3
  98. package/dist/src/sessions/adapters/warp.js +1 -1
  99. package/dist/src/sessions/batch-import.js +44 -6
  100. package/dist/src/sessions/contracts.js +20 -1
  101. package/dist/src/sessions/coverage-preflight.js +7 -1
  102. package/dist/src/sessions/discovery-capability.js +38 -0
  103. package/dist/src/sessions/fortemi-export-mapping.js +33 -3
  104. package/dist/src/sessions/host-catalog.js +157 -0
  105. package/dist/src/sessions/image-blobs.js +141 -0
  106. package/dist/src/sessions/importer.js +9 -232
  107. package/dist/src/sessions/index.js +6 -0
  108. package/dist/src/sessions/intake-dir.js +71 -0
  109. package/dist/src/sessions/normalization.js +251 -0
  110. package/dist/src/sessions/refresh.js +25 -2
  111. package/dist/src/sessions/repository.js +164 -22
  112. package/dist/src/sessions/tail.js +91 -0
  113. package/dist/src/sessions/workspace-discovery.js +88 -9
  114. package/dist/src/skills/agent-skill-sidecar.js +28 -0
  115. package/dist/src/skills/agent-skills.js +4 -23
  116. package/dist/src/skills/claude-surface-projection.js +25 -0
  117. package/dist/src/skills/deployer.js +8 -288
  118. package/dist/src/skills/deployment-policy.js +114 -0
  119. package/dist/src/skills/deployment-projection.js +152 -0
  120. package/dist/src/skills/deployment-types.js +20 -0
  121. package/dist/src/skills/exporter.js +8 -31
  122. package/dist/src/skills/provider-extensions.js +41 -0
  123. package/dist/src/skills/run.js +6 -0
  124. package/dist/src/skills/types.js +0 -8
  125. package/dist/src/skills/validator-resources.js +68 -0
  126. package/dist/src/skills/validator.js +6 -55
  127. package/dist/src/storage/cli-migrate-options.js +125 -0
  128. package/dist/src/storage/cli-migrate.js +111 -0
  129. package/dist/src/storage/cli.js +4 -279
  130. package/dist/src/storage/migration-adapter.js +79 -0
  131. package/dist/src/storage/migration-copy.js +108 -0
  132. package/dist/src/storage/migration-protocol.js +14 -197
  133. package/dist/src/storage/migration-records.js +186 -0
  134. package/dist/src/storage/migration-types.js +12 -0
  135. package/dist/src/tracker/forge-http.js +2 -1
  136. package/dist/src/tracker/issue-pages.js +110 -0
  137. package/package.json +7 -1
  138. package/schemas/mission-v1.schema.json +335 -0
  139. package/schemas/models/model-catalog.v1.schema.json +19 -1
  140. package/schemas/models/model-config-compatibility.v1.schema.json +3 -2
  141. package/schemas/models/model-policy.v1.schema.json +1 -1
  142. package/tools/agents/providers/provider-paths-staging.mjs +35 -0
  143. package/tools/agents/providers/rule-binding-targets.mjs +146 -0
  144. package/tools/agents/providers/rule-delivery.mjs +7 -126
  145. package/tools/ralph-external/lib/muse-adapter.mjs +10 -0
  146. package/tools/ralph-external/lib/worker-context.mjs +237 -0
  147. package/tools/ralph-external/lib/worker-invocation-ancestry.mjs +132 -0
  148. package/tools/ralph-external/lib/worker-invocation-processes.mjs +123 -0
  149. package/tools/ralph-external/lib/worker-isolation-status.mjs +48 -0
  150. package/tools/ralph-external/lib/worker-isolation.mjs +17 -29
  151. package/tools/ralph-external/lib/worker-launch-cli.mjs +3 -1
  152. package/tools/ralph-external/lib/worker-overflow.mjs +48 -13
  153. package/tools/ralph-external/lib/worker-retry-evidence.mjs +103 -0
  154. package/tools/ralph-external/lib/worker-termination.mjs +82 -0
  155. package/tools/ralph-external/session-launcher.mjs +51 -96
  156. package/vscode-extension/schemas/aiwg.config.v1.json +177 -0
@@ -0,0 +1,123 @@
1
+ // Bind worker environments to an invocation and find surviving marked processes.
2
+ import { opendirSync, statSync, openSync, readSync, closeSync, constants } from 'node:fs';
3
+ import { StringDecoder } from 'node:string_decoder';
4
+ import { classifyUnreadableWorkerProcess, readProcCgroup } from './worker-invocation-ancestry.mjs';
5
+
6
+ export const WORKER_INVOCATION_MARKER = 'AIWG_WORKER_INVOCATION_ID';
7
+
8
+ function startedBeforeWorker(fs, path, startTicks) {
9
+ if (typeof startTicks !== 'string' || !/^\d+$/.test(startTicks)) return false;
10
+ let fd;
11
+ try {
12
+ fd = fs.openSync(`${path}/stat`, constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOFOLLOW);
13
+ const buffer = Buffer.alloc(4096);
14
+ const size = fs.readSync(fd, buffer, 0, buffer.length, null);
15
+ if (size === buffer.length) return false;
16
+ const stat = buffer.toString('utf8', 0, size);
17
+ // comm may contain spaces and parentheses; field 3 begins after its LAST ')'.
18
+ const end = stat.lastIndexOf(')');
19
+ const ticks = end < 0 ? undefined : stat.slice(end + 1).trim().split(/\s+/)[19];
20
+ return /^\d+$/.test(ticks ?? '') && BigInt(ticks) < BigInt(startTicks);
21
+ } catch { return false; }
22
+ finally { if (fd !== undefined) fs.closeSync(fd); }
23
+ }
24
+
25
+ export function buildWorkerInvocationEnvironment(envNames, invocationId, inherited = process.env) {
26
+ if (typeof invocationId !== 'string' || !invocationId || invocationId.includes('\0')) {
27
+ throw new Error('Worker environment requires a non-empty NUL-free invocation ID');
28
+ }
29
+ const names = [...new Set([...envNames, WORKER_INVOCATION_MARKER])];
30
+ const env = Object.fromEntries(envNames.filter(name => inherited[name] !== undefined)
31
+ .map(name => [name, inherited[name]]));
32
+ return { env: { ...env, [WORKER_INVOCATION_MARKER]: invocationId }, envNames: names };
33
+ }
34
+
35
+ export function scanWorkerInvocationProcesses({ invocationId, startTicks, uid = process.getuid?.(),
36
+ platform = process.platform, procRoot = '/proc', now = () => performance.now(),
37
+ deadline = now() + 100, maxEntries = 4096, maxEnvironmentBytes = 1048576,
38
+ workerPid, workerPgid, workerSession, workerCgroup, selfCgroup, maxAncestryDepth,
39
+ fs = { opendirSync, statSync, openSync, readSync, closeSync } } = {}) {
40
+ const unknown = detail => ({ confirmed: false,
41
+ missing: `escaped-descendant evidence: ${detail}` });
42
+ if (platform !== 'linux') return unknown('same-uid invocation-marker scan requires Linux /proc');
43
+ if (!Number.isInteger(uid) || typeof invocationId !== 'string' || !invocationId || invocationId.includes('\0')) {
44
+ return unknown('launcher-bound invocation marker and uid');
45
+ }
46
+ // Ancestry discrimination needs the recorded worker leader. Without it the
47
+ // scan keeps the legacy fail-closed behaviour for every unreadable
48
+ // post-start process.
49
+ const workerKnown = Number.isSafeInteger(workerPid) && workerPid > 1
50
+ && typeof startTicks === 'string' && /^\d+$/.test(startTicks);
51
+ let resolvedWorkerCgroup = workerCgroup;
52
+ if (resolvedWorkerCgroup === undefined && workerKnown) {
53
+ resolvedWorkerCgroup = readProcCgroup(fs, `${procRoot}/${workerPid}`);
54
+ }
55
+ let resolvedSelfCgroup = selfCgroup;
56
+ if (resolvedSelfCgroup === undefined) resolvedSelfCgroup = readProcCgroup(fs, `${procRoot}/self`);
57
+ const marker = `${WORKER_INVOCATION_MARKER}=${invocationId}`;
58
+ const present = () => ({ ...unknown('same-uid process still carries invocation marker (possible escaped descendant)'), markerPresent: true });
59
+ const gone = error => ['ENOENT', 'ESRCH'].includes(error.code);
60
+ let directory, uncertain = null;
61
+ try {
62
+ directory = fs.opendirSync(procRoot);
63
+ // Stream entries and cap per-process reads; neither a huge PID set nor
64
+ // a large environment can turn this proof into an unbounded allocation.
65
+ const buffer = Buffer.alloc(Math.min(65536, maxEnvironmentBytes + 1));
66
+ for (let count = 0; ; count++) {
67
+ if (now() >= deadline) return unknown('same-uid invocation-marker scan time bound reached');
68
+ const entry = directory.readSync();
69
+ if (!entry) return uncertain ?? { confirmed: true, missing: null };
70
+ if (count >= maxEntries) return unknown('same-uid invocation-marker scan count bound reached');
71
+ if (!/^\d+$/.test(entry.name)) continue;
72
+ const path = `${procRoot}/${entry.name}`;
73
+ let fd, sameUid = false;
74
+ try {
75
+ if (fs.statSync(path).uid !== uid) continue;
76
+ sameUid = true;
77
+ fd = fs.openSync(`${path}/environ`, constants.O_RDONLY | constants.O_NONBLOCK | constants.O_NOFOLLOW);
78
+ let bytes = 0, tail = '';
79
+ const decoder = new StringDecoder('utf8');
80
+ for (;;) {
81
+ if (now() >= deadline) return unknown('same-uid invocation-marker scan time bound reached');
82
+ const size = fs.readSync(fd, buffer, 0, Math.min(buffer.length, maxEnvironmentBytes + 1 - bytes), null);
83
+ if (!size) break;
84
+ bytes += size;
85
+ if (bytes > maxEnvironmentBytes) {
86
+ uncertain = unknown('same-uid process environment exceeds scan byte bound'); break;
87
+ }
88
+ const fields = (tail + decoder.write(buffer.subarray(0, size))).split('\0');
89
+ tail = fields.pop();
90
+ if (fields.includes(marker)) return present();
91
+ }
92
+ if (tail + decoder.end() === marker) return present();
93
+ } catch (error) {
94
+ // An escaped descendant cannot predate its worker leader. Ownership
95
+ // failures and unreadable/missing start times still cannot prove absence.
96
+ if (gone(error)) continue;
97
+ if (!sameUid) {
98
+ uncertain = unknown('unreadable same-uid process environment or ownership');
99
+ continue;
100
+ }
101
+ if (!workerKnown) {
102
+ if (!startedBeforeWorker(fs, path, startTicks)) {
103
+ uncertain = unknown('unreadable same-uid process environment or ownership');
104
+ }
105
+ continue;
106
+ }
107
+ // Hardened (non-dumpable, e.g. Codex) processes hide their environment
108
+ // but not their stat/cgroup links: a parallel worker's tree reaches a
109
+ // live process that predates this worker and is provably not ours,
110
+ // while a genuine escapee still links to the leader, its session or
111
+ // group, or its private cgroup.
112
+ const verdict = classifyUnreadableWorkerProcess({ fs, procRoot, pid: Number(entry.name), uid,
113
+ workerPid, workerPgid, workerSession, workerStart: startTicks,
114
+ workerCgroup: resolvedWorkerCgroup, selfCgroup: resolvedSelfCgroup,
115
+ now, deadline, ...(maxAncestryDepth === undefined ? {} : { maxDepth: maxAncestryDepth }) });
116
+ if (verdict === 'possible-descendant') {
117
+ uncertain = unknown('unreadable same-uid process links to the worker (ancestry, session, group or cgroup)');
118
+ }
119
+ } finally { if (fd !== undefined) fs.closeSync(fd); }
120
+ }
121
+ } catch { return unknown('readable Linux /proc process inventory'); }
122
+ finally { directory?.closeSync(); }
123
+ }
@@ -0,0 +1,48 @@
1
+ // Read retained unit exit state and its kernel-backed recursive task count.
2
+ import { spawnSync } from 'node:child_process';
3
+ import { readFileSync } from 'node:fs';
4
+ import { buildSystemdClientEnv, resolveSystemdClient } from './systemd-client.mjs';
5
+
6
+ function emptyCgroup(fields, readCgroup) {
7
+ if (fields.TasksCurrent === '0') return true;
8
+ if (/^[1-9][0-9]*$/.test(fields.TasksCurrent ?? '')) return false;
9
+ if (!['inactive', 'failed'].includes(fields.ActiveState)) return false;
10
+ // A loaded inactive unit can have released its cgroup entirely.
11
+ if (fields.LoadState === 'loaded' && fields.ControlGroup === '') return true;
12
+ const group = fields.ControlGroup;
13
+ if (!group?.startsWith('/') || group === '/' || group.split('/').includes('..')) return false;
14
+ try { return /^populated 0$/m.test(readCgroup(`/sys/fs/cgroup${group}/cgroup.events`, 'utf8')); }
15
+ catch (error) { return error.code === 'ENOENT'; }
16
+ }
17
+
18
+ /**
19
+ * Read retained service state after launch/exit without reaping or mutation.
20
+ * `memoryMax` is bytes. Query failures throw rather than reporting success.
21
+ */
22
+ export function getWorkerIsolationStatus(unit, { spawn = spawnSync, timeoutMs = 10000, readCgroup = readFileSync } = {}) {
23
+ if (typeof unit !== 'string' || !/^[A-Za-z0-9:_.@-]+$/.test(unit)) {
24
+ throw new Error('Worker isolation requires a unit-safe unit name');
25
+ }
26
+ const shown = spawn(resolveSystemdClient('systemctl'), ['--user', 'show', unit, '-p', 'ActiveState',
27
+ '-p', 'Result', '-p', 'ExecMainStatus', '-p', 'ExecMainCode', '-p', 'MainPID', '-p', 'MemoryMax', '-p', 'TasksCurrent', '-p', 'ControlGroup', '-p', 'LoadState'], { encoding: 'utf8', timeout: timeoutMs, env: buildSystemdClientEnv({ envNames: [] }) });
28
+ if (shown.error || shown.status !== 0) {
29
+ throw new Error(`worker status unavailable: ${shown.error?.message ?? String(shown.stderr ?? '').trim()}`);
30
+ }
31
+ const fields = Object.fromEntries(String(shown.stdout ?? '').trim().split('\n').map(line => {
32
+ const index = line.indexOf('=');
33
+ return [line.slice(0, index), line.slice(index + 1)];
34
+ }));
35
+ if (!fields.ActiveState || !fields.Result || !fields.MemoryMax) {
36
+ throw new Error('worker status unavailable: missing systemd properties');
37
+ }
38
+ return {
39
+ active: ['inactive', 'failed'].includes(fields.ActiveState) ? false
40
+ : ['active', 'activating', 'deactivating', 'reloading'].includes(fields.ActiveState) ? true : null,
41
+ ...(emptyCgroup(fields, readCgroup) ? { cgroupEmpty: true } : {}),
42
+ result: fields.Result,
43
+ ...(fields.ExecMainStatus ? { execMainStatus: Number(fields.ExecMainStatus) } : {}),
44
+ memoryMax: fields.MemoryMax === 'infinity' ? Infinity : Number(fields.MemoryMax),
45
+ ...(fields.MainPID ? { mainPid: Number(fields.MainPID) } : {}),
46
+ ...(fields.ExecMainCode ? { execMainCode: Number(fields.ExecMainCode) } : {}),
47
+ };
48
+ }
@@ -556,35 +556,7 @@ export function launchIsolatedWorker({
556
556
  });
557
557
  }
558
558
 
559
- /**
560
- * Read retained service state after launch/exit without reaping or mutation.
561
- * `memoryMax` is bytes. Query failures throw rather than reporting success.
562
- */
563
- export function getWorkerIsolationStatus(unit, { spawn = spawnSync, timeoutMs = 10000 } = {}) {
564
- if (typeof unit !== 'string' || !/^[A-Za-z0-9:_.@-]+$/.test(unit)) {
565
- throw new Error('Worker isolation requires a unit-safe unit name');
566
- }
567
- const shown = spawn(resolveSystemdClient('systemctl'), ['--user', 'show', unit, '-p', 'ActiveState',
568
- '-p', 'Result', '-p', 'ExecMainStatus', '-p', 'ExecMainCode', '-p', 'MainPID', '-p', 'MemoryMax'], { encoding: 'utf8', timeout: timeoutMs, env: buildSystemdClientEnv({ envNames: [] }) });
569
- if (shown.error || shown.status !== 0) {
570
- throw new Error(`worker status unavailable: ${shown.error?.message ?? String(shown.stderr ?? '').trim()}`);
571
- }
572
- const fields = Object.fromEntries(String(shown.stdout ?? '').trim().split('\n').map(line => {
573
- const index = line.indexOf('=');
574
- return [line.slice(0, index), line.slice(index + 1)];
575
- }));
576
- if (!fields.ActiveState || !fields.Result || !fields.ExecMainStatus || !fields.MemoryMax) {
577
- throw new Error('worker status unavailable: missing systemd properties');
578
- }
579
- return {
580
- active: ['active', 'activating', 'deactivating', 'reloading'].includes(fields.ActiveState),
581
- result: fields.Result,
582
- execMainStatus: Number(fields.ExecMainStatus),
583
- memoryMax: fields.MemoryMax === 'infinity' ? Infinity : Number(fields.MemoryMax),
584
- ...(fields.MainPID ? { mainPid: Number(fields.MainPID) } : {}),
585
- ...(fields.ExecMainCode ? { execMainCode: Number(fields.ExecMainCode) } : {}),
586
- };
587
- }
559
+ export { getWorkerIsolationStatus } from './worker-isolation-status.mjs';
588
560
 
589
561
  /**
590
562
  * Request the unit's cgroup be torn down (`KillMode=control-group` is already
@@ -608,6 +580,22 @@ export function stopWorkerUnit(unit, { spawn = spawnSync, timeoutMs = 10000 } =
608
580
  diagnostic: result.error ? result.error.message : String(result.stderr ?? '').trim() || null };
609
581
  }
610
582
 
583
+ /**
584
+ * SIGKILL every process in the unit's cgroup and return without waiting.
585
+ * `stop` sends SIGTERM and then waits for the unit's stop timeout, which a
586
+ * worker that ignores SIGTERM outlasts (#3691).
587
+ */
588
+ export function killWorkerUnit(unit, { spawn = spawnSync, timeoutMs = 10000, signal = 'SIGKILL' } = {}) {
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
+ if (!/^SIG[A-Z0-9]+$/.test(signal)) throw new Error('Worker isolation requires a named signal');
593
+ const result = spawn(resolveSystemdClient('systemctl'), ['--user', 'kill', '--kill-whom=all', `--signal=${signal}`, unit],
594
+ { encoding: 'utf8', timeout: timeoutMs, killSignal: 'SIGKILL', env: buildSystemdClientEnv({ envNames: [] }) });
595
+ return { ok: !result.error && result.status === 0,
596
+ diagnostic: result.error ? result.error.message : String(result.stderr ?? '').trim() || null };
597
+ }
598
+
611
599
  /**
612
600
  * Read the kernel-reported `ControlGroup` systemd placed this unit's
613
601
  * processes under. This is the containment authority for `systemd-unit`
@@ -12,6 +12,7 @@ export function parseWorkerLaunchArgs(args) {
12
12
  if (seen.has(flag)) throw new Error(`Duplicate worker launch option: ${flag}`);
13
13
  seen.add(flag);
14
14
  if (flag === '--launch-worker') continue;
15
+ if (flag === '--check-descendants') { result.checkDescendants = true; continue; }
15
16
  if (flag === '--allow-degraded-worker') { result.allowDegraded = true; continue; }
16
17
  // A run directory declares a retry; its launcher ID is derived before validation.
17
18
  const key = { '--invocation': 'invocationPath', '--run-dir': 'runDir',
@@ -24,11 +25,12 @@ export function parseWorkerLaunchArgs(args) {
24
25
  else result[key] = value;
25
26
  }
26
27
  if (!seen.has('--launch-worker') || !result.invocationPath || !result.runDir) {
27
- throw new Error('Usage: ralph-external --launch-worker --invocation FILE --run-dir DIR [--allow-degraded-worker] [--retry-of-run-dir DIR [--retry-of ID]]');
28
+ throw new Error('Usage: ralph-external --launch-worker --invocation FILE --run-dir DIR [--allow-degraded-worker] [--check-descendants] [--retry-of-run-dir DIR [--retry-of ID]]');
28
29
  }
29
30
  if (retryOfInvocationId !== undefined && retryOfRunDir === undefined) {
30
31
  throw new Error('--retry-of and --retry-of-run-dir must be supplied together when an explicit ID is used');
31
32
  }
33
+ if (result.checkDescendants && retryOfRunDir === undefined) throw new Error('--check-descendants requires --retry-of-run-dir');
32
34
  if (retryOfRunDir !== undefined) result.retryOf = { runDir: retryOfRunDir, ...(retryOfInvocationId === undefined ? {} : { invocationId: retryOfInvocationId }) };
33
35
  return result;
34
36
  }
@@ -1,15 +1,30 @@
1
1
  /** Stop authority and bounded termination proof for MCP stdout overflow. */
2
2
  import { importImpl } from '../../_resolve-impl.mjs';
3
- import { getWorkerIsolationStatus, stopWorkerUnit } from './worker-isolation.mjs';
3
+ import { getWorkerIsolationStatus, killWorkerUnit, stopWorkerUnit } from './worker-isolation.mjs';
4
4
  import { signalOwnedWorker } from '../session-launcher.mjs';
5
5
  import { sampleWorkerChildren, sampleWorkerIdentity, sameWorkerIdentity } from '../process-monitor.mjs';
6
6
  const { signalChild } = await importImpl(import.meta.url, 'process/signal-child.mjs');
7
7
 
8
+ // Only identities sampled beneath a verified live supervisor enter this cache.
9
+ const ownedWorkers = new WeakMap();
10
+ function hasBirthIdentity(worker) {
11
+ return Number.isSafeInteger(worker?.pid) && worker.pid > 1 && worker.pid !== process.pid
12
+ && Number.isSafeInteger(worker.pgid) && worker.pgid > 1
13
+ && Number.isSafeInteger(worker.uid) && worker.uid === process.getuid?.()
14
+ && Number.isSafeInteger(worker.sessionId) && worker.sessionId > 0
15
+ && typeof worker.startTicks === 'string' && /^\d+$/.test(worker.startTicks)
16
+ && ['bootId', 'host', 'executable'].every(key => typeof worker[key] === 'string' && worker[key].length > 0);
17
+ }
18
+
8
19
  export async function stopOverflowWorker(launch, {
9
20
  deadlineMs = 2000, pollIntervalMs = 25,
10
- stopUnit = stopWorkerUnit, unitStatus = getWorkerIsolationStatus,
21
+ // Kill and stop are one unit-stop authority: a caller that substitutes the stop
22
+ // also controls the kill, so the real systemctl never bypasses it.
23
+ stopUnit = stopWorkerUnit, killUnit = stopUnit === stopWorkerUnit ? killWorkerUnit : () => ({ ok: false }),
24
+ unitStatus = getWorkerIsolationStatus,
11
25
  sample = sampleWorkerIdentity, children = sampleWorkerChildren, platform = process.platform,
12
- signal = signalOwnedWorker, clock = Date.now, sleep = ms => new Promise(resolve => setTimeout(resolve, ms)),
26
+ signal = signalOwnedWorker, killPid = (pid, sig) => process.kill(pid, sig),
27
+ clock = Date.now, sleep = ms => new Promise(resolve => setTimeout(resolve, ms)),
13
28
  } = {}) {
14
29
  let delivered = false;
15
30
  const uncertain = diagnostic => ({ termination: 'uncertain', diagnostic, delivered });
@@ -18,8 +33,13 @@ export async function stopOverflowWorker(launch, {
18
33
  // Subprocess timeouts cannot bound stalled kernel or /proc reads.
19
34
  const remaining = () => Math.max(1, deadline - clock());
20
35
  if (launch.mode === 'systemd-unit' && launch.unit) {
21
- try { if (!(delivered = !!stopUnit(launch.unit, { timeoutMs: remaining() }).ok)) diagnostic = 'OG_WORKER_MCP_STOP_FAILED'; }
22
- catch { diagnostic = 'OG_WORKER_MCP_STOP_FAILED'; }
36
+ // SIGKILL the unit's whole cgroup first. A plain stop sends SIGTERM and then
37
+ // waits out the unit's stop timeout, which a worker ignoring SIGTERM outlasts
38
+ // (#3691). The stop that follows only cleans up the dead unit.
39
+ let killed = false;
40
+ try { killed = !!killUnit(launch.unit, { timeoutMs: remaining() }).ok; } catch { killed = false; }
41
+ try { delivered = !!stopUnit(launch.unit, { timeoutMs: remaining() }).ok || killed; } catch { delivered = killed; }
42
+ if (!delivered) diagnostic = 'OG_WORKER_MCP_STOP_FAILED';
23
43
  } else {
24
44
  const child = launch.child;
25
45
  // The direct-child handle cannot prove descendant termination.
@@ -28,15 +48,30 @@ export async function stopOverflowWorker(launch, {
28
48
  catch { return uncertain('OG_WORKER_MCP_STOP_FAILED'); }
29
49
  return uncertain('OG_WORKER_MCP_STOP_UNCONFIRMED');
30
50
  }
31
- const supervisor = sample(child?.pid);
32
- if (!supervisor || supervisor.parentPid !== process.pid || supervisor.uid !== process.getuid?.()
33
- || child.exitCode !== null || child.signalCode !== null) return uncertain('OG_WORKER_MCP_STOP_UNCONFIRMED');
34
- const candidates = launch.transportSupervisor ? children(supervisor.pid) : [supervisor];
35
- const worker = candidates?.length === 1 ? candidates[0] : null;
36
- if (!worker || launch.transportSupervisor && worker.parentPid !== supervisor.pid
37
- || !sameWorkerIdentity(supervisor, sample(supervisor.pid))) return uncertain('OG_WORKER_MCP_STOP_UNCONFIRMED');
51
+ let worker = launch.transportSupervisor ? ownedWorkers.get(launch) : null;
52
+ if (!worker) {
53
+ const supervisor = sample(child?.pid);
54
+ if (!supervisor || supervisor.parentPid !== process.pid || supervisor.uid !== process.getuid?.()
55
+ || child.exitCode !== null || child.signalCode !== null) return uncertain('OG_WORKER_MCP_STOP_UNCONFIRMED');
56
+ const candidates = launch.transportSupervisor ? children(supervisor.pid) : [supervisor];
57
+ worker = candidates?.length === 1 ? candidates[0] : null;
58
+ if (!worker || launch.transportSupervisor && (worker.parentPid !== supervisor.pid || !hasBirthIdentity(worker))
59
+ || !sameWorkerIdentity(supervisor, sample(supervisor.pid))) return uncertain('OG_WORKER_MCP_STOP_UNCONFIRMED');
60
+ if (launch.transportSupervisor) ownedWorkers.set(launch, worker = Object.freeze({ ...worker }));
61
+ }
38
62
  // Reuse the launcher's birth/session/UID checks; never signal a bare PID.
39
- if (!(delivered = !!signal(worker, 'SIGKILL'))) return uncertain('OG_WORKER_MCP_STOP_FAILED');
63
+ if (!(delivered = !!signal(worker, 'SIGKILL'))) {
64
+ // #3691: reparenting changes ownership topology, not process birth.
65
+ // The cache pins birth/UID/session while the parent link is intact;
66
+ // compare every identity field except parentPid on a fresh kernel sample.
67
+ // This also works on retained-control retries after client.close() has
68
+ // killed the supervisor. Never refresh the pin from an orphan or disk.
69
+ const fresh = launch.transportSupervisor ? sample(worker.pid) : null;
70
+ if (fresh && sameWorkerIdentity(worker, { ...fresh, parentPid: worker.parentPid })) {
71
+ try { killPid(worker.pid, 'SIGKILL'); delivered = true; } catch { delivered = false; }
72
+ }
73
+ if (!delivered) return uncertain('OG_WORKER_MCP_STOP_FAILED');
74
+ }
40
75
  // Descendants can escape the group, so its termination stays uncertain.
41
76
  return uncertain('OG_WORKER_MCP_STOP_UNCONFIRMED');
42
77
  }
@@ -0,0 +1,103 @@
1
+ // Authenticate predecessor records and optionally recheck owned descendant absence.
2
+ import { join } from 'node:path';
3
+ import { importImpl } from '../../_resolve-impl.mjs';
4
+ import { lstat } from 'node:fs/promises';
5
+ import { boundedPrivateRead } from './worker-inputs.mjs';
6
+ import { realBeneath, stableTaskChainDigest, assertStopBeforeRetry, publishWorkerRecord, UNBOUND_RETRY_EVIDENCE_CODE } from '../session-launcher.mjs';
7
+ import { recheckWorkerTermination } from './worker-termination.mjs';
8
+
9
+ async function matchesLegacyTask(record, plan) {
10
+ // Non-recovery launches retained a digest of the request including output paths.
11
+ // Reconstruct only those paths; all task fields and the brief content stay bound.
12
+ if (![record.prompt?.path, record.cwd, record.paths?.final].every(value => typeof value === 'string')
13
+ || record.prompt?.digest !== plan.invocation?.briefDigest) return false;
14
+ const { qualificationDigest } = await importImpl(import.meta.url, 'models/qualification-digest.mjs');
15
+ return record.taskDigest === qualificationDigest({ ...plan.request,
16
+ brief: record.prompt.path, workdir: record.cwd, final_output: record.paths.final });
17
+ }
18
+
19
+ export async function verifyRetryPredecessor(retryOf, { newInvocationPath, inspect, workerWritableRoots, checkDescendants = false } = {}) {
20
+ const fail = (reason) => {
21
+ const error = new Error(`Retry predecessor evidence refused: ${reason}`);
22
+ error.code = UNBOUND_RETRY_EVIDENCE_CODE;
23
+ throw error;
24
+ };
25
+ if (!retryOf || typeof retryOf.invocationId !== 'string' || !retryOf.invocationId
26
+ || typeof retryOf.runDir !== 'string' || !retryOf.runDir) {
27
+ fail('retryOf requires both a non-empty invocationId and runDir');
28
+ }
29
+ const { invocationId: prevId, runDir: prevRunDir } = retryOf;
30
+ // #2915 review 4 (finding 4): the minimal directory-authority repair —
31
+ // reject a predecessor location inside any worker-writable root, exactly
32
+ // like `launchOneWorker` already refuses for a NEW run directory. This
33
+ // closes the easiest forgery (planting a complete, well-formed fake
34
+ // predecessor inside the worktree the worker itself can write to) but is
35
+ // explicitly NOT a defense against an unconfined malicious same-UID
36
+ // worker in general: such a worker can still write anywhere this
37
+ // conductor process can (e.g. directly under a configured capture root,
38
+ // if that root is not itself outside every worker-writable path, or by
39
+ // racing a legitimate predecessor's own directory before this check
40
+ // runs). A real guarantee needs an enforced filesystem/process boundary
41
+ // protecting conductor-owned metadata, or a different execution
42
+ // identity — same-UID alone never provides it.
43
+ if (Array.isArray(workerWritableRoots)) {
44
+ for (const root of workerWritableRoots) {
45
+ if (await realBeneath(root, prevRunDir)) fail('predecessor location is inside a worker-writable root');
46
+ }
47
+ }
48
+ if (!await inspect(prevRunDir, 'directory')) fail('predecessor run directory is missing');
49
+ const dirStat = await lstat(prevRunDir);
50
+ if (dirStat.uid !== process.getuid() || (dirStat.mode & 0o077)) fail('predecessor run directory is not privately owned');
51
+ let ownerBytes, invocationBytes, exitBytes;
52
+ try {
53
+ // #2915 review 4 (finding 4): `rejectInsecureMode: true` — an insecure
54
+ // mode on predecessor metadata REFUSES the retry instead of silently
55
+ // chmod-repairing it and proceeding (the former behaviour the review
56
+ // found: "the probe's 0666 files were accepted and changed to 0600").
57
+ ownerBytes = await boundedPrivateRead(join(prevRunDir, '.launch-owner'), 256, inspect, true, true);
58
+ invocationBytes = await boundedPrivateRead(join(prevRunDir, 'invocation.json'), 1048576, inspect, true, true);
59
+ exitBytes = await boundedPrivateRead(join(prevRunDir, 'exit.json'), 1048576, inspect, true, true);
60
+ } catch (error) { fail(`predecessor metadata is missing, insecurely-moded, or not a bounded, privately-owned, unaliased regular file (${error.message})`); }
61
+ if (ownerBytes.toString('utf8') !== prevId) fail('.launch-owner does not match retryOf.invocationId');
62
+ let invocationRecord, exitRecord;
63
+ try {
64
+ invocationRecord = JSON.parse(invocationBytes.toString('utf8'));
65
+ exitRecord = JSON.parse(exitBytes.toString('utf8'));
66
+ } catch { fail('predecessor metadata is not valid JSON'); }
67
+ if (invocationRecord?.invocationId !== prevId || invocationRecord?.origin !== 'launcher') {
68
+ fail('invocation.json identity does not match retryOf (or is not launcher-written)');
69
+ }
70
+ if (exitRecord?.invocationId !== prevId) fail('exit.json identity does not match retryOf');
71
+ if (newInvocationPath !== undefined) {
72
+ // #2915 review 4 (finding 4): the SAME stable task-chain identity
73
+ // `launchOneWorker` publishes — role/task/group plus the brief CONTENT
74
+ // digest, never `final_output`/`brief`/`workdir` PATHS, which legitimately
75
+ // differ between a confirmed predecessor and this fresh attempt.
76
+ let newTaskDigest, newPlan;
77
+ try {
78
+ const newPlanBytes = await boundedPrivateRead(newInvocationPath, 1048576, inspect);
79
+ newPlan = JSON.parse(newPlanBytes.toString('utf8'));
80
+ newTaskDigest = await stableTaskChainDigest(newPlan.request, newPlan.invocation?.briefDigest ?? null);
81
+ } catch { fail('new attempt plan is missing or invalid; cannot bind the retry to a task'); }
82
+ if (invocationRecord.taskDigest !== newTaskDigest && !await matchesLegacyTask(invocationRecord, newPlan)) fail('predecessor task does not match the new attempt (task-chain binding)');
83
+ }
84
+ if (exitRecord.termination !== 'confirmed') {
85
+ let evidence = null;
86
+ if (checkDescendants) {
87
+ try {
88
+ const processRecord = JSON.parse((await boundedPrivateRead(join(prevRunDir, 'process.json'), 65536, inspect, true, true)).toString('utf8'));
89
+ evidence = await recheckWorkerTermination({ processRecord, exitRecord });
90
+ } catch (error) { evidence = { confirmed: false, missing: `readable, private launcher process.json (${error.message})` }; }
91
+ if (evidence.confirmed) {
92
+ exitRecord = { ...exitRecord, termination: 'confirmed', descendants: 'none',
93
+ ...(exitRecord.health ? { health: { ...exitRecord.health, termination: 'confirmed' } } : {}) };
94
+ await publishWorkerRecord(prevRunDir, 'exit.json', exitRecord, inspect);
95
+ }
96
+ }
97
+ if (exitRecord.termination !== 'confirmed') {
98
+ assertStopBeforeRetry(exitRecord, { expectedInvocationId: prevId, runDir: prevRunDir, missing: evidence?.missing });
99
+ }
100
+ }
101
+ assertStopBeforeRetry(exitRecord, { expectedInvocationId: prevId });
102
+ return exitRecord;
103
+ }
@@ -0,0 +1,82 @@
1
+ // Confirm owned containment is empty independently of semantic observer coverage.
2
+ import { sampleWorkerIdentity, isSafeWorkerGroupTarget } from '../process-monitor.mjs';
3
+ import { getWorkerIsolationStatus } from './worker-isolation-status.mjs';
4
+ import { scanWorkerInvocationProcesses } from './worker-invocation-processes.mjs';
5
+
6
+ export function observeWorkerTermination({ mode, native, workerExited, identity,
7
+ supervisor = sampleWorkerIdentity(process.pid), kill = process.kill, invocationId,
8
+ scan = scanWorkerInvocationProcesses, deadline } = {}) {
9
+ if (mode === 'systemd-unit') {
10
+ const exited = workerExited === true || [1, 2, 3].includes(native?.execMainCode);
11
+ return { confirmed: exited && native?.active === false && native?.cgroupEmpty === true,
12
+ missing: !exited ? 'launcher-observed exit or unit ExecMainCode exit evidence' : native?.active !== false
13
+ ? 'unit inactive evidence' : native?.cgroupEmpty !== true ? 'empty unit cgroup (TasksCurrent=0)' : null };
14
+ }
15
+ if (!workerExited) return { confirmed: false, missing: 'launcher-observed worker exit' };
16
+ if (!identity || identity.pid !== identity.pgid || identity.uid !== process.getuid?.()
17
+ || !supervisor || identity.host !== supervisor.host || identity.bootId !== supervisor.bootId
18
+ || !isSafeWorkerGroupTarget(identity.pgid, supervisor)) {
19
+ return { confirmed: false, missing: 'launcher-owned process group identity on this host and boot' };
20
+ }
21
+ try { kill(-identity.pgid, 0); }
22
+ catch (error) {
23
+ // Pass the recorded leader so the marker scan can tell a parallel
24
+ // hardened (non-dumpable) worker's tree apart from a genuine escapee via
25
+ // readable stat/cgroup links instead of failing closed on every
26
+ // unreadable post-start process.
27
+ if (error.code === 'ESRCH') {
28
+ return scan({ invocationId, startTicks: identity.startTicks, uid: identity.uid, deadline,
29
+ workerPid: identity.pid, workerPgid: identity.pgid, workerSession: identity.sessionId });
30
+ }
31
+ return { confirmed: false, missing: `empty owned process group (signal-0 returned ${error.code ?? 'unknown'})` };
32
+ }
33
+ return { confirmed: false, missing: 'empty owned process group (signal-0 still finds members)' };
34
+ }
35
+
36
+ export function terminationRemedy(result, runDir) {
37
+ const missing = result?.isolation === 'systemd-unit'
38
+ ? 'unit exit, inactive state and empty cgroup (TasksCurrent=0)'
39
+ : 'launcher-observed worker exit, empty owned process group (kill(-pgid, 0) = ESRCH) and escaped-descendant evidence (bounded same-uid invocation-marker scan in Linux /proc)';
40
+ return `Missing evidence: ${missing}. Repeat the original --launch-worker${runDir ? ` --retry-of-run-dir ${JSON.stringify(runDir)}` : ' --retry-of-run-dir DIR'} or --verify-worker${runDir ? ` --run-dir ${JSON.stringify(runDir)}` : ' --run-dir DIR'} command with --check-descendants for a bounded descendant check (at most 2 seconds); live or unknown descendants still refuse linkage. Missing launcher-owned group identity requires the original launch owner to establish containment; worker-reported identity cannot authorize this check.`;
41
+ }
42
+
43
+ export async function recheckWorkerTermination({ processRecord, exitRecord,
44
+ status = getWorkerIsolationStatus, observe = observeWorkerTermination,
45
+ now = () => performance.now(), wait = ms => new Promise(resolve => setTimeout(resolve, ms)) }) {
46
+ if (processRecord?.invocationId !== exitRecord.invocationId || processRecord.origin !== 'launcher') {
47
+ return { confirmed: false, missing: 'matching launcher process.json' };
48
+ }
49
+ const local = sampleWorkerIdentity(process.pid);
50
+ if (!local || processRecord.supervisor?.host !== local.host || processRecord.supervisor?.bootId !== local.bootId
51
+ || processRecord.supervisor?.uid !== local.uid || processRecord.isolation !== exitRecord.isolation
52
+ || !['systemd-unit', 'native-sandbox', 'degraded'].includes(processRecord.isolation)) {
53
+ return { confirmed: false, missing: 'launcher containment identity on this host and boot' };
54
+ }
55
+ const identity = processRecord.identityObservation === 'stable-owned-group'
56
+ && processRecord.ownership === 'owned-group' ? processRecord.worker : null;
57
+ if (processRecord.isolation !== 'systemd-unit' && processRecord.invocationMarker !== processRecord.invocationId) {
58
+ return { confirmed: false, missing: 'escaped-descendant evidence: launcher-bound invocation marker in process.json' };
59
+ }
60
+ const deadline = now() + 2000;
61
+ // Both elapsed time and sample count bound the check, including injected clocks.
62
+ let evidence;
63
+ for (let attempt = 0; attempt < 21; attempt++) {
64
+ if (evidence && now() >= deadline) return evidence;
65
+ let native = null;
66
+ // Use the unit's retained exit state whenever a unit name is recorded,
67
+ // not only when the recorded mode already says systemd-unit: launch-mode
68
+ // selection is unchanged, but unit (cgroup) evidence is preferred
69
+ // wherever it is available. Non-unit modes still ignore it below.
70
+ if (typeof processRecord.unit === 'string' && processRecord.unit !== '') {
71
+ try { native = status(processRecord.unit, { timeoutMs: Math.max(1, Math.min(100, deadline - now())) }); }
72
+ catch { /* Unavailable is never empty. */ }
73
+ }
74
+ evidence = observe({ mode: processRecord.isolation, native, identity, invocationId: processRecord.invocationId,
75
+ deadline: Math.min(deadline, now() + 100),
76
+ workerExited: Number.isInteger(exitRecord.exitCode) && exitRecord.exitCode >= 0 && exitRecord.exitCode <= 255
77
+ || exitRecord.captureCoverage === 'complete' && exitRecord.workerTermination === 'confirmed' && /^SIG[A-Z0-9]+$/.test(exitRecord.signal ?? '') });
78
+ if (evidence.confirmed || evidence.markerPresent || now() >= deadline || attempt === 20) return evidence;
79
+ await wait(Math.min(100, Math.max(0, deadline - now())));
80
+ }
81
+ return evidence;
82
+ }