@aiwg/cli 2026.10.9 → 2026.10.11

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 (116) hide show
  1. package/agentic/code/providers/model-capabilities.v1.json +275 -17
  2. package/bin/aiwg.mjs +8 -0
  3. package/dist/src/cli/handlers/mc-admission.js +39 -0
  4. package/dist/src/cli/handlers/mc.js +2 -31
  5. package/dist/src/cli/handlers/models-help.js +52 -0
  6. package/dist/src/cli/handlers/models.js +11 -81
  7. package/dist/src/cli/handlers/sessions.js +198 -15
  8. package/dist/src/cli/handlers/use-addon-profile.js +72 -0
  9. package/dist/src/cli/handlers/use.js +203 -234
  10. package/dist/src/cli/ui.js +1 -0
  11. package/dist/src/config/aiwg-config.js +38 -184
  12. package/dist/src/config/cli-help.js +54 -0
  13. package/dist/src/config/cli.js +5 -69
  14. package/dist/src/config/config-normalization.js +10 -0
  15. package/dist/src/config/worker-activation-gate.js +26 -0
  16. package/dist/src/config/worker-adoption-cli.js +102 -0
  17. package/dist/src/config/worker-adoption.js +368 -0
  18. package/dist/src/config/worker-config-digest.js +14 -0
  19. package/dist/src/config/worker-context.js +337 -0
  20. package/dist/src/config/worker-group-templates.js +150 -0
  21. package/dist/src/config/worker-guidance-warning.js +28 -0
  22. package/dist/src/config/worker-owner-lock.js +152 -0
  23. package/dist/src/config/worker-parallelism.js +154 -0
  24. package/dist/src/config/worker-write.js +75 -0
  25. package/dist/src/decision/telemetry/exporter.js +2 -1
  26. package/dist/src/decision/telemetry/index.js +1 -0
  27. package/dist/src/decision/telemetry/mapping.js +43 -0
  28. package/dist/src/decision/telemetry/otlp-http.js +5 -2
  29. package/dist/src/decision/telemetry/redaction.js +6 -1
  30. package/dist/src/decision/telemetry/types.js +3 -0
  31. package/dist/src/decision/telemetry/worker.js +69 -0
  32. package/dist/src/lint/rule-activation.js +15 -7
  33. package/dist/src/lint/runner.js +2 -2
  34. package/dist/src/mcp/server.mjs +2 -11
  35. package/dist/src/mcp/stdio-lifecycle.mjs +27 -0
  36. package/dist/src/mcp/tools/worker-failure-diagnostics.mjs +9 -0
  37. package/dist/src/mcp/tools/worker-invoke.mjs +114 -16
  38. package/dist/src/mission-protocol/codecs.js +8 -38
  39. package/dist/src/mission-protocol/worker-contract-diagnostics.js +62 -0
  40. package/dist/src/mission-protocol/worker-invocation-types.js +2 -0
  41. package/dist/src/models/conductor-data-policy.d.mts +8 -0
  42. package/dist/src/models/conductor-data-policy.mjs +190 -0
  43. package/dist/src/models/config-loader.js +19 -3
  44. package/dist/src/models/config-writer.js +27 -0
  45. package/dist/src/models/model-capabilities.v1.json +275 -17
  46. package/dist/src/models/provider-policy.js +14 -155
  47. package/dist/src/models/resolver.js +2 -293
  48. package/dist/src/models/worker-artifact-binding.mjs +12 -0
  49. package/dist/src/models/worker-direct-readiness-policy.js +155 -0
  50. package/dist/src/models/worker-direct-readiness.js +9 -218
  51. package/dist/src/models/worker-health-readiness.js +105 -26
  52. package/dist/src/models/worker-installation.mjs +12 -14
  53. package/dist/src/models/worker-member-references.js +64 -0
  54. package/dist/src/models/worker-plan.js +317 -0
  55. package/dist/src/models/worker-selection-policy.js +217 -0
  56. package/dist/src/models/wrapper-deployment.js +1 -1
  57. package/dist/src/policy/authorization.js +29 -1
  58. package/dist/src/process/aiwg-probe-command.d.mts +15 -0
  59. package/dist/src/process/aiwg-probe-command.mjs +37 -0
  60. package/dist/src/serve/shared-host-admission-checks.js +116 -0
  61. package/dist/src/serve/shared-host-scheduler.js +25 -107
  62. package/dist/src/serve/shared-host-starvation.js +73 -0
  63. package/dist/src/serve/worker-artifacts.d.mts +4 -0
  64. package/dist/src/serve/worker-artifacts.mjs +56 -0
  65. package/dist/src/serve/worker-command-request.js +68 -0
  66. package/dist/src/serve/worker-invocation-paths.js +144 -0
  67. package/dist/src/serve/worker-invocation-preparation.js +169 -0
  68. package/dist/src/serve/worker-invocation.js +24 -291
  69. package/dist/src/serve/worker-stdin-frame.d.mts +2 -2
  70. package/dist/src/serve/worker-stdin-frame.mjs +6 -6
  71. package/dist/src/sessions/adapters/grokbot.js +7 -0
  72. package/dist/src/sessions/batch-import.js +34 -6
  73. package/dist/src/sessions/contracts.js +19 -0
  74. package/dist/src/sessions/coverage-preflight.js +7 -1
  75. package/dist/src/sessions/fortemi-export-mapping.js +33 -3
  76. package/dist/src/sessions/host-catalog.js +157 -0
  77. package/dist/src/sessions/image-blobs.js +141 -0
  78. package/dist/src/sessions/importer.js +23 -3
  79. package/dist/src/sessions/index.js +2 -0
  80. package/dist/src/sessions/refresh.js +25 -2
  81. package/dist/src/sessions/repository.js +164 -22
  82. package/dist/src/sessions/workspace-discovery.js +5 -2
  83. package/dist/src/smiths/context-pipeline/parallelism-section.js +19 -7
  84. package/package.json +15 -1
  85. package/schemas/mission-v1.schema.json +20 -19
  86. package/schemas/models/model-config-compatibility.v1.schema.json +16 -0
  87. package/tools/agents/providers/claude-post-deploy.mjs +35 -0
  88. package/tools/agents/providers/claude.mjs +3 -30
  89. package/tools/agents/providers/codex-post-deploy.mjs +13 -0
  90. package/tools/agents/providers/codex.mjs +2 -8
  91. package/tools/agents/providers/conductor-data-guard.mjs +61 -0
  92. package/tools/agents/providers/rule-delivery.mjs +3 -16
  93. package/tools/agents/providers/rule-presentation.mjs +38 -0
  94. package/tools/plugin/package-plugins.mjs +1 -1
  95. package/tools/ralph-external/lib/claude-adapter.mjs +2 -2
  96. package/tools/ralph-external/lib/codex-adapter.mjs +3 -3
  97. package/tools/ralph-external/lib/managed-worker-attempt.mjs +86 -0
  98. package/tools/ralph-external/lib/muse-adapter.mjs +10 -0
  99. package/tools/ralph-external/lib/provider-adapter.mjs +11 -260
  100. package/tools/ralph-external/lib/session-attempt.mjs +85 -0
  101. package/tools/ralph-external/lib/strict-worker-json.mjs +190 -0
  102. package/tools/ralph-external/lib/worker-artifacts.mjs +209 -0
  103. package/tools/ralph-external/lib/worker-context.mjs +237 -0
  104. package/tools/ralph-external/lib/worker-final.mjs +143 -0
  105. package/tools/ralph-external/lib/worker-inputs.mjs +55 -0
  106. package/tools/ralph-external/lib/worker-invocation-ancestry.mjs +132 -0
  107. package/tools/ralph-external/lib/worker-invocation-processes.mjs +123 -0
  108. package/tools/ralph-external/lib/worker-isolation-status.mjs +48 -0
  109. package/tools/ralph-external/lib/worker-isolation.mjs +19 -73
  110. package/tools/ralph-external/lib/worker-launch-cli.mjs +49 -0
  111. package/tools/ralph-external/lib/worker-native-sandbox.mjs +83 -0
  112. package/tools/ralph-external/lib/worker-overflow.mjs +48 -13
  113. package/tools/ralph-external/lib/worker-retry-evidence.mjs +103 -0
  114. package/tools/ralph-external/lib/worker-termination.mjs +82 -0
  115. package/tools/ralph-external/session-launcher.mjs +124 -313
  116. package/vscode-extension/schemas/aiwg.config.v1.json +161 -2
@@ -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
+ }
@@ -42,50 +42,8 @@ import { buildSystemdClientEnv, resolveSystemdClient, resolveSystemdRun, CONNECT
42
42
 
43
43
  const UNIT_SAFE = /[^A-Za-z0-9:_.@-]/g;
44
44
  const transportBootstrapVariables = ['NODE_OPTIONS', 'NODE_PATH', 'LD_PRELOAD', 'LD_LIBRARY_PATH', 'LD_AUDIT', 'LD_DEBUG', 'LD_DEBUG_OUTPUT'];
45
- const nativeSandboxProbes = new Map();
46
- let nativeSandboxProbeSequence = 0;
47
- export const NATIVE_SANDBOX_UNAVAILABLE = 'OG_NATIVE_SANDBOX_UNAVAILABLE';
48
-
49
- export function workerNeedsNativeNamespaces(adapter, permissions = {}, platform = process.platform) {
50
- return platform === 'linux' && adapter?.provider === 'codex'
51
- && (permissions.transport ?? 'shell') === 'shell'
52
- && ['read-only', 'workspace-write'].includes(permissions.sandbox ?? 'workspace-write');
53
- }
54
-
55
- export function nativeSandboxFailure(text) {
56
- return /loopback: Failed RTM_NEWADDR: Operation not permitted|uid_map[^\n]*Operation not permitted/i.test(text);
57
- }
58
-
59
- export function nativeSandboxProbeMessage(result) {
60
- return `${NATIVE_SANDBOX_UNAVAILABLE}: Native sandbox unavailable: restricted unprivileged user namespaces inside systemd user units (host AppArmor policy may restrict userns). Probe: ${String(result.detail ?? 'failed').replace(/[\x00-\x1f\x7f]/g, ' ').slice(0, 512)}. Select parallelism.groups.<group>.execution.launchMode="native-sandbox" with a verified enforcing CLI sandbox, or configure a host AppArmor allowance for the systemd worker launch path.`;
61
- }
62
-
63
- /** No inference: exercise user/network namespaces through the worker's unit and client environment path. */
64
- export function probeWorkerNativeSandbox({ spawn = spawnSync, env = process.env, envNames = ['PATH', 'HOME'], cwd,
65
- cache = spawn === spawnSync ? nativeSandboxProbes : undefined } = {}) {
66
- const key = JSON.stringify([cwd, [...envNames].sort()]);
67
- if (cache?.has(key)) return cache.get(key);
68
- let result;
69
- try {
70
- const managerEnvNames = resolveManagerEnvNames({ spawn });
71
- const command = process.execPath;
72
- const args = [fileURLToPath(import.meta.url), '--worker-environment', JSON.stringify(unitEnvironmentNames(envNames)),
73
- '--', '/usr/bin/unshare', '--user', '--map-root-user', '--net', '/usr/bin/true'];
74
- const argv = buildWorkerIsolationArgs({ unit: proposeWorkerUnitName(`sandbox-probe-${process.pid}-${++nativeSandboxProbeSequence}`),
75
- command, args, cwd, env, envNames, managerEnvNames, memoryMaxGb: 0.25, attached: true,
76
- extraProperties: ['RuntimeMaxSec=5s', 'TimeoutStopSec=1s'] });
77
- const observed = spawn(resolveSystemdRun(), ['--collect', ...argv.slice(1)], {
78
- encoding: 'utf8', timeout: 8000, maxBuffer: 4096, env: buildSystemdClientEnv({ env, envNames }) });
79
- result = observed.status === 0 && !observed.error ? { ok: true, mode: 'systemd-unit' }
80
- : { ok: false, mode: 'systemd-unit', code: NATIVE_SANDBOX_UNAVAILABLE,
81
- detail: String(observed.error?.message ?? (String(observed.stderr ?? '').trim() || `exit ${observed.status}`)).replace(/[\x00-\x1f\x7f]/g, ' ').slice(0, 512) };
82
- } catch (error) {
83
- result = { ok: false, mode: 'systemd-unit', code: NATIVE_SANDBOX_UNAVAILABLE,
84
- detail: String(error.message).replace(/[\x00-\x1f\x7f]/g, ' ').slice(0, 512) };
85
- }
86
- cache?.set(key, result);
87
- return result;
88
- }
45
+ export { NATIVE_SANDBOX_UNAVAILABLE, workerNeedsNativeNamespaces, nativeSandboxFailure,
46
+ nativeSandboxProbeMessage, probeWorkerNativeSandbox } from './worker-native-sandbox.mjs';
89
47
 
90
48
  /** Derive a unit-safe `aiwg-worker-<name>-<epoch>` unit name. */
91
49
  export function proposeWorkerUnitName(name, nowSeconds = Math.floor(Date.now() / 1000)) {
@@ -598,35 +556,7 @@ export function launchIsolatedWorker({
598
556
  });
599
557
  }
600
558
 
601
- /**
602
- * Read retained service state after launch/exit without reaping or mutation.
603
- * `memoryMax` is bytes. Query failures throw rather than reporting success.
604
- */
605
- export function getWorkerIsolationStatus(unit, { spawn = spawnSync, timeoutMs = 10000 } = {}) {
606
- if (typeof unit !== 'string' || !/^[A-Za-z0-9:_.@-]+$/.test(unit)) {
607
- throw new Error('Worker isolation requires a unit-safe unit name');
608
- }
609
- const shown = spawn(resolveSystemdClient('systemctl'), ['--user', 'show', unit, '-p', 'ActiveState',
610
- '-p', 'Result', '-p', 'ExecMainStatus', '-p', 'ExecMainCode', '-p', 'MainPID', '-p', 'MemoryMax'], { encoding: 'utf8', timeout: timeoutMs, env: buildSystemdClientEnv({ envNames: [] }) });
611
- if (shown.error || shown.status !== 0) {
612
- throw new Error(`worker status unavailable: ${shown.error?.message ?? String(shown.stderr ?? '').trim()}`);
613
- }
614
- const fields = Object.fromEntries(String(shown.stdout ?? '').trim().split('\n').map(line => {
615
- const index = line.indexOf('=');
616
- return [line.slice(0, index), line.slice(index + 1)];
617
- }));
618
- if (!fields.ActiveState || !fields.Result || !fields.ExecMainStatus || !fields.MemoryMax) {
619
- throw new Error('worker status unavailable: missing systemd properties');
620
- }
621
- return {
622
- active: ['active', 'activating', 'deactivating', 'reloading'].includes(fields.ActiveState),
623
- result: fields.Result,
624
- execMainStatus: Number(fields.ExecMainStatus),
625
- memoryMax: fields.MemoryMax === 'infinity' ? Infinity : Number(fields.MemoryMax),
626
- ...(fields.MainPID ? { mainPid: Number(fields.MainPID) } : {}),
627
- ...(fields.ExecMainCode ? { execMainCode: Number(fields.ExecMainCode) } : {}),
628
- };
629
- }
559
+ export { getWorkerIsolationStatus } from './worker-isolation-status.mjs';
630
560
 
631
561
  /**
632
562
  * Request the unit's cgroup be torn down (`KillMode=control-group` is already
@@ -650,6 +580,22 @@ export function stopWorkerUnit(unit, { spawn = spawnSync, timeoutMs = 10000 } =
650
580
  diagnostic: result.error ? result.error.message : String(result.stderr ?? '').trim() || null };
651
581
  }
652
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
+
653
599
  /**
654
600
  * Read the kernel-reported `ControlGroup` systemd placed this unit's
655
601
  * processes under. This is the containment authority for `systemd-unit`
@@ -0,0 +1,49 @@
1
+ // Parse one-shot launch options and derive retry IDs from launcher records.
2
+ import { join, isAbsolute } from 'node:path';
3
+ import { boundedPrivateRead } from './worker-inputs.mjs';
4
+
5
+ export function parseWorkerLaunchArgs(args) {
6
+ const result = {};
7
+ const seen = new Set();
8
+ let retryOfInvocationId;
9
+ let retryOfRunDir;
10
+ for (let i = 0; i < args.length; i++) {
11
+ const flag = args[i];
12
+ if (seen.has(flag)) throw new Error(`Duplicate worker launch option: ${flag}`);
13
+ seen.add(flag);
14
+ if (flag === '--launch-worker') continue;
15
+ if (flag === '--check-descendants') { result.checkDescendants = true; continue; }
16
+ if (flag === '--allow-degraded-worker') { result.allowDegraded = true; continue; }
17
+ // A run directory declares a retry; its launcher ID is derived before validation.
18
+ const key = { '--invocation': 'invocationPath', '--run-dir': 'runDir',
19
+ '--retry-of': '__retryOfInvocationId', '--retry-of-run-dir': '__retryOfRunDir' }[flag];
20
+ if (!key) throw new Error(`Unsupported worker launch option: ${flag}`);
21
+ const value = args[++i];
22
+ if (!value || value.startsWith('--')) throw new Error(`${flag} requires a value`);
23
+ if (key === '__retryOfInvocationId') retryOfInvocationId = value;
24
+ else if (key === '__retryOfRunDir') retryOfRunDir = value;
25
+ else result[key] = value;
26
+ }
27
+ if (!seen.has('--launch-worker') || !result.invocationPath || !result.runDir) {
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]]');
29
+ }
30
+ if (retryOfInvocationId !== undefined && retryOfRunDir === undefined) {
31
+ throw new Error('--retry-of and --retry-of-run-dir must be supplied together when an explicit ID is used');
32
+ }
33
+ if (result.checkDescendants && retryOfRunDir === undefined) throw new Error('--check-descendants requires --retry-of-run-dir');
34
+ if (retryOfRunDir !== undefined) result.retryOf = { runDir: retryOfRunDir, ...(retryOfInvocationId === undefined ? {} : { invocationId: retryOfInvocationId }) };
35
+ return result;
36
+ }
37
+
38
+ export async function resolveRetryReference(retryOf, { inspect }) {
39
+ if (!retryOf || typeof retryOf !== 'object' || retryOf.invocationId !== undefined) return retryOf;
40
+ if (!isAbsolute(retryOf.runDir ?? '')) throw new Error('Retry run directory must be absolute');
41
+ const bytes = await boundedPrivateRead(join(retryOf.runDir, 'invocation.json'), 1048576, inspect, true, true);
42
+ const record = JSON.parse(bytes.toString('utf8'));
43
+ if (record.origin !== 'launcher' || typeof record.invocationId !== 'string' || !record.invocationId) {
44
+ throw new Error('Retry predecessor invocation.json must contain a launcher invocationId');
45
+ }
46
+ // Derivation is not acceptance: verifyRetryPredecessor still checks ownership,
47
+ // task-chain identity, matching records, writable roots and confirmed stop.
48
+ return { ...retryOf, invocationId: record.invocationId };
49
+ }
@@ -0,0 +1,83 @@
1
+ /** Probe each CLI's native sandbox mechanism through the worker's transient unit. */
2
+ import { spawnSync } from 'node:child_process';
3
+ import { accessSync, constants, statSync } from 'node:fs';
4
+ import { fileURLToPath } from 'node:url';
5
+ import path from 'node:path';
6
+ import { buildWorkerIsolationArgs, proposeWorkerUnitName, resolveManagerEnvNames } from './worker-isolation.mjs';
7
+ import { buildSystemdClientEnv, resolveSystemdRun, unitEnvironmentNames } from './systemd-client.mjs';
8
+
9
+ const nativeSandboxProbes = new Map();
10
+ let sequence = 0;
11
+ export const NATIVE_SANDBOX_UNAVAILABLE = 'OG_NATIVE_SANDBOX_UNAVAILABLE';
12
+
13
+ /** Prefer the system bwrap allowed by Ubuntu's executable-specific AppArmor policy. */
14
+ export function resolveWorkerBwrap({ env = process.env, executable = candidate => {
15
+ try { accessSync(candidate, constants.X_OK); return statSync(candidate).isFile(); } catch { return false; }
16
+ } } = {}) {
17
+ for (const candidate of ['/usr/bin/bwrap', ...(env.PATH ?? '').split(path.delimiter)
18
+ .filter(directory => path.isAbsolute(directory)).map(directory => path.join(directory, 'bwrap'))]) {
19
+ if (executable(candidate)) return candidate;
20
+ }
21
+ throw new Error('bwrap is unavailable at /usr/bin/bwrap or on the worker PATH');
22
+ }
23
+
24
+ const bwrapProbe = options => ({ mechanism: 'bwrap', command: resolveWorkerBwrap(options),
25
+ args: ['--ro-bind', '/', '/', '--unshare-user', '--unshare-net', '--', '/usr/bin/true'] });
26
+
27
+ // Hooks are owned by launcher code, never loaded from project configuration.
28
+ export const workerNativeSandboxProbeHooks = { codex: bwrapProbe, muse: bwrapProbe };
29
+
30
+ // CLIs without their own hook keep a generic user/network namespace probe: bwrap when the host
31
+ // has it (allowed by Ubuntu's bwrap profile), else the previous unshare probe.
32
+ const genericProbe = options => {
33
+ try { return bwrapProbe(options); }
34
+ catch { return { mechanism: 'unshare', command: '/usr/bin/unshare', args: ['--user', '--map-root-user', '--net', '/usr/bin/true'] }; }
35
+ };
36
+
37
+ export function workerNeedsNativeNamespaces(adapter, permissions = {}, platform = process.platform) {
38
+ return platform === 'linux' && Object.hasOwn(workerNativeSandboxProbeHooks, adapter?.provider ?? '')
39
+ && (permissions.transport ?? 'shell') === 'shell'
40
+ && ['read-only', 'workspace-write'].includes(permissions.sandbox ?? 'workspace-write');
41
+ }
42
+
43
+ export function nativeSandboxFailure(text) {
44
+ return /loopback: Failed RTM_NEWADDR: Operation not permitted|uid_map[^\n]*Operation not permitted/i.test(text);
45
+ }
46
+
47
+ const bounded = detail => String(detail).replace(/[\x00-\x1f\x7f]/g, ' ').slice(0, 512);
48
+
49
+ export function nativeSandboxProbeMessage(result) {
50
+ return `${NATIVE_SANDBOX_UNAVAILABLE}: Native sandbox unavailable: ${bounded(result.mechanism ?? 'CLI sandbox')} probe failed inside a systemd user unit. Probe: ${bounded(result.detail ?? 'failed')}. Verify that the named sandbox executable is installed and can create its sandbox in the worker unit environment.`;
51
+ }
52
+
53
+ /** Hooks return { mechanism, command, args }; the unit and environment path stay shared. */
54
+ export function probeWorkerNativeSandbox({ adapter = { provider: 'codex' }, probeHooks = workerNativeSandboxProbeHooks,
55
+ spawn = spawnSync, env = process.env, envNames = ['PATH', 'HOME'], cwd,
56
+ cache = spawn === spawnSync ? nativeSandboxProbes : undefined } = {}) {
57
+ let mechanism = ['codex', 'muse'].includes(adapter.provider) ? 'bwrap' : `${adapter.provider} sandbox`;
58
+ let key;
59
+ let result;
60
+ try {
61
+ const hook = Object.hasOwn(probeHooks, adapter.provider) ? probeHooks[adapter.provider] : genericProbe;
62
+ const probe = hook({ env, cwd, adapter });
63
+ mechanism = probe.mechanism;
64
+ key = JSON.stringify([adapter.provider, probe, cwd, [...envNames].sort().map(name => [name, env[name]])]);
65
+ if (cache?.has(key)) return cache.get(key);
66
+ const managerEnvNames = resolveManagerEnvNames({ spawn });
67
+ const command = process.execPath;
68
+ const args = [fileURLToPath(new URL('./worker-isolation.mjs', import.meta.url)), '--worker-environment',
69
+ JSON.stringify(unitEnvironmentNames(envNames)), '--', probe.command, ...probe.args];
70
+ const argv = buildWorkerIsolationArgs({ unit: proposeWorkerUnitName(`sandbox-probe-${process.pid}-${++sequence}`),
71
+ command, args, cwd, env, envNames, managerEnvNames, memoryMaxGb: 0.25, attached: true,
72
+ extraProperties: ['RuntimeMaxSec=5s', 'TimeoutStopSec=1s'] });
73
+ const observed = spawn(resolveSystemdRun(), ['--collect', ...argv.slice(1)], {
74
+ encoding: 'utf8', timeout: 8000, maxBuffer: 4096, env: buildSystemdClientEnv({ env, envNames }) });
75
+ result = observed.status === 0 && !observed.error ? { ok: true, mode: 'systemd-unit', mechanism }
76
+ : { ok: false, mode: 'systemd-unit', mechanism, code: NATIVE_SANDBOX_UNAVAILABLE,
77
+ detail: bounded(observed.error?.message ?? (String(observed.stderr ?? '').trim() || `exit ${observed.status}`)) };
78
+ } catch (error) {
79
+ result = { ok: false, mode: 'systemd-unit', mechanism, code: NATIVE_SANDBOX_UNAVAILABLE, detail: bounded(error.message) };
80
+ }
81
+ if (key) cache?.set(key, result);
82
+ return result;
83
+ }
@@ -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
+ }