chati-dev 4.5.28 → 4.5.29
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/bin/chati.js +151 -123
- package/framework/agents/build/dev.md +4 -4
- package/framework/agents/deploy/devops.md +3 -3
- package/framework/agents/discover/brief.md +18 -6
- package/framework/agents/discover/brownfield-wu.md +3 -3
- package/framework/agents/discover/greenfield-wu.md +4 -4
- package/framework/agents/plan/architect.md +2 -2
- package/framework/agents/plan/detail.md +4 -4
- package/framework/agents/plan/phases.md +3 -3
- package/framework/agents/plan/tasks.md +47 -19
- package/framework/agents/plan/ux-brand-architect.md +1 -1
- package/framework/agents/plan/ux.md +26 -2
- package/framework/agents/quality/qa-implementation.md +8 -8
- package/framework/agents/quality/qa-planning.md +58 -4
- package/framework/agents/quality/qa-visual.md +4 -4
- package/framework/config.yaml +2 -2
- package/framework/constitution.md +12 -1
- package/framework/context/governance.md +2 -1
- package/framework/context/root.md +3 -1
- package/framework/domains/constitution.yaml +1 -1
- package/framework/domains/global.yaml +1 -1
- package/framework/domains/workflows/standard-flow.yaml +7 -8
- package/framework/hooks/advance-trigger.js +3 -2
- package/framework/hooks/brief-validator.js +1 -1
- package/framework/hooks/post-dev.js +1 -1
- package/framework/hooks/team-quality-gate.js +9 -2
- package/framework/i18n/en.yaml +1 -0
- package/framework/i18n/es.yaml +1 -0
- package/framework/i18n/fr.yaml +1 -0
- package/framework/i18n/pt.yaml +1 -0
- package/framework/manifest.json +83 -83
- package/framework/manifest.sig +1 -1
- package/framework/orchestrator/chati-router.js +14 -6
- package/framework/orchestrator/chati.md +61 -23
- package/framework/rules/human-writing-style.md +7 -0
- package/framework/schemas/session.schema.json +25 -2
- package/framework/schemas/task.schema.json +26 -0
- package/framework/tasks/orchestrator-handoff.md +10 -10
- package/framework/tasks/orchestrator-resume.md +3 -3
- package/framework/workflows/brownfield-fullstack.yaml +6 -3
- package/framework/workflows/brownfield-service.yaml +5 -3
- package/framework/workflows/brownfield-ui.yaml +5 -3
- package/framework/workflows/greenfield-fullstack.yaml +6 -3
- package/framework/workflows/standard-flow.yaml +8 -3
- package/node_modules/@chati/core/native/browser-proof-wire.h +29 -0
- package/node_modules/@chati/core/native/darwin-browser-listener.c +36 -0
- package/node_modules/@chati/core/native/darwin-browser-listener.h +32 -0
- package/node_modules/@chati/core/native/darwin-browser-proof.h +86 -0
- package/node_modules/@chati/core/native/darwin-code-identity.h +35 -0
- package/node_modules/@chati/core/native/darwin-launch-messages.h +37 -0
- package/node_modules/@chati/core/native/darwin-launcher.c +312 -0
- package/node_modules/@chati/core/native/descriptor-fs.c +407 -0
- package/node_modules/@chati/core/native/execution-admission.h +59 -0
- package/node_modules/@chati/core/native/execution-gate.c +41 -0
- package/node_modules/@chati/core/native/linux-process.c +41 -0
- package/node_modules/@chati/core/native/prebuilds/darwin-arm64/darwin-launcher +0 -0
- package/node_modules/@chati/core/native/prebuilds/darwin-arm64/descriptor-fs.node +0 -0
- package/node_modules/@chati/core/native/prebuilds/darwin-arm64/execution-gate +0 -0
- package/node_modules/@chati/core/native/prebuilds/linux-x64/descriptor-fs.node +0 -0
- package/node_modules/@chati/core/native/prebuilds/linux-x64/execution-gate +0 -0
- package/node_modules/@chati/core/native/process-info-pipe.c +91 -0
- package/node_modules/@chati/core/package.json +2 -1
- package/node_modules/@chati/core/scripts/build-native.js +36 -0
- package/node_modules/@chati/core/src/darwin-browser-proof.js +87 -0
- package/node_modules/@chati/core/src/darwin-host-boot.js +58 -0
- package/node_modules/@chati/core/src/darwin-process.js +79 -0
- package/node_modules/@chati/core/src/darwin-supervisor-channels.js +82 -0
- package/node_modules/@chati/core/src/darwin-terminal-receipt.js +111 -0
- package/node_modules/@chati/core/src/descriptor-fs.js +222 -0
- package/node_modules/@chati/core/src/execution-gate.js +8 -0
- package/node_modules/@chati/core/src/host-boot-marker.js +45 -0
- package/node_modules/@chati/core/src/index.js +18 -0
- package/node_modules/@chati/core/src/linux-host-boot.js +218 -0
- package/node_modules/@chati/core/src/linux-process.js +133 -0
- package/node_modules/@chati/planning/src/browser-verification-plan.js +71 -0
- package/node_modules/@chati/planning/src/index.js +33 -0
- package/node_modules/@chati/planning/src/planning-material.js +64 -0
- package/node_modules/@chati/planning/src/verification-plan.js +57 -0
- package/node_modules/@chati/rail/package.json +1 -0
- package/node_modules/@chati/rail/src/execution-restoration.js +92 -0
- package/node_modules/@chati/rail/src/index.js +812 -167
- package/node_modules/@chati/rail/src/provider-processing.js +118 -0
- package/node_modules/@chati/rail/src/terminal-containment.js +23 -0
- package/node_modules/@chati/release-lane/src/index.js +40 -43
- package/node_modules/@chati/review-council/src/index.js +223 -0
- package/node_modules/@chati/tracking-clickup/src/index.js +67 -58
- package/package-artifact-manifest.json +1 -1
- package/package-artifact-manifest.sig +1 -1
- package/package.json +3 -2
- package/scripts/verify-real-harness-e2e.js +20 -8
- package/src/installer/core.js +147 -107
- package/src/installer/package-artifact.js +7 -0
- package/src/installer/preflight.js +13 -0
- package/src/installer/provider-overlay.js +1 -2
- package/src/installer/templates.js +58 -0
- package/src/installer-v2/catalog-client.js +31 -1
- package/src/installer-v2/clickup-connection-storage.js +92 -0
- package/src/installer-v2/clickup-mcp-client.js +155 -0
- package/src/installer-v2/clickup-oauth-loopback.js +96 -0
- package/src/installer-v2/clickup-oauth.js +233 -0
- package/src/installer-v2/index.js +141 -15
- package/src/installer-v2/installation-authority.js +98 -4
- package/src/installer-v2/provider-executable.js +117 -12
- package/src/installer-v2/wizard-installation.js +11 -0
- package/src/orchestrator/agent-selector.js +39 -1
- package/src/orchestrator/browser-artifact-store.js +131 -0
- package/src/orchestrator/cli.js +762 -205
- package/src/orchestrator/clickup-mcp-observer.js +226 -0
- package/src/orchestrator/clickup-runtime.js +45 -24
- package/src/orchestrator/council-gate-runtime.js +903 -0
- package/src/orchestrator/evidence-authority-enrollment.js +75 -0
- package/src/orchestrator/evidence-authority-request.js +46 -0
- package/src/orchestrator/handoff-engine.js +4 -11
- package/src/orchestrator/pipeline-manager.js +42 -4
- package/src/orchestrator/planning-contract-revision.js +711 -0
- package/src/orchestrator/planning-reconciliation.js +302 -0
- package/src/orchestrator/planning-runtime.js +476 -29
- package/src/orchestrator/rail-adjudication-evidence.js +15 -24
- package/src/orchestrator/rail-human-effort-authority.js +46 -0
- package/src/orchestrator/rail-human-effort.js +149 -0
- package/src/orchestrator/rail-runtime.js +291 -47
- package/src/orchestrator/rail-verification.js +278 -0
- package/src/orchestrator/review-runtime.js +37 -2
- package/src/orchestrator/runtime-installation-v2.js +143 -24
- package/src/orchestrator/session-manager.js +1457 -29
- package/src/tasks/handoff.js +57 -33
- package/src/terminal/adapters/claude-adapter.js +3 -0
- package/src/terminal/adapters/codex-adapter.js +13 -0
- package/src/terminal/adapters/grok-adapter.js +1 -0
- package/src/terminal/agent-attempt-budget.js +35 -0
- package/src/terminal/browser-endpoint-proxy.js +174 -0
- package/src/terminal/browser-observer-driver.js +94 -0
- package/src/terminal/browser-observer-supervisor.js +73 -0
- package/src/terminal/browser-observer.js +95 -0
- package/src/terminal/browser-process-facts.js +75 -0
- package/src/terminal/browser-runtime.js +131 -0
- package/src/terminal/darwin-authority-policy.js +213 -0
- package/src/terminal/darwin-browser-application.js +116 -0
- package/src/terminal/darwin-browser-observer.js +57 -0
- package/src/terminal/darwin-execution.js +185 -0
- package/src/terminal/executable-snapshot.js +202 -0
- package/src/terminal/handoff-parser.js +48 -2
- package/src/terminal/isolation.js +14 -13
- package/src/terminal/prompt-builder.js +176 -15
- package/src/terminal/provider-preflight.js +47 -27
- package/src/terminal/rail-execution-worktree.js +22 -6
- package/src/terminal/rail-process-observer.js +10 -0
- package/src/terminal/rail-prompts.js +21 -3
- package/src/terminal/rail-readonly-workspace.js +54 -5
- package/src/terminal/rail-worktree-checkpoint.js +315 -0
- package/src/terminal/rail-worktree-restoration.js +485 -0
- package/src/terminal/run-agent.js +164 -42
- package/src/terminal/run-council-gate.js +338 -0
- package/src/terminal/run-planning-reconciliation.js +49 -0
- package/src/terminal/run-rail-adjudication.js +29 -7
- package/src/terminal/run-rail-recovery.js +97 -0
- package/src/terminal/run-rail-review.js +36 -15
- package/src/terminal/run-rail-rework.js +26 -15
- package/src/terminal/run-rail-task.js +22 -9
- package/src/terminal/runtime-native-helper.js +85 -0
- package/src/terminal/spawner.js +899 -142
- package/src/terminal/verification-process-facts.js +49 -0
- package/src/upgrade/entrypoints.js +68 -0
- package/src/upgrade/generation.js +194 -0
- package/src/upgrade/maintenance.js +79 -0
- package/src/upgrade/paths.js +81 -0
- package/src/upgrade/upgrade.js +267 -0
- package/src/utils/execution-role.js +15 -0
- package/src/utils/handoff-paths.js +59 -0
- package/src/wizard/i18n.js +1 -0
- package/src/wizard/index.js +17 -5
- package/src/wizard/questions.js +24 -12
package/src/terminal/spawner.js
CHANGED
|
@@ -8,21 +8,30 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import { spawn, spawnSync } from 'child_process';
|
|
11
|
-
import { createHash } from 'node:crypto';
|
|
11
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
12
12
|
import { EventEmitter } from 'node:events';
|
|
13
13
|
import {
|
|
14
|
-
closeSync, constants, fstatSync, fsyncSync, lstatSync, mkdirSync, mkdtempSync, openSync,
|
|
14
|
+
closeSync, constants, existsSync, fstatSync, fsyncSync, lstatSync, mkdirSync, mkdtempSync, openSync,
|
|
15
15
|
readFileSync, readSync, realpathSync, rmSync, writeSync,
|
|
16
16
|
} from 'node:fs';
|
|
17
17
|
import { homedir, hostname, tmpdir } from 'node:os';
|
|
18
18
|
import { basename, delimiter, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
19
|
-
import { canonicalize, sha256
|
|
19
|
+
import { canonicalize, sha256, directoryPathSync, openAtSync, mkdirAtSync, lstatAtSync, nativeExecutionGatePath,
|
|
20
|
+
createLinuxProcessInfoPipe, readLinuxProcessInfoPipe, captureLinuxNamespaceIdentity, observeLinuxNamespace } from '@chati/core';
|
|
21
|
+
import { captureDarwinHostBootIdentity, createDarwinTerminalLaunchBinding, assertDarwinTerminalLaunch, assertDarwinTerminalCompletion } from '@chati/core';
|
|
20
22
|
import { validateWriteScopes, buildIsolationEnv } from './isolation.js';
|
|
21
23
|
import { getProvider } from './cli-registry.js';
|
|
22
24
|
import { getRateLimiter } from './rate-limiter.js';
|
|
23
25
|
import { recordModelSelection } from '../orchestrator/session-manager.js';
|
|
26
|
+
import {
|
|
27
|
+
assertDarwinSealedLaunch, disposeDarwinSealedLaunch, prepareDarwinSealedLaunch, prepareDarwinAuthorityLaunch,
|
|
28
|
+
prepareDarwinBrowserObserverLaunch,
|
|
29
|
+
} from './darwin-execution.js';
|
|
30
|
+
import { PROVIDER_CONTROL_PLANE_DIRECTORIES } from './darwin-authority-policy.js';
|
|
31
|
+
import { createDarwinVerificationBinding, projectVerificationExecutableBinding, verificationProcessCompleted } from './verification-process-facts.js';
|
|
24
32
|
import {
|
|
25
33
|
digestProviderExecutable, openPinnedProviderExecutable, openPinnedProviderSupportingExecutables,
|
|
34
|
+
sealedProviderRuntimeEnvironment, validateProviderExecutableBinding,
|
|
26
35
|
} from '../installer-v2/provider-executable.js';
|
|
27
36
|
import {
|
|
28
37
|
installationAuthorityStateDirectory, signHostAuthorityPayload, verifyHostAuthorityPayload,
|
|
@@ -59,17 +68,18 @@ const OVERLOAD_PATTERNS = [/529/, /overloaded/i, /capacity/i, /too many requests
|
|
|
59
68
|
|
|
60
69
|
let _counter = 0;
|
|
61
70
|
const TERMINAL_AUTHORITY_STATE = new WeakMap();
|
|
71
|
+
// Diagnostic observations are not receipts, cleanup proof or execution authority.
|
|
72
|
+
const TERMINAL_DIAGNOSTIC_OBSERVATIONS = new WeakMap();
|
|
73
|
+
const TERMINAL_ATTEMPT_DIAGNOSTICS = new WeakMap();
|
|
62
74
|
const TERMINAL_AUTHORITY_DOMAIN = 'chati-terminal-receipt-v1';
|
|
63
75
|
const TERMINAL_SECURITY_TEST_HOOKS = Symbol.for('chati.terminal.security-test-hooks');
|
|
64
76
|
const READ_ONLY_PROVIDERS = new Set(['claude', 'codex', 'grok']);
|
|
65
77
|
const BUBBLEWRAP_PATH = '/usr/bin/bwrap';
|
|
66
|
-
const PROVIDER_CONTROL_PLANE_DIRECTORIES = Object.freeze([
|
|
67
|
-
'.chati', '.chati.dev', '.claude', '.codex', '.grok', '.agents',
|
|
68
|
-
]);
|
|
69
78
|
const PROCESS_TREE_SETTLE_WAIT = new Int32Array(new SharedArrayBuffer(4));
|
|
70
79
|
const PRIVATE_PROMPT_MARKER = '<chati-private-prompt-file>';
|
|
71
80
|
const CONTAINED_PROMPT_PATH = '/run/user/chati-private-prompt';
|
|
72
81
|
const CONTAINED_PROVIDER_EXECUTABLE_PATH = '/run/user/chati-provider-executable';
|
|
82
|
+
const CONTAINED_EXECUTION_GATE_PATH = '/run/user/chati-execution-gate';
|
|
73
83
|
const AUTHORITY_SYSTEM_PATH = '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin';
|
|
74
84
|
const TEAM_CONTEXT_ENV_KEYS = Object.freeze([
|
|
75
85
|
'CHATI_TEAM_ID', 'CHATI_TEAM_MEMBER', 'CHATI_TEAM_TASK_LIST', 'CHATI_TEAM_MAILBOX',
|
|
@@ -80,6 +90,40 @@ function runTerminalSecurityTestHook(name, payload) {
|
|
|
80
90
|
if (hooks && typeof hooks[name] === 'function') hooks[name](payload);
|
|
81
91
|
}
|
|
82
92
|
|
|
93
|
+
function terminalAttemptSnapshot(handle, attempt) {
|
|
94
|
+
const observation = TERMINAL_DIAGNOSTIC_OBSERVATIONS.get(handle);
|
|
95
|
+
if (!observation) return null;
|
|
96
|
+
return Object.freeze({
|
|
97
|
+
attempt,
|
|
98
|
+
timeout_ms: Number.isSafeInteger(observation.timeout) && observation.timeout > 0 ? observation.timeout : null,
|
|
99
|
+
elapsed_ms: Number.isFinite(observation.elapsedMs) && observation.elapsedMs >= 0 ? Math.floor(observation.elapsedMs) : null,
|
|
100
|
+
exit_code: Number.isInteger(observation.exitCode) ? observation.exitCode : null,
|
|
101
|
+
timed_out: observation.timedOut === true,
|
|
102
|
+
termination_cause: observation.terminationCause,
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function projectAttemptDiagnostics(attempts, exceptionStage = null) {
|
|
107
|
+
return Object.freeze({ schema_version: 1,
|
|
108
|
+
attempts: Object.freeze(attempts.map(row => Object.freeze({ ...row }))),
|
|
109
|
+
exception_stage: exceptionStage });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function rememberAttemptDiagnostics(target, attempts, exceptionStage = null) {
|
|
113
|
+
if (target !== null && (typeof target === 'object' || typeof target === 'function')) {
|
|
114
|
+
TERMINAL_ATTEMPT_DIAGNOSTICS.set(target, projectAttemptDiagnostics(attempts, exceptionStage));
|
|
115
|
+
}
|
|
116
|
+
return target;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Closed, content-free observations only. Reading this never signs, waits or kills. */
|
|
120
|
+
export function getTerminalAttemptDiagnostics(handleOrError) {
|
|
121
|
+
const remembered = TERMINAL_ATTEMPT_DIAGNOSTICS.get(handleOrError);
|
|
122
|
+
if (remembered) return projectAttemptDiagnostics(remembered.attempts, remembered.exception_stage);
|
|
123
|
+
const observation = terminalAttemptSnapshot(handleOrError, 0);
|
|
124
|
+
return observation ? projectAttemptDiagnostics([observation]) : null;
|
|
125
|
+
}
|
|
126
|
+
|
|
83
127
|
function openPrivatePromptFile(prompt) {
|
|
84
128
|
const directory = mkdtempSync(join(tmpdir(), 'chati-terminal-prompt-'));
|
|
85
129
|
const path = join(directory, 'prompt');
|
|
@@ -189,6 +233,24 @@ function digestPinnedExecutable(descriptor, expected = null) {
|
|
|
189
233
|
return Object.freeze({ digest, snapshot: after });
|
|
190
234
|
}
|
|
191
235
|
|
|
236
|
+
// Internal descriptor boundary, exported for deterministic race regressions on
|
|
237
|
+
// disposable images. Runtime callers cannot select a different helper path.
|
|
238
|
+
export function _pinNativeExecutionGateDescriptor(descriptor) {
|
|
239
|
+
const stat = fstatSync(descriptor);
|
|
240
|
+
const magic = Buffer.alloc(4); readSync(descriptor, magic, 0, 4, 0);
|
|
241
|
+
if (!stat.isFile() || stat.nlink !== 1 || (stat.mode & 0o022) !== 0
|
|
242
|
+
|| (stat.mode & 0o111) === 0 || ![0, process.geteuid()].includes(stat.uid)
|
|
243
|
+
|| magic.toString('hex') !== '7f454c46') {
|
|
244
|
+
return failContainment('execution admission requires a private pinned native helper');
|
|
245
|
+
}
|
|
246
|
+
runTerminalSecurityTestHook('afterExecutionGateMagicValidation', { descriptor });
|
|
247
|
+
const executable = digestPinnedExecutable(descriptor);
|
|
248
|
+
if (!sameExecutableSnapshot(executableSnapshot(stat), executable.snapshot)) {
|
|
249
|
+
return failContainment('execution admission helper changed between format validation and hashing');
|
|
250
|
+
}
|
|
251
|
+
return executable;
|
|
252
|
+
}
|
|
253
|
+
|
|
192
254
|
function currentBubblewrapTrust(descriptor) {
|
|
193
255
|
const trustedDirectories = ['/', '/usr', '/usr/bin'].map((path) => ({ path, stat: lstatSync(path) }));
|
|
194
256
|
const pathStat = lstatSync(BUBBLEWRAP_PATH);
|
|
@@ -258,8 +320,8 @@ function closeDirectoryChain(chain) {
|
|
|
258
320
|
}
|
|
259
321
|
|
|
260
322
|
function openPinnedDirectoryChain(path) {
|
|
261
|
-
if (process.platform
|
|
262
|
-
return failContainment(
|
|
323
|
+
if (!['linux', 'darwin'].includes(process.platform)) {
|
|
324
|
+
return failContainment(`authority-bearing RAIL processes require descriptor-pinned project storage; unsupported platform: ${process.platform}`);
|
|
263
325
|
}
|
|
264
326
|
const absolute = resolve(path);
|
|
265
327
|
const descriptors = [];
|
|
@@ -270,18 +332,18 @@ function openPinnedDirectoryChain(path) {
|
|
|
270
332
|
descriptors.push(descriptor);
|
|
271
333
|
snapshots.push(directorySnapshot(fstatSync(descriptor)));
|
|
272
334
|
for (const segment of absolute.slice(1).split(sep).filter(Boolean)) {
|
|
273
|
-
descriptor =
|
|
274
|
-
|
|
335
|
+
descriptor = openAtSync(
|
|
336
|
+
descriptor, segment,
|
|
275
337
|
constants.O_RDONLY | (constants.O_DIRECTORY || 0) | (constants.O_NOFOLLOW || 0),
|
|
276
338
|
);
|
|
339
|
+
descriptors.push(descriptor);
|
|
277
340
|
const stat = fstatSync(descriptor);
|
|
278
341
|
if (!stat.isDirectory()) return failContainment('authority project chain must contain only real directories');
|
|
279
|
-
descriptors.push(descriptor);
|
|
280
342
|
snapshots.push(directorySnapshot(stat));
|
|
281
343
|
}
|
|
282
344
|
return Object.freeze({
|
|
283
345
|
path: absolute,
|
|
284
|
-
reference: `/proc/self/fd/${descriptors.at(-1)}
|
|
346
|
+
reference: process.platform === 'linux' ? `/proc/self/fd/${descriptors.at(-1)}` : directoryPathSync(descriptors.at(-1)),
|
|
285
347
|
descriptors: Object.freeze(descriptors),
|
|
286
348
|
snapshots: Object.freeze(snapshots),
|
|
287
349
|
identity_digest: sha256({ path: absolute, snapshots }),
|
|
@@ -312,18 +374,17 @@ function assertPinnedDirectoryChain(chain) {
|
|
|
312
374
|
function ensureScopedDirectory(projectChain, relativeDirectory) {
|
|
313
375
|
const descriptors = [];
|
|
314
376
|
const snapshots = [];
|
|
315
|
-
let
|
|
377
|
+
let parentDescriptor = projectChain.descriptors.at(-1);
|
|
316
378
|
try {
|
|
317
379
|
for (const segment of relativeDirectory.split(sep).filter(Boolean)) {
|
|
318
|
-
|
|
319
|
-
try { mkdirSync(target, { mode: 0o700 }); }
|
|
380
|
+
try { mkdirAtSync(parentDescriptor, segment, { mode: 0o700 }); }
|
|
320
381
|
catch (error) { if (error?.code !== 'EEXIST') throw error; }
|
|
321
|
-
const pathStat =
|
|
382
|
+
const pathStat = lstatAtSync(parentDescriptor, segment);
|
|
322
383
|
if (pathStat.isSymbolicLink() || !pathStat.isDirectory()) {
|
|
323
384
|
return failContainment('Planning write scope ancestors must be real directories');
|
|
324
385
|
}
|
|
325
|
-
const descriptor =
|
|
326
|
-
|
|
386
|
+
const descriptor = openAtSync(
|
|
387
|
+
parentDescriptor, segment,
|
|
327
388
|
constants.O_RDONLY | (constants.O_DIRECTORY || 0) | (constants.O_NOFOLLOW || 0),
|
|
328
389
|
);
|
|
329
390
|
const pinned = fstatSync(descriptor);
|
|
@@ -333,9 +394,9 @@ function ensureScopedDirectory(projectChain, relativeDirectory) {
|
|
|
333
394
|
}
|
|
334
395
|
descriptors.push(descriptor);
|
|
335
396
|
snapshots.push(directorySnapshot(pinned));
|
|
336
|
-
|
|
397
|
+
parentDescriptor = descriptor;
|
|
337
398
|
}
|
|
338
|
-
return Object.freeze({ descriptors, snapshots,
|
|
399
|
+
return Object.freeze({ descriptors, snapshots, directoryDescriptor: parentDescriptor });
|
|
339
400
|
} catch (error) {
|
|
340
401
|
descriptors.reverse().forEach((descriptor) => { try { closeSync(descriptor); } catch { /* already closed */ } });
|
|
341
402
|
if (error?.code === 'TERMINAL_CONTAINMENT_UNAVAILABLE') throw error;
|
|
@@ -376,9 +437,8 @@ function preparePinnedWriteScopes(projectChain, writeScope = []) {
|
|
|
376
437
|
targetSnapshot = parent.snapshots.at(-1);
|
|
377
438
|
} else {
|
|
378
439
|
const name = rel.split(sep).at(-1);
|
|
379
|
-
const
|
|
380
|
-
|
|
381
|
-
targetDescriptor = openSync(target, pathFlags, 0o600);
|
|
440
|
+
const pathFlags = constants.O_RDWR | constants.O_CREAT | (constants.O_NOFOLLOW || 0) | (constants.O_NONBLOCK || 0);
|
|
441
|
+
targetDescriptor = openAtSync(parent.directoryDescriptor, name, pathFlags, 0o600);
|
|
382
442
|
const stat = fstatSync(targetDescriptor);
|
|
383
443
|
if (!stat.isFile() || stat.nlink !== 1) {
|
|
384
444
|
closeSync(targetDescriptor);
|
|
@@ -444,31 +504,134 @@ function hiddenInstallationAuthorityArgs() {
|
|
|
444
504
|
}
|
|
445
505
|
}
|
|
446
506
|
|
|
507
|
+
function buildDarwinAuthorityContainment({
|
|
508
|
+
args, cwd, readOnly, writeScope, terminalId, provider, providerExecutableBinding,
|
|
509
|
+
promptRecord, maskedReadPaths, councilToolIsolation, authorityContext,
|
|
510
|
+
verificationIsolation = false,
|
|
511
|
+
}) {
|
|
512
|
+
if (providerExecutableBinding === undefined) {
|
|
513
|
+
throw Object.assign(new Error('authority-bearing provider launch requires an executable binding sealed by the installation'), { code: 'PROVIDER_EXECUTABLE_BINDING_REQUIRED' });
|
|
514
|
+
}
|
|
515
|
+
const binding = validateProviderExecutableBinding(providerExecutableBinding, { harnessId: provider });
|
|
516
|
+
const project = realpathSync(cwd);
|
|
517
|
+
let projectChain;
|
|
518
|
+
let stateChain;
|
|
519
|
+
let pinnedWriteScopes;
|
|
520
|
+
let darwinLaunch;
|
|
521
|
+
const maskedChains = [];
|
|
522
|
+
const controlPlaneChains = [];
|
|
523
|
+
try {
|
|
524
|
+
projectChain = openPinnedDirectoryChain(project);
|
|
525
|
+
if (!Array.isArray(maskedReadPaths)) return failContainment('masked read paths must be an array');
|
|
526
|
+
for (const requested of [...new Set(maskedReadPaths)]) {
|
|
527
|
+
const physical = realpathSync(requested);
|
|
528
|
+
if (pathContains(physical, project) || pathContains(project, physical)) {
|
|
529
|
+
return failContainment('masked read path must not overlap the authority working directory');
|
|
530
|
+
}
|
|
531
|
+
maskedChains.push(openPinnedDirectoryChain(physical));
|
|
532
|
+
}
|
|
533
|
+
for (const name of PROVIDER_CONTROL_PLANE_DIRECTORIES) {
|
|
534
|
+
let stat;
|
|
535
|
+
try { stat = lstatAtSync(projectChain.descriptors.at(-1), name); }
|
|
536
|
+
catch (error) { if (error.code === 'ENOENT') continue; throw error; }
|
|
537
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) return failContainment(`${name} control plane must be a real directory`);
|
|
538
|
+
controlPlaneChains.push(openPinnedDirectoryChain(join(project, name)));
|
|
539
|
+
}
|
|
540
|
+
pinnedWriteScopes = preparePinnedWriteScopes(projectChain, readOnly ? [] : writeScope);
|
|
541
|
+
let stateDirectory;
|
|
542
|
+
if (!verificationIsolation) {
|
|
543
|
+
stateDirectory = join(realpathSync(homedir()), provider === 'claude' ? '.claude' : provider === 'codex' ? '.codex' : '.grok');
|
|
544
|
+
try { mkdirSync(stateDirectory, { mode: 0o700 }); }
|
|
545
|
+
catch (error) { if (error.code !== 'EEXIST') throw error; }
|
|
546
|
+
// Descriptor traversal rejects a provider state symlink, including ancestors.
|
|
547
|
+
stateChain = openPinnedDirectoryChain(stateDirectory);
|
|
548
|
+
}
|
|
549
|
+
darwinLaunch = prepareDarwinAuthorityLaunch({
|
|
550
|
+
binding, harnessId: provider, projectDir: project, args, hasPrompt: Boolean(promptRecord),
|
|
551
|
+
promptDirectory: promptRecord?.directory, authorityContext, stateDirectory,
|
|
552
|
+
writeScopes: pinnedWriteScopes, maskedReadPaths: maskedChains.map(chain => chain.path), councilToolIsolation, verificationIsolation,
|
|
553
|
+
subjectReadOnly: verificationIsolation && readOnly === true,
|
|
554
|
+
});
|
|
555
|
+
const containment = Object.freeze({
|
|
556
|
+
kind: darwinLaunch.kind, id: `darwin:${terminalId}`,
|
|
557
|
+
command: darwinLaunch.command, args: darwinLaunch.args, darwinLaunch,
|
|
558
|
+
pinnedProjectChain: projectChain, pinnedStateChain: stateChain,
|
|
559
|
+
controlPlaneChains: Object.freeze(controlPlaneChains), maskedReadChains: Object.freeze(maskedChains),
|
|
560
|
+
pinnedWriteScopes, projectReference: projectChain.reference, projectIdentityDigest: projectChain.identity_digest,
|
|
561
|
+
providerExecutableBinding: binding,
|
|
562
|
+
digest: sha256({
|
|
563
|
+
kind: darwinLaunch.kind, helper_sha256: darwinLaunch.helperSha256, policy_digest: darwinLaunch.policyDigest,
|
|
564
|
+
provider_executable: binding, native_nonce: darwinLaunch.supervisorChannels.nonce,
|
|
565
|
+
project_identity_digest: projectChain.identity_digest,
|
|
566
|
+
state_identity_digest: stateChain?.identity_digest ?? null,
|
|
567
|
+
masked_identity_digests: maskedChains.map(chain => chain.identity_digest),
|
|
568
|
+
}),
|
|
569
|
+
});
|
|
570
|
+
assertAuthorityContainmentStable(containment);
|
|
571
|
+
return containment;
|
|
572
|
+
} catch (error) {
|
|
573
|
+
closeAuthorityContainment({ pinnedProjectChain: projectChain, pinnedStateChain: stateChain,
|
|
574
|
+
pinnedWriteScopes, controlPlaneChains, maskedReadChains: maskedChains });
|
|
575
|
+
if (darwinLaunch) try { disposeDarwinSealedLaunch(darwinLaunch); } catch (cleanupError) { error.darwinCleanupError = cleanupError; }
|
|
576
|
+
throw error;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
447
580
|
function buildAuthorityContainment({
|
|
448
581
|
args, cwd, readOnly, writeScope, terminalId, provider, providerExecutableBinding,
|
|
449
|
-
promptRecord = null, maskedReadPaths = [],
|
|
582
|
+
promptRecord = null, maskedReadPaths = [], councilToolIsolation = false, verificationIsolation = false,
|
|
583
|
+
observerDirectory = null,
|
|
584
|
+
executionAdmission = false,
|
|
585
|
+
authorityContext,
|
|
450
586
|
}) {
|
|
587
|
+
if (process.platform === 'darwin') {
|
|
588
|
+
if (observerDirectory !== null) return failContainment('Darwin browser verification isolation is not implemented');
|
|
589
|
+
return buildDarwinAuthorityContainment({ args, cwd, readOnly, writeScope, terminalId, provider,
|
|
590
|
+
providerExecutableBinding, promptRecord, maskedReadPaths, councilToolIsolation, authorityContext, verificationIsolation });
|
|
591
|
+
}
|
|
451
592
|
const trustedBubblewrap = requireBubblewrapContainment();
|
|
452
593
|
if (providerExecutableBinding === undefined) {
|
|
453
594
|
closeSync(trustedBubblewrap.descriptor);
|
|
454
595
|
throw Object.assign(new Error('authority-bearing provider launch requires an executable binding sealed by the installation'), { code: 'PROVIDER_EXECUTABLE_BINDING_REQUIRED' });
|
|
455
596
|
}
|
|
456
597
|
let projectChain;
|
|
598
|
+
let observerChain;
|
|
457
599
|
const maskedChains = [];
|
|
458
600
|
let trustedProvider;
|
|
459
601
|
let trustedSupportingProviders = [];
|
|
602
|
+
let admissionHelper;
|
|
460
603
|
let pinnedWriteScopes;
|
|
461
|
-
const stateDirectory =
|
|
604
|
+
const stateDirectory = verificationIsolation ? '/run/user/chati-verification-home'
|
|
605
|
+
: join(homedir(), provider === 'claude' ? '.claude' : provider === 'codex' ? '.codex' : '.grok');
|
|
462
606
|
let stateDirectoryArgs;
|
|
463
607
|
let stateDirectoryDescriptor;
|
|
464
608
|
const controlPlaneDescriptors = [];
|
|
465
609
|
const controlPlaneArgs = [];
|
|
466
610
|
try {
|
|
467
|
-
|
|
611
|
+
if (executionAdmission) {
|
|
612
|
+
const descriptor = openSync(nativeExecutionGatePath(), constants.O_RDONLY | constants.O_NOFOLLOW);
|
|
613
|
+
admissionHelper = { descriptor };
|
|
614
|
+
admissionHelper.executable = _pinNativeExecutionGateDescriptor(descriptor);
|
|
615
|
+
}
|
|
616
|
+
trustedProvider = openPinnedProviderExecutable(providerExecutableBinding, {
|
|
617
|
+
harnessId: provider, projectDir: cwd, requireNativeImage: verificationIsolation,
|
|
618
|
+
});
|
|
468
619
|
trustedSupportingProviders = openPinnedProviderSupportingExecutables(
|
|
469
620
|
providerExecutableBinding, { harnessId: provider, projectDir: cwd },
|
|
470
621
|
);
|
|
471
622
|
projectChain = openPinnedDirectoryChain(cwd);
|
|
623
|
+
if (observerDirectory !== null) {
|
|
624
|
+
if (!verificationIsolation) return failContainment('observer tools require verification isolation');
|
|
625
|
+
const physical = realpathSync(observerDirectory);
|
|
626
|
+
const stat = lstatSync(physical);
|
|
627
|
+
if (pathContains(physical, cwd) || pathContains(cwd, physical)
|
|
628
|
+
|| !stat.isDirectory() || stat.uid !== process.geteuid() || (stat.mode & 0o777) !== 0o700
|
|
629
|
+
|| pathContains(physical, installationAuthorityStateDirectory())
|
|
630
|
+
|| pathContains(installationAuthorityStateDirectory(), physical)) {
|
|
631
|
+
return failContainment('observer tools must be a separate private directory without authority keys');
|
|
632
|
+
}
|
|
633
|
+
observerChain = openPinnedDirectoryChain(physical);
|
|
634
|
+
}
|
|
472
635
|
if (!Array.isArray(maskedReadPaths)) return failContainment('masked read paths must be an array');
|
|
473
636
|
for (const requested of [...new Set(maskedReadPaths.map((entry) => resolve(String(entry))))]) {
|
|
474
637
|
const physical = realpathSync(requested);
|
|
@@ -479,22 +642,27 @@ function buildAuthorityContainment({
|
|
|
479
642
|
}
|
|
480
643
|
pinnedWriteScopes = preparePinnedWriteScopes(projectChain, readOnly ? [] : writeScope);
|
|
481
644
|
let stateStat;
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
stateStat = lstatSync(stateDirectory);
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
645
|
+
if (verificationIsolation) {
|
|
646
|
+
// Retain descriptor numbering without exposing any host provider state.
|
|
647
|
+
stateDirectoryDescriptor = openSync('/dev/null', constants.O_RDONLY);
|
|
648
|
+
} else {
|
|
649
|
+
try { stateStat = lstatSync(stateDirectory); }
|
|
650
|
+
catch (error) {
|
|
651
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
652
|
+
mkdirSync(stateDirectory, { mode: 0o700 });
|
|
653
|
+
stateStat = lstatSync(stateDirectory);
|
|
654
|
+
}
|
|
655
|
+
if (!stateStat.isDirectory()) {
|
|
656
|
+
throw Object.assign(new Error('provider state path must be a real directory'), { code: 'TERMINAL_CONTAINMENT_UNAVAILABLE' });
|
|
657
|
+
}
|
|
658
|
+
stateDirectoryDescriptor = openSync(stateDirectory, constants.O_RDONLY | (constants.O_DIRECTORY || 0) | (constants.O_NOFOLLOW || 0));
|
|
659
|
+
const descriptorStat = fstatSync(stateDirectoryDescriptor);
|
|
660
|
+
if (descriptorStat.dev !== stateStat.dev || descriptorStat.ino !== stateStat.ino) {
|
|
661
|
+
throw Object.assign(new Error('provider state directory changed before containment'), { code: 'TERMINAL_CONTAINMENT_UNAVAILABLE' });
|
|
662
|
+
}
|
|
495
663
|
}
|
|
496
|
-
const physicalProject =
|
|
497
|
-
const physicalState = realpathSync(stateDirectory);
|
|
664
|
+
const physicalProject = directoryPathSync(projectChain.descriptors.at(-1));
|
|
665
|
+
const physicalState = verificationIsolation ? stateDirectory : realpathSync(stateDirectory);
|
|
498
666
|
if (pathContains(physicalProject, physicalState) || pathContains(physicalState, physicalProject)) {
|
|
499
667
|
throw Object.assign(new Error('provider state and project directories must not physically overlap inside authority containment'), { code: 'TERMINAL_CONTAINMENT_UNAVAILABLE' });
|
|
500
668
|
}
|
|
@@ -515,23 +683,22 @@ function buildAuthorityContainment({
|
|
|
515
683
|
throw Object.assign(new Error('masked read path overlaps provider state or executable authority'), { code: 'TERMINAL_CONTAINMENT_UNAVAILABLE' });
|
|
516
684
|
}
|
|
517
685
|
}
|
|
518
|
-
stateDirectoryArgs = ['--bind-fd', '4', stateDirectory];
|
|
686
|
+
stateDirectoryArgs = verificationIsolation ? ['--dir', stateDirectory] : ['--bind-fd', '4', stateDirectory];
|
|
519
687
|
// Provider-specific configuration is control-plane material too. A RAIL
|
|
520
688
|
// execution may write its scoped product files, but cannot rewrite a
|
|
521
689
|
// harness hook, skill, rule or config and then rely on that change in the
|
|
522
690
|
// same contained process.
|
|
523
691
|
for (const name of PROVIDER_CONTROL_PLANE_DIRECTORIES) {
|
|
524
|
-
const target = join(projectChain.reference, name);
|
|
525
692
|
let targetStat;
|
|
526
|
-
try { targetStat =
|
|
693
|
+
try { targetStat = lstatAtSync(projectChain.descriptors.at(-1), name); }
|
|
527
694
|
catch (error) { if (error?.code === 'ENOENT') continue; throw error; }
|
|
528
695
|
if (targetStat.isSymbolicLink() || !targetStat.isDirectory()) {
|
|
529
696
|
throw Object.assign(new Error(`${name} control plane must be a real directory`), { code: 'TERMINAL_CONTAINMENT_UNAVAILABLE' });
|
|
530
697
|
}
|
|
531
|
-
const descriptor =
|
|
698
|
+
const descriptor = openAtSync(projectChain.descriptors.at(-1), name, constants.O_RDONLY | (constants.O_DIRECTORY || 0) | (constants.O_NOFOLLOW || 0));
|
|
532
699
|
const pinned = fstatSync(descriptor);
|
|
533
700
|
if (pinned.dev !== targetStat.dev || pinned.ino !== targetStat.ino
|
|
534
|
-
|| !pathContains(physicalProject,
|
|
701
|
+
|| !pathContains(physicalProject, directoryPathSync(descriptor))) {
|
|
535
702
|
closeSync(descriptor);
|
|
536
703
|
throw Object.assign(new Error(`${name} control plane changed before containment`), { code: 'TERMINAL_CONTAINMENT_UNAVAILABLE' });
|
|
537
704
|
}
|
|
@@ -540,6 +707,7 @@ function buildAuthorityContainment({
|
|
|
540
707
|
controlPlaneArgs.push('--ro-bind-fd', String(descriptorNumber), join(cwd, name));
|
|
541
708
|
}
|
|
542
709
|
} catch (error) {
|
|
710
|
+
if (admissionHelper) closeSync(admissionHelper.descriptor);
|
|
543
711
|
controlPlaneDescriptors.forEach((descriptor) => closeSync(descriptor));
|
|
544
712
|
if (stateDirectoryDescriptor !== undefined) closeSync(stateDirectoryDescriptor);
|
|
545
713
|
if (trustedProvider?.descriptor !== undefined) closeSync(trustedProvider.descriptor);
|
|
@@ -547,6 +715,7 @@ function buildAuthorityContainment({
|
|
|
547
715
|
for (const descriptor of pinnedWriteScopes?.descriptors ?? []) try { closeSync(descriptor); } catch { /* already closed */ }
|
|
548
716
|
for (const chain of maskedChains) closeDirectoryChain(chain);
|
|
549
717
|
closeDirectoryChain(projectChain);
|
|
718
|
+
closeDirectoryChain(observerChain);
|
|
550
719
|
closeSync(trustedBubblewrap.descriptor);
|
|
551
720
|
if (error?.code === 'TERMINAL_CONTAINMENT_UNAVAILABLE' || error?.code?.startsWith('PROVIDER_EXECUTABLE_')) throw error;
|
|
552
721
|
throw Object.assign(new Error(`cannot prepare provider state directory: ${error.message}`), { code: 'TERMINAL_CONTAINMENT_UNAVAILABLE' });
|
|
@@ -558,6 +727,9 @@ function buildAuthorityContainment({
|
|
|
558
727
|
const promptDescriptorNumber = 6 + controlPlaneDescriptors.length + writableScopeDescriptors.length;
|
|
559
728
|
const providerDescriptorNumber = promptDescriptorNumber + (promptRecord ? 1 : 0);
|
|
560
729
|
const supportingProviderDescriptorNumber = providerDescriptorNumber + 1;
|
|
730
|
+
const observerDescriptorNumber = supportingProviderDescriptorNumber + trustedSupportingProviders.length;
|
|
731
|
+
const admissionHelperDescriptorNumber = observerDescriptorNumber + (observerChain ? 1 : 0);
|
|
732
|
+
const admissionControlDescriptorNumber = admissionHelperDescriptorNumber + 1;
|
|
561
733
|
const supportingProviderArgs = trustedSupportingProviders.flatMap((support, index) => [
|
|
562
734
|
'--ro-bind-fd', String(supportingProviderDescriptorNumber + index),
|
|
563
735
|
join(dirname(CONTAINED_PROVIDER_EXECUTABLE_PATH), basename(support.binding.executable_path)),
|
|
@@ -565,15 +737,40 @@ function buildAuthorityContainment({
|
|
|
565
737
|
const maskedPathArgs = maskedChains
|
|
566
738
|
.filter((chain) => !pathContains('/tmp', chain.path))
|
|
567
739
|
.flatMap((chain) => ['--tmpfs', chain.path]);
|
|
740
|
+
// Council artifacts are untrusted input. The review CLIs receive every byte
|
|
741
|
+
// in their private prompt and must not need a shell, host files, MCPs or web
|
|
742
|
+
// tools to inspect them. Hide the host home, root account and command trees
|
|
743
|
+
// structurally, while re-binding only the selected provider state needed by
|
|
744
|
+
// the CLI transport itself. This remains defense in depth beside the
|
|
745
|
+
// provider-specific no-tools modes below.
|
|
746
|
+
const councilHomeIsolationArgs = stateDirectory.startsWith('/root/')
|
|
747
|
+
? ['--tmpfs', '/root', '--dir', stateDirectory]
|
|
748
|
+
: ['--tmpfs', '/home', '--dir', dirname(stateDirectory), '--dir', stateDirectory, '--tmpfs', '/root'];
|
|
749
|
+
const councilToolIsolationArgs = councilToolIsolation
|
|
750
|
+
? [
|
|
751
|
+
...councilHomeIsolationArgs,
|
|
752
|
+
'--tmpfs', '/usr/bin', '--tmpfs', '/usr/sbin',
|
|
753
|
+
'--tmpfs', '/usr/local/bin', '--tmpfs', '/usr/local/sbin',
|
|
754
|
+
]
|
|
755
|
+
: [];
|
|
568
756
|
const containmentArgs = [
|
|
569
757
|
'--die-with-parent', '--unshare-pid', '--unshare-ipc', '--unshare-cgroup-try',
|
|
570
|
-
|
|
758
|
+
...(verificationIsolation ? [
|
|
759
|
+
'--unshare-net', '--unshare-uts',
|
|
760
|
+
// Checks run untrusted project code: expose runtime system files only,
|
|
761
|
+
// not a read-only view of the operator's files, credentials or brain.
|
|
762
|
+
...['/usr', '/bin', '/sbin', '/lib', '/lib64'].filter((path) => existsSync(path))
|
|
763
|
+
.flatMap((path) => ['--ro-bind', path, path]),
|
|
764
|
+
'--dir', '/etc',
|
|
765
|
+
...(existsSync('/etc/ld.so.cache') ? ['--ro-bind', '/etc/ld.so.cache', '/etc/ld.so.cache'] : []),
|
|
766
|
+
] : ['--ro-bind', '/', '/']),
|
|
571
767
|
'--dev', '/dev', '--proc', '/proc',
|
|
768
|
+
...councilToolIsolationArgs,
|
|
572
769
|
'--tmpfs', '/run/user',
|
|
573
770
|
'--tmpfs', '/tmp',
|
|
574
|
-
...readOnlyTmpPathEntries(),
|
|
771
|
+
...(verificationIsolation ? [] : readOnlyTmpPathEntries()),
|
|
575
772
|
...maskedPathArgs,
|
|
576
|
-
...hiddenInstallationAuthorityArgs(),
|
|
773
|
+
...(verificationIsolation ? [] : hiddenInstallationAuthorityArgs()),
|
|
577
774
|
...stateDirectoryArgs,
|
|
578
775
|
pinnedWriteScopes.fullProject ? '--bind-fd' : '--ro-bind-fd', '5', cwd,
|
|
579
776
|
...controlPlaneArgs,
|
|
@@ -581,8 +778,12 @@ function buildAuthorityContainment({
|
|
|
581
778
|
...(promptRecord ? ['--ro-bind-fd', String(promptDescriptorNumber), CONTAINED_PROMPT_PATH] : []),
|
|
582
779
|
'--ro-bind-fd', String(providerDescriptorNumber), CONTAINED_PROVIDER_EXECUTABLE_PATH,
|
|
583
780
|
...supportingProviderArgs,
|
|
781
|
+
...(observerChain ? ['--ro-bind-fd', String(observerDescriptorNumber), '/run/chati-observer-tools'] : []),
|
|
782
|
+
...(admissionHelper ? ['--ro-bind-fd', String(admissionHelperDescriptorNumber), CONTAINED_EXECUTION_GATE_PATH] : []),
|
|
783
|
+
...(admissionHelper ? ['--info-fd', String(admissionControlDescriptorNumber + 1)] : []),
|
|
584
784
|
'--chdir', cwd,
|
|
585
|
-
'--',
|
|
785
|
+
'--', ...(admissionHelper ? [CONTAINED_EXECUTION_GATE_PATH, String(admissionControlDescriptorNumber), '30000'] : []),
|
|
786
|
+
CONTAINED_PROVIDER_EXECUTABLE_PATH, ...args,
|
|
586
787
|
];
|
|
587
788
|
return Object.freeze({
|
|
588
789
|
kind: 'bubblewrap_pid_namespace_v1',
|
|
@@ -596,8 +797,13 @@ function buildAuthorityContainment({
|
|
|
596
797
|
...(promptRecord ? [promptRecord.descriptor] : []),
|
|
597
798
|
trustedProvider.descriptor,
|
|
598
799
|
...trustedSupportingProviders.map((support) => support.descriptor),
|
|
800
|
+
...(observerChain ? [observerChain.descriptors.at(-1)] : []),
|
|
801
|
+
...(admissionHelper ? [admissionHelper.descriptor] : []),
|
|
599
802
|
]),
|
|
803
|
+
admissionHelper,
|
|
804
|
+
admissionControlDescriptorNumber: admissionHelper ? admissionControlDescriptorNumber : null,
|
|
600
805
|
pinnedProjectChain: projectChain,
|
|
806
|
+
observerChain,
|
|
601
807
|
maskedReadChains: Object.freeze(maskedChains),
|
|
602
808
|
pinnedWriteScopes,
|
|
603
809
|
projectReference: projectChain.reference,
|
|
@@ -612,6 +818,7 @@ function buildAuthorityContainment({
|
|
|
612
818
|
digest: sha256({
|
|
613
819
|
binary: BUBBLEWRAP_PATH,
|
|
614
820
|
binary_sha256: trustedBubblewrap.binaryDigest,
|
|
821
|
+
...(admissionHelper ? { admission_helper_sha256: admissionHelper.executable.digest } : {}),
|
|
615
822
|
provider_executable: trustedProvider.binding,
|
|
616
823
|
supporting_provider_executables: trustedSupportingProviders.map((support) => support.binding),
|
|
617
824
|
args: containmentArgs.slice(0, -args.length - 1),
|
|
@@ -623,12 +830,267 @@ function closeAuthorityContainment(containment) {
|
|
|
623
830
|
const descriptors = new Set([
|
|
624
831
|
...(containment?.inheritedDescriptors ?? []),
|
|
625
832
|
...(containment?.pinnedProjectChain?.descriptors ?? []),
|
|
833
|
+
...(containment?.pinnedStateChain?.descriptors ?? []),
|
|
626
834
|
...(containment?.pinnedWriteScopes?.descriptors ?? []),
|
|
835
|
+
...(containment?.observerChain?.descriptors ?? []),
|
|
627
836
|
]);
|
|
628
837
|
for (const descriptor of descriptors) {
|
|
629
838
|
try { closeSync(descriptor); } catch { /* preserve the primary spawn result */ }
|
|
630
839
|
}
|
|
631
840
|
for (const chain of containment?.maskedReadChains ?? []) closeDirectoryChain(chain);
|
|
841
|
+
for (const chain of containment?.controlPlaneChains ?? []) closeDirectoryChain(chain);
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
function assertAuthorityContainmentStable(containment) {
|
|
845
|
+
runTerminalSecurityTestHook('beforeAuthorityExecutableRevalidation', { containment });
|
|
846
|
+
if (containment.darwinLaunch) {
|
|
847
|
+
assertDarwinSealedLaunch(containment.darwinLaunch);
|
|
848
|
+
for (const chain of [containment.pinnedProjectChain, containment.pinnedStateChain,
|
|
849
|
+
...containment.controlPlaneChains, ...containment.maskedReadChains].filter(Boolean)) assertPinnedDirectoryChain(chain);
|
|
850
|
+
assertPinnedWriteScopes(containment.pinnedWriteScopes);
|
|
851
|
+
for (const entry of containment.pinnedWriteScopes.entries) {
|
|
852
|
+
const stat = lstatSync(entry.target);
|
|
853
|
+
const matches = entry.kind === 'directory'
|
|
854
|
+
? sameDirectorySnapshot(directorySnapshot(stat), entry.snapshot)
|
|
855
|
+
: sameExecutableSnapshot(executableSnapshot(stat), entry.snapshot);
|
|
856
|
+
if (realpathSync(entry.target) !== entry.target || !matches) return failContainment('Darwin write scope pathname no longer names its pinned target');
|
|
857
|
+
}
|
|
858
|
+
return true;
|
|
859
|
+
}
|
|
860
|
+
currentBubblewrapTrust(containment.inheritedDescriptors[0]);
|
|
861
|
+
digestPinnedExecutable(containment.inheritedDescriptors[0], containment.trustedExecutable);
|
|
862
|
+
if (containment.admissionHelper) digestPinnedExecutable(containment.admissionHelper.descriptor, containment.admissionHelper.executable);
|
|
863
|
+
const executable = digestProviderExecutable(
|
|
864
|
+
containment.providerExecutableDescriptor, containment.trustedProviderExecutable.snapshot,
|
|
865
|
+
);
|
|
866
|
+
if (executable.sha256 !== containment.providerExecutableBinding.executable_sha256) {
|
|
867
|
+
throw Object.assign(new Error('provider executable changed after installation binding validation'), { code: 'PROVIDER_EXECUTABLE_DRIFT' });
|
|
868
|
+
}
|
|
869
|
+
containment.supportingProviderExecutableDescriptors.forEach((descriptor, index) => {
|
|
870
|
+
const supporting = digestProviderExecutable(descriptor, containment.trustedSupportingProviderExecutables[index].snapshot);
|
|
871
|
+
if (supporting.sha256 !== containment.supportingProviderExecutableBindings[index].executable_sha256) {
|
|
872
|
+
throw Object.assign(new Error('provider supporting executable changed after installation binding validation'), { code: 'PROVIDER_EXECUTABLE_DRIFT' });
|
|
873
|
+
}
|
|
874
|
+
});
|
|
875
|
+
assertPinnedDirectoryChain(containment.pinnedProjectChain);
|
|
876
|
+
if (containment.observerChain) assertPinnedDirectoryChain(containment.observerChain);
|
|
877
|
+
for (const chain of containment.maskedReadChains) assertPinnedDirectoryChain(chain);
|
|
878
|
+
assertPinnedWriteScopes(containment.pinnedWriteScopes);
|
|
879
|
+
return true;
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
/**
|
|
883
|
+
* Execute one frozen check in a disposable result workspace. This low-level
|
|
884
|
+
* observer reports process facts only. It cannot issue certificates, assert a
|
|
885
|
+
* verdict or choose the canonical attempt, plan or commit for its caller.
|
|
886
|
+
*/
|
|
887
|
+
export async function runIsolatedVerificationCheck(options = {}) {
|
|
888
|
+
return runIsolatedVerificationProcess(options);
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
/** Internal trusted observer entry point; not exposed through check plans or CLI. */
|
|
892
|
+
export async function runIsolatedBrowserSupervisor({ observerDirectory, ...options } = {}) {
|
|
893
|
+
if (typeof observerDirectory !== 'string' || !isAbsolute(observerDirectory)) {
|
|
894
|
+
throw Object.assign(new Error('browser observer requires private read-only tools'), { code: 'VERIFICATION_CHECK_INVALID' });
|
|
895
|
+
}
|
|
896
|
+
return runIsolatedVerificationProcess(options, observerDirectory);
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
/** Separate trusted role, never selectable by an application check plan. */
|
|
900
|
+
export async function runIsolatedDarwinBrowserObserver({ observerDirectory, applicationProject, proxyPort, baseBundleId, applicationSignal, ...options } = {}) {
|
|
901
|
+
if (process.platform !== 'darwin') return failContainment('Darwin browser observer requires an actual Mac');
|
|
902
|
+
if (!(applicationSignal instanceof AbortSignal)) return failContainment('browser observer requires its owned application lifetime signal');
|
|
903
|
+
return runIsolatedVerificationProcess(options, null,
|
|
904
|
+
structuredClone({ observerDirectory, applicationProject, proxyPort, baseBundleId }), applicationSignal);
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
async function runIsolatedVerificationProcess(options = {}, observerDirectory = null, darwinBrowserObserver = null, applicationSignal = null) {
|
|
908
|
+
// Canonical CI/acceptance opts into immutable source; standalone disposable
|
|
909
|
+
// diagnostics retain their existing writable-subject default. Never CLI input.
|
|
910
|
+
const { workingDir, executableBinding, args, stdin, timeoutMs, subjectReadOnly = false } = options;
|
|
911
|
+
const fields = ['workingDir', 'executableBinding', 'args', 'stdin', 'timeoutMs', 'subjectReadOnly'];
|
|
912
|
+
if (Object.keys(options).some((field) => !fields.includes(field))
|
|
913
|
+
|| (Object.hasOwn(options, 'subjectReadOnly') && typeof options.subjectReadOnly !== 'boolean')
|
|
914
|
+
|| (subjectReadOnly && darwinBrowserObserver !== null)
|
|
915
|
+
|| typeof workingDir !== 'string' || !isAbsolute(workingDir)
|
|
916
|
+
|| !Array.isArray(args) || args.length > 64
|
|
917
|
+
|| args.some((arg) => typeof arg !== 'string' || arg.includes('\0') || Buffer.byteLength(arg) > 8192)
|
|
918
|
+
|| typeof stdin !== 'string' || stdin.length === 0 || Buffer.byteLength(stdin) > 1000000
|
|
919
|
+
|| !Number.isInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > 1800000) {
|
|
920
|
+
throw Object.assign(new Error('verification check requires bounded script, arguments, working directory and timeout only'), { code: 'VERIFICATION_CHECK_INVALID' });
|
|
921
|
+
}
|
|
922
|
+
if (process.platform === 'darwin') {
|
|
923
|
+
if (observerDirectory !== null) return failContainment('Darwin browser verification isolation is not implemented');
|
|
924
|
+
return runDarwinVerificationProcess(options, darwinBrowserObserver, applicationSignal);
|
|
925
|
+
}
|
|
926
|
+
let containment;
|
|
927
|
+
let child;
|
|
928
|
+
try {
|
|
929
|
+
containment = buildAuthorityContainment({
|
|
930
|
+
args: [...args], cwd: resolve(workingDir), readOnly: subjectReadOnly, writeScope: subjectReadOnly ? [] : ['.'],
|
|
931
|
+
terminalId: generateTerminalId('verification'), providerExecutableBinding: executableBinding,
|
|
932
|
+
verificationIsolation: true,
|
|
933
|
+
observerDirectory,
|
|
934
|
+
});
|
|
935
|
+
assertAuthorityContainmentStable(containment);
|
|
936
|
+
child = spawn(containment.command, containment.args, {
|
|
937
|
+
cwd: '/', detached: true,
|
|
938
|
+
env: { PATH: AUTHORITY_SYSTEM_PATH, LANG: 'C.UTF-8', LC_ALL: 'C.UTF-8', TZ: 'UTC', TMPDIR: '/tmp',
|
|
939
|
+
...(observerDirectory ? { XDG_CACHE_HOME: '/tmp/chati-observer-cache', PLAYWRIGHT_BROWSERS_PATH: '/run/chati-observer-tools/chromium' } : {}),
|
|
940
|
+
},
|
|
941
|
+
stdio: ['pipe', 'pipe', 'pipe', ...containment.inheritedDescriptors],
|
|
942
|
+
});
|
|
943
|
+
} finally {
|
|
944
|
+
closeAuthorityContainment(containment);
|
|
945
|
+
}
|
|
946
|
+
return new Promise((resolveResult, reject) => {
|
|
947
|
+
const chunks = { stdout: [], stderr: [] };
|
|
948
|
+
const outputLimit = 2 * 1024 * 1024;
|
|
949
|
+
let outputBytes = 0;
|
|
950
|
+
let timedOut = false;
|
|
951
|
+
let outputLimitExceeded = false;
|
|
952
|
+
let inputError = null;
|
|
953
|
+
let spawnError = null;
|
|
954
|
+
const terminate = () => {
|
|
955
|
+
try { signalProcessTree(child, child.pid, 'SIGKILL'); }
|
|
956
|
+
catch (error) { spawnError ??= error; }
|
|
957
|
+
};
|
|
958
|
+
const timer = setTimeout(() => { timedOut = true; terminate(); }, timeoutMs);
|
|
959
|
+
for (const stream of ['stdout', 'stderr']) {
|
|
960
|
+
child[stream].on('data', (chunk) => {
|
|
961
|
+
const remaining = Math.max(0, outputLimit - outputBytes);
|
|
962
|
+
if (remaining > 0) chunks[stream].push(chunk.subarray(0, remaining));
|
|
963
|
+
outputBytes += chunk.length;
|
|
964
|
+
if (outputBytes > outputLimit) { outputLimitExceeded = true; terminate(); }
|
|
965
|
+
});
|
|
966
|
+
}
|
|
967
|
+
child.once('error', (error) => { spawnError = error; });
|
|
968
|
+
child.stdin.on('error', (error) => { inputError = error.code || error.name; });
|
|
969
|
+
child.once('close', (exitCode, signal) => {
|
|
970
|
+
clearTimeout(timer);
|
|
971
|
+
if (spawnError) { reject(spawnError); return; }
|
|
972
|
+
if (!processTreeStopsAfterClose(child.pid, child.pid)) {
|
|
973
|
+
terminate();
|
|
974
|
+
reject(Object.assign(new Error('verification process group did not terminate'), { code: 'VERIFICATION_PROCESS_STILL_RUNNING' }));
|
|
975
|
+
return;
|
|
976
|
+
}
|
|
977
|
+
const stdout = Buffer.concat(chunks.stdout);
|
|
978
|
+
const stderr = Buffer.concat(chunks.stderr);
|
|
979
|
+
resolveResult(Object.freeze({
|
|
980
|
+
exit_code: exitCode, signal, timed_out: timedOut, output_limit_exceeded: outputLimitExceeded,
|
|
981
|
+
input_error: inputError, process_tree_stopped: true,
|
|
982
|
+
subject_read_only: subjectReadOnly,
|
|
983
|
+
stdout: stdout.toString('utf8'), stderr: stderr.toString('utf8'),
|
|
984
|
+
stdout_base64: stdout.toString('base64'), stderr_base64: stderr.toString('base64'),
|
|
985
|
+
stdout_sha256: createHash('sha256').update(stdout).digest('hex'),
|
|
986
|
+
stderr_sha256: createHash('sha256').update(stderr).digest('hex'),
|
|
987
|
+
captured_output_bytes: stdout.length + stderr.length,
|
|
988
|
+
stdin_sha256: createHash('sha256').update(stdin).digest('hex'),
|
|
989
|
+
executable_binding: containment.providerExecutableBinding,
|
|
990
|
+
containment_kind: containment.kind, containment_digest: containment.digest,
|
|
991
|
+
}));
|
|
992
|
+
});
|
|
993
|
+
child.stdin.end(stdin);
|
|
994
|
+
});
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
async function runDarwinVerificationProcess(options, browserObserver = null, applicationSignal = null) {
|
|
998
|
+
const { executableBinding, args, stdin, timeoutMs, subjectReadOnly = false } = options;
|
|
999
|
+
const workingDir = realpathSync(options.workingDir);
|
|
1000
|
+
const context = { authority_kind: 'verification_check', invocation_id: randomUUID(),
|
|
1001
|
+
launch_nonce: randomUUID(), executable_binding_digest: sha256(executableBinding) };
|
|
1002
|
+
let containment; let child; let timer; let escalation; let drainTimer; let cancelObserver;
|
|
1003
|
+
const assertStable = () => browserObserver ? assertDarwinSealedLaunch(containment.darwinLaunch) : assertAuthorityContainmentStable(containment);
|
|
1004
|
+
try {
|
|
1005
|
+
if (browserObserver) {
|
|
1006
|
+
const launch = prepareDarwinBrowserObserverLaunch({ binding: executableBinding, args: [...args], projectDir: workingDir,
|
|
1007
|
+
authorityContext: context, ...browserObserver });
|
|
1008
|
+
containment = { darwinLaunch: launch, kind: launch.kind, providerExecutableBinding: structuredClone(executableBinding),
|
|
1009
|
+
digest: sha256({ role: 'browser-observer', helper_sha256: launch.helperSha256, policy_digest: launch.policyDigest,
|
|
1010
|
+
executable_binding: executableBinding, native_nonce: launch.supervisorChannels.nonce }) };
|
|
1011
|
+
} else {
|
|
1012
|
+
containment = buildAuthorityContainment({ args: [...args], cwd: workingDir,
|
|
1013
|
+
readOnly: subjectReadOnly, writeScope: subjectReadOnly ? [] : ['.'],
|
|
1014
|
+
terminalId: generateTerminalId('verification'), providerExecutableBinding: executableBinding,
|
|
1015
|
+
verificationIsolation: true, authorityContext: context });
|
|
1016
|
+
}
|
|
1017
|
+
assertStable();
|
|
1018
|
+
const launch = containment.darwinLaunch;
|
|
1019
|
+
child = spawn(launch.command, launch.args, { cwd: '/', detached: true,
|
|
1020
|
+
env: { PATH: '/usr/bin:/bin:/usr/sbin:/sbin', LANG: 'C.UTF-8', LC_ALL: 'C.UTF-8', TZ: 'UTC',
|
|
1021
|
+
HOME: launch.temporaryDirectory, TMPDIR: launch.temporaryDirectory },
|
|
1022
|
+
stdio: ['pipe', 'pipe', 'pipe', 'ignore', launch.projectDescriptor, ...launch.supervisorChannels.inheritedDescriptors] });
|
|
1023
|
+
let ready; let setupError; let inputError = null; let spawnError = null;
|
|
1024
|
+
let timedOut = false; let outputLimitExceeded = false; let outputBytes = 0;
|
|
1025
|
+
const chunks = { stdout: [], stderr: [] };
|
|
1026
|
+
const stop = (signal = 'SIGTERM') => {
|
|
1027
|
+
if (child.exitCode !== null || child.signalCode !== null) return;
|
|
1028
|
+
try { signalProcessTree(child, child.pid, signal); } catch (error) { spawnError ??= error; }
|
|
1029
|
+
if (!escalation) escalation = setTimeout(() => {
|
|
1030
|
+
if (child.exitCode !== null || child.signalCode !== null) return;
|
|
1031
|
+
try { signalProcessTree(child, child.pid, 'SIGKILL'); } catch { /* failed completion is not success */ }
|
|
1032
|
+
}, 2000);
|
|
1033
|
+
};
|
|
1034
|
+
const finished = new Promise((done, reject) => {
|
|
1035
|
+
for (const stream of ['stdout', 'stderr']) child[stream].on('data', bytes => {
|
|
1036
|
+
const available = Math.max(0, 2 * 1024 * 1024 - outputBytes);
|
|
1037
|
+
if (available) chunks[stream].push(bytes.subarray(0, available));
|
|
1038
|
+
outputBytes += bytes.length;
|
|
1039
|
+
if (outputBytes > 2 * 1024 * 1024 && !outputLimitExceeded) { outputLimitExceeded = true; stop(); }
|
|
1040
|
+
});
|
|
1041
|
+
child.once('error', error => { spawnError = error; });
|
|
1042
|
+
child.stdin.on('error', error => { inputError = error.code || error.name; });
|
|
1043
|
+
// A detached writer may keep stdout open after the helper exits. Bound
|
|
1044
|
+
// this drain and reject, never accept truncated output or wait forever.
|
|
1045
|
+
child.once('exit', () => {
|
|
1046
|
+
clearTimeout(timer); clearTimeout(escalation);
|
|
1047
|
+
drainTimer = setTimeout(() => {
|
|
1048
|
+
child.stdout.destroy(); child.stderr.destroy(); child.stdin.destroy();
|
|
1049
|
+
reject(Object.assign(new Error('verification output did not close after native helper exit'), { code: 'VERIFICATION_OUTPUT_NOT_CLOSED' }));
|
|
1050
|
+
}, 2000);
|
|
1051
|
+
});
|
|
1052
|
+
child.once('close', (exitCode, signal) => {
|
|
1053
|
+
try {
|
|
1054
|
+
if (setupError || spawnError) throw setupError || spawnError;
|
|
1055
|
+
const completion = launch.supervisorChannels.readCompletion(exitCode);
|
|
1056
|
+
assertStable();
|
|
1057
|
+
const stdout = Buffer.concat(chunks.stdout); const stderr = Buffer.concat(chunks.stderr);
|
|
1058
|
+
const facts = { exit_code: exitCode, signal, timed_out: timedOut, output_limit_exceeded: outputLimitExceeded,
|
|
1059
|
+
input_error: inputError, process_tree_stopped: false, process_group_stopped: true,
|
|
1060
|
+
subject_read_only: subjectReadOnly,
|
|
1061
|
+
stdout: stdout.toString('utf8'), stderr: stderr.toString('utf8'),
|
|
1062
|
+
stdout_base64: stdout.toString('base64'), stderr_base64: stderr.toString('base64'),
|
|
1063
|
+
stdout_sha256: createHash('sha256').update(stdout).digest('hex'), stderr_sha256: createHash('sha256').update(stderr).digest('hex'),
|
|
1064
|
+
captured_output_bytes: stdout.length + stderr.length, stdin_sha256: createHash('sha256').update(stdin).digest('hex'),
|
|
1065
|
+
executable_binding: projectVerificationExecutableBinding(containment.providerExecutableBinding, executableBinding),
|
|
1066
|
+
containment_kind: containment.kind, containment_digest: containment.digest,
|
|
1067
|
+
darwin_verification_binding: createDarwinVerificationBinding({ context, helperSha256: launch.helperSha256,
|
|
1068
|
+
policyDigest: launch.policyDigest, ready, completion, workingDir, executableBinding, args, stdin }) };
|
|
1069
|
+
verificationProcessCompleted(facts, { ...options, workingDir });
|
|
1070
|
+
done(Object.freeze(facts));
|
|
1071
|
+
} catch (error) { reject(error); }
|
|
1072
|
+
});
|
|
1073
|
+
});
|
|
1074
|
+
timer = setTimeout(() => { timedOut = true; stop(); }, timeoutMs);
|
|
1075
|
+
if (applicationSignal) {
|
|
1076
|
+
cancelObserver = () => { spawnError ??= Object.assign(new Error('browser application exited during observation'), { code: 'BROWSER_APPLICATION_EXITED' }); stop(); };
|
|
1077
|
+
applicationSignal.addEventListener('abort', cancelObserver, { once: true });
|
|
1078
|
+
}
|
|
1079
|
+
try {
|
|
1080
|
+
if (applicationSignal?.aborted) throw Object.assign(new Error('browser application already exited'), { code: 'BROWSER_APPLICATION_EXITED' });
|
|
1081
|
+
ready = launch.supervisorChannels.readReady(child.pid, launch.verifiedCdhash);
|
|
1082
|
+
assertStable();
|
|
1083
|
+
launch.supervisorChannels.authorize();
|
|
1084
|
+
child.stdin.end(stdin);
|
|
1085
|
+
} catch (error) { setupError = error; stop('SIGKILL'); }
|
|
1086
|
+
return await finished;
|
|
1087
|
+
} finally {
|
|
1088
|
+
if (cancelObserver) applicationSignal.removeEventListener('abort', cancelObserver);
|
|
1089
|
+
clearTimeout(timer); clearTimeout(escalation); clearTimeout(drainTimer);
|
|
1090
|
+
// No recursive cleanup of a potentially detached writer's mutable TMP.
|
|
1091
|
+
try { if (containment?.darwinLaunch) disposeDarwinSealedLaunch(containment.darwinLaunch); }
|
|
1092
|
+
finally { closeAuthorityContainment(containment); }
|
|
1093
|
+
}
|
|
632
1094
|
}
|
|
633
1095
|
|
|
634
1096
|
function signalProcessTree(child, processGroupId, signal) {
|
|
@@ -655,7 +1117,55 @@ function processTreeStopsAfterClose(childPid, processGroupId) {
|
|
|
655
1117
|
return false;
|
|
656
1118
|
}
|
|
657
1119
|
|
|
658
|
-
function
|
|
1120
|
+
async function stopDarwinAuthority(runtime) {
|
|
1121
|
+
const child = runtime.child;
|
|
1122
|
+
const wasRunning = !runtime.finalized;
|
|
1123
|
+
if (wasRunning) runtime.killRequested = true;
|
|
1124
|
+
const signalHelper = (signal) => {
|
|
1125
|
+
// The native helper owns a SEPARATE admitted provider process group. On
|
|
1126
|
+
// Darwin its own zombie-only group may report EPERM. Never interpret a
|
|
1127
|
+
// lookup of that group (or a reused PID after close) as provider lifetime.
|
|
1128
|
+
if (child.exitCode === null && child.signalCode === null) {
|
|
1129
|
+
try { child.kill(signal); } catch { /* only private completion can prove cleanup */ }
|
|
1130
|
+
}
|
|
1131
|
+
};
|
|
1132
|
+
const waitForClose = async () => {
|
|
1133
|
+
let timer;
|
|
1134
|
+
try {
|
|
1135
|
+
return await Promise.race([
|
|
1136
|
+
runtime.exitPromise.then(() => true),
|
|
1137
|
+
new Promise(resolve => { timer = setTimeout(() => resolve(false), 5000); }),
|
|
1138
|
+
]);
|
|
1139
|
+
} finally { clearTimeout(timer); }
|
|
1140
|
+
};
|
|
1141
|
+
if (wasRunning) {
|
|
1142
|
+
signalHelper('SIGTERM');
|
|
1143
|
+
if (!(await waitForClose())) {
|
|
1144
|
+
signalHelper('SIGKILL');
|
|
1145
|
+
if (!(await waitForClose())) {
|
|
1146
|
+
throw Object.assign(new Error('native Darwin helper did not close with a private completion'),
|
|
1147
|
+
{ code: 'TERMINAL_PROCESS_TREE_UNRESOLVED' });
|
|
1148
|
+
}
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
// SIGKILL/ESRCH/helper exit alone is never positive completion evidence.
|
|
1152
|
+
// readCompletion in finalize validates the original READY/GO binding and
|
|
1153
|
+
// scoped group emptiness; detached descendants remain explicitly unknown.
|
|
1154
|
+
if (runtime.timeoutCleanupFailure) throw runtime.timeoutCleanupFailure;
|
|
1155
|
+
if (!runtime.darwinCompletion) {
|
|
1156
|
+
throw Object.assign(new Error('Darwin cleanup requires its private supervisor observation'),
|
|
1157
|
+
{ code: 'DARWIN_COMPLETION_OBSERVATION_REQUIRED' });
|
|
1158
|
+
}
|
|
1159
|
+
return { killed: wasRunning, exitCode: runtime.exitCode };
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
function terminalAuthorityReceipt(material, { launchReceipt } = {}) {
|
|
1163
|
+
if (material.purpose === 'terminal_launch' && material.process_containment_kind === 'darwin_scoped_process_group_v1') {
|
|
1164
|
+
assertDarwinTerminalLaunch(material);
|
|
1165
|
+
}
|
|
1166
|
+
if (launchReceipt?.process_containment_kind === 'darwin_scoped_process_group_v1') {
|
|
1167
|
+
assertDarwinTerminalCompletion(material, launchReceipt);
|
|
1168
|
+
}
|
|
659
1169
|
const signed = signHostAuthorityPayload(canonicalize(material), {
|
|
660
1170
|
domain: TERMINAL_AUTHORITY_DOMAIN,
|
|
661
1171
|
});
|
|
@@ -727,10 +1237,18 @@ export function getTerminalCompletionAuthorityReceipt(handle) {
|
|
|
727
1237
|
if (runtime.outputOverflow) {
|
|
728
1238
|
throw Object.assign(new Error('terminal output exceeded the authority capture limit'), { code: 'TERMINAL_OUTPUT_OVERFLOW' });
|
|
729
1239
|
}
|
|
730
|
-
if (
|
|
1240
|
+
if (state.launchReceipt.darwin_launch_binding && !runtime.darwinCompletion) {
|
|
1241
|
+
throw Object.assign(new Error('Darwin completion requires its private supervisor observation'), { code: 'DARWIN_COMPLETION_OBSERVATION_REQUIRED' });
|
|
1242
|
+
}
|
|
1243
|
+
if (!state.launchReceipt.darwin_launch_binding
|
|
1244
|
+
&& !processTreeStopsAfterClose(runtime.child.pid, runtime.processGroupId)) {
|
|
731
1245
|
try { signalProcessTree(runtime.child, runtime.processGroupId, 'SIGKILL'); } catch { /* fail closed below */ }
|
|
732
1246
|
throw Object.assign(new Error('terminal process group remained active after the provider CLI exited'), { code: 'TERMINAL_PROCESS_GROUP_STILL_RUNNING' });
|
|
733
1247
|
}
|
|
1248
|
+
if (state.launchReceipt.process_namespace_identity
|
|
1249
|
+
&& observeLinuxNamespace(state.launchReceipt.process_namespace_identity, { timeoutMs: 500 }).status !== 'exited') {
|
|
1250
|
+
throw Object.assign(new Error('the exact provider namespace has not finished exiting'), { code: 'TERMINAL_NAMESPACE_STILL_RUNNING' });
|
|
1251
|
+
}
|
|
734
1252
|
if (!state.completionReceipt) {
|
|
735
1253
|
const stdout = runtime.stdout.join('');
|
|
736
1254
|
let railResult = null;
|
|
@@ -762,6 +1280,13 @@ export function getTerminalCompletionAuthorityReceipt(handle) {
|
|
|
762
1280
|
schema_version: 1,
|
|
763
1281
|
purpose: 'terminal_completion',
|
|
764
1282
|
launch_receipt_digest: sha256(state.launchReceipt),
|
|
1283
|
+
...(state.launchReceipt.process_namespace_identity ? {
|
|
1284
|
+
process_namespace_identity_digest: sha256(state.launchReceipt.process_namespace_identity),
|
|
1285
|
+
} : {}),
|
|
1286
|
+
...(runtime.darwinCompletion ? {
|
|
1287
|
+
darwin_launch_binding_digest: sha256(state.launchReceipt.darwin_launch_binding),
|
|
1288
|
+
darwin_completion_observation: runtime.darwinCompletion,
|
|
1289
|
+
} : {}),
|
|
765
1290
|
terminal_id: identity.terminal_id,
|
|
766
1291
|
invocation_id: state.context.invocation_id,
|
|
767
1292
|
launch_nonce: state.context.launch_nonce,
|
|
@@ -790,12 +1315,15 @@ export function getTerminalCompletionAuthorityReceipt(handle) {
|
|
|
790
1315
|
} : {}),
|
|
791
1316
|
...(railResult || {}),
|
|
792
1317
|
completed_at: runtime.completedAt,
|
|
793
|
-
|
|
1318
|
+
process_time_kind: 'provider-invocation-wall-time',
|
|
1319
|
+
process_elapsed_ms: runtime.processElapsedMs,
|
|
1320
|
+
}, { launchReceipt: state.launchReceipt });
|
|
794
1321
|
}
|
|
795
1322
|
return state.completionReceipt;
|
|
796
1323
|
}
|
|
797
1324
|
|
|
798
|
-
/** Wait for an authority terminal and propagate
|
|
1325
|
+
/** Wait for an authority terminal and propagate cleanup/private completion failures.
|
|
1326
|
+
* Darwin completion proves the scoped process group, not all detached descendants. */
|
|
799
1327
|
export async function waitForTerminal(handle) {
|
|
800
1328
|
const state = TERMINAL_AUTHORITY_STATE.get(handle);
|
|
801
1329
|
if (!state) {
|
|
@@ -818,6 +1346,10 @@ export async function waitForTerminal(handle) {
|
|
|
818
1346
|
if (!cleanup.ok) throw cleanup.error;
|
|
819
1347
|
}
|
|
820
1348
|
if (runtime.timeoutCleanupFailure) throw runtime.timeoutCleanupFailure;
|
|
1349
|
+
// Planning's retry wrapper must not accept output or retry an exited helper
|
|
1350
|
+
// without a complete, bound private observation. The other Darwin consumers
|
|
1351
|
+
// use this same wait boundary; none may interpret an exit event as proof.
|
|
1352
|
+
if (state.launchReceipt.darwin_launch_binding) getTerminalCompletionAuthorityReceipt(handle);
|
|
821
1353
|
return handle;
|
|
822
1354
|
}
|
|
823
1355
|
|
|
@@ -869,11 +1401,18 @@ const BLOCKED_ENV_EXACT = ['CLAUDECODE'];
|
|
|
869
1401
|
* User-configured vars like CLAUDE_API_KEY are preserved.
|
|
870
1402
|
*
|
|
871
1403
|
* @param {Record<string, string>} env - Source environment (typically process.env)
|
|
1404
|
+
* @param {object} [options] - Explicit child provider and secret policy
|
|
872
1405
|
* @returns {Record<string, string>} Cleaned environment (new object)
|
|
873
1406
|
*/
|
|
874
|
-
export function cleanParentEnv(env) {
|
|
1407
|
+
export function cleanParentEnv(env, { provider, allowProviderSecrets = true } = {}) {
|
|
875
1408
|
const cleaned = {};
|
|
876
1409
|
for (const [key, value] of Object.entries(env)) {
|
|
1410
|
+
// setup-token is an authentication credential, not parent session state.
|
|
1411
|
+
// Unknown/other providers and secret-free launches must still strip it.
|
|
1412
|
+
if (key === 'CLAUDE_CODE_OAUTH_TOKEN' && provider === 'claude' && allowProviderSecrets) {
|
|
1413
|
+
cleaned[key] = value;
|
|
1414
|
+
continue;
|
|
1415
|
+
}
|
|
877
1416
|
if (BLOCKED_ENV_EXACT.includes(key)) continue;
|
|
878
1417
|
if (BLOCKED_ENV_PREFIXES.some(prefix => key.startsWith(prefix))) continue;
|
|
879
1418
|
cleaned[key] = value;
|
|
@@ -888,10 +1427,10 @@ const AUTHORITY_PROVIDER_SECRET_PREFIXES = Object.freeze({
|
|
|
888
1427
|
});
|
|
889
1428
|
const AUTHORITY_SECRET_NAME = /(API[_-]?KEY|ACCESS[_-]?KEY|PRIVATE[_-]?KEY|PASSWORD|SECRET|TOKEN|CREDENTIAL|AUTH[_-]?SOCK)/i;
|
|
890
1429
|
|
|
891
|
-
export function cleanAuthorityEnv(env, provider) {
|
|
892
|
-
const allowedPrefixes = AUTHORITY_PROVIDER_SECRET_PREFIXES[provider] || [];
|
|
1430
|
+
export function cleanAuthorityEnv(env, provider, { allowProviderSecrets = true } = {}) {
|
|
1431
|
+
const allowedPrefixes = allowProviderSecrets ? (AUTHORITY_PROVIDER_SECRET_PREFIXES[provider] || []) : [];
|
|
893
1432
|
const cleaned = {};
|
|
894
|
-
for (const [key, value] of Object.entries(cleanParentEnv(env))) {
|
|
1433
|
+
for (const [key, value] of Object.entries(cleanParentEnv(env, { provider, allowProviderSecrets }))) {
|
|
895
1434
|
if (AUTHORITY_SECRET_NAME.test(key) && !allowedPrefixes.some((prefix) => key.startsWith(prefix))) continue;
|
|
896
1435
|
cleaned[key] = value;
|
|
897
1436
|
}
|
|
@@ -920,8 +1459,10 @@ export function cleanAuthorityEnv(env, provider) {
|
|
|
920
1459
|
* @property {number} [timeout] - Max execution time in ms
|
|
921
1460
|
* @property {function} [beforeProcessSpawn] - Read-only preparation after containment is pinned
|
|
922
1461
|
* @property {function} [beforeAuthoritySpawn] - Final synchronous authority reservation before spawn
|
|
1462
|
+
* @property {function} [afterAuthoritySpawn] - Commit dispatch with (receipt, handle) before positive-GO
|
|
923
1463
|
* @property {string[]} [maskedReadPaths] - Canonical host paths hidden from an authority provider
|
|
924
1464
|
* @property {string} [auditProjectDir] - Canonical CHATI project used only for the host-side model audit
|
|
1465
|
+
* @property {boolean} [councilToolIsolation] - Disable local tools for an untrusted council subject
|
|
925
1466
|
*/
|
|
926
1467
|
|
|
927
1468
|
/**
|
|
@@ -976,6 +1517,7 @@ export function buildSpawnCommand(config) {
|
|
|
976
1517
|
|
|
977
1518
|
const env = {
|
|
978
1519
|
...isolationEnv,
|
|
1520
|
+
...(config.providerExecutableBinding !== undefined ? sealedProviderRuntimeEnvironment(config.provider) : {}),
|
|
979
1521
|
CHATI_TERMINAL_ID: terminalId,
|
|
980
1522
|
CHATI_AGENT: config.agent,
|
|
981
1523
|
CHATI_TASK_ID: config.taskId,
|
|
@@ -1050,47 +1592,93 @@ export function spawnTerminal(config) {
|
|
|
1050
1592
|
throw Object.assign(new Error(`authorityContext.${field} is required`), { code: 'INVALID_TERMINAL_AUTHORITY_CONTEXT' });
|
|
1051
1593
|
}
|
|
1052
1594
|
}
|
|
1595
|
+
// Only callers with an explicit Darwin lifetime/cleanup contract are enabled.
|
|
1596
|
+
if (process.platform === 'darwin'
|
|
1597
|
+
&& !['rail_execution', 'rail_review', 'rail_adjudication', 'planning_execution', 'council_gate'].includes(authorityContext.authority_kind)) {
|
|
1598
|
+
throw Object.assign(new Error(`Darwin lifetime and cleanup are not integrated for ${authorityContext.authority_kind}`), {
|
|
1599
|
+
code: 'DARWIN_AUTHORITY_CONSUMER_UNAVAILABLE',
|
|
1600
|
+
});
|
|
1601
|
+
}
|
|
1053
1602
|
}
|
|
1054
1603
|
|
|
1055
1604
|
const readOnly = Array.isArray(config.writeScope) && config.writeScope.length === 0;
|
|
1056
1605
|
if (config.beforeProcessSpawn !== undefined && typeof config.beforeProcessSpawn !== 'function') {
|
|
1057
1606
|
throw Object.assign(new Error('beforeProcessSpawn must be a function'), { code: 'INVALID_TERMINAL_PRESPAWN_HOOK' });
|
|
1058
1607
|
}
|
|
1608
|
+
|
|
1609
|
+
// Snapshot the eligibility before caller hooks. The sealed Claude transport
|
|
1610
|
+
// needs its own auth even when council subjects have no tools/host secrets.
|
|
1611
|
+
// This is not a prefix exception and does not change general secret filtering.
|
|
1612
|
+
const councilClaudeTransportAuth = actualProvider === 'claude' && readOnly
|
|
1613
|
+
&& config.councilToolIsolation === true && authorityContext?.authority_kind === 'council_gate';
|
|
1059
1614
|
if (config.beforeAuthoritySpawn !== undefined
|
|
1060
1615
|
&& (authorityContext === undefined || typeof config.beforeAuthoritySpawn !== 'function')) {
|
|
1061
1616
|
throw Object.assign(new Error('beforeAuthoritySpawn requires an authority context and callback'), { code: 'INVALID_TERMINAL_PRESPAWN_HOOK' });
|
|
1062
1617
|
}
|
|
1618
|
+
if (config.afterAuthoritySpawn !== undefined
|
|
1619
|
+
&& (authorityContext?.authority_kind !== 'rail_execution' || typeof config.afterAuthoritySpawn !== 'function')) {
|
|
1620
|
+
throw Object.assign(new Error('afterAuthoritySpawn requires a RAIL execution authority and synchronous durable-dispatch callback'), { code: 'INVALID_TERMINAL_ADMISSION_HOOK' });
|
|
1621
|
+
}
|
|
1063
1622
|
|
|
1064
1623
|
const detachedProcessGroup = authorityContext !== undefined && process.platform !== 'win32';
|
|
1065
1624
|
let promptRecord;
|
|
1066
1625
|
let containment;
|
|
1067
1626
|
let sealedProvider;
|
|
1627
|
+
let darwinLaunch;
|
|
1628
|
+
let darwinCompletionError;
|
|
1629
|
+
let darwinReady;
|
|
1630
|
+
let darwinRuntimeOwned = false;
|
|
1631
|
+
let darwinDisposed = false;
|
|
1632
|
+
const disposeDarwin = (validate = false) => {
|
|
1633
|
+
if (!darwinLaunch || darwinDisposed) return;
|
|
1634
|
+
darwinDisposed = true;
|
|
1635
|
+
try { if (validate) assertDarwinSealedLaunch(darwinLaunch); }
|
|
1636
|
+
catch (error) { darwinCompletionError = error; }
|
|
1637
|
+
try { disposeDarwinSealedLaunch(darwinLaunch); }
|
|
1638
|
+
catch (error) { darwinCompletionError ??= error; }
|
|
1639
|
+
};
|
|
1068
1640
|
let executionWorktreeBaseline;
|
|
1641
|
+
let executionAdmissionReceipt;
|
|
1642
|
+
let darwinHostBootIdentity;
|
|
1643
|
+
let executionNamespaceIdentity;
|
|
1644
|
+
let processInfoPipe;
|
|
1069
1645
|
let spawnArgs = args;
|
|
1070
1646
|
let child;
|
|
1647
|
+
let processStartedTick;
|
|
1071
1648
|
try {
|
|
1072
1649
|
if (promptFile !== null) {
|
|
1073
1650
|
promptRecord = openPrivatePromptFile(promptFile);
|
|
1074
|
-
spawnArgs = materializePrivatePromptArgs(args, authorityContext === undefined
|
|
1075
|
-
? '/proc/self/fd/3'
|
|
1651
|
+
spawnArgs = materializePrivatePromptArgs(args, authorityContext === undefined || process.platform === 'darwin'
|
|
1652
|
+
? (process.platform === 'darwin' ? '/dev/fd/3' : '/proc/self/fd/3')
|
|
1076
1653
|
: CONTAINED_PROMPT_PATH);
|
|
1077
1654
|
}
|
|
1078
1655
|
containment = authorityContext === undefined ? null : buildAuthorityContainment({
|
|
1079
1656
|
args: spawnArgs, cwd: resolve(cwd), readOnly, terminalId, provider: actualProvider,
|
|
1080
1657
|
writeScope: config.writeScope, providerExecutableBinding: config.providerExecutableBinding, promptRecord,
|
|
1081
1658
|
maskedReadPaths: config.maskedReadPaths,
|
|
1659
|
+
councilToolIsolation: config.councilToolIsolation === true,
|
|
1660
|
+
executionAdmission: config.afterAuthoritySpawn !== undefined,
|
|
1661
|
+
authorityContext,
|
|
1082
1662
|
});
|
|
1663
|
+
darwinLaunch = containment?.darwinLaunch;
|
|
1083
1664
|
if (!containment && config.providerExecutableBinding !== undefined) {
|
|
1084
|
-
if (process.platform
|
|
1665
|
+
if (process.platform === 'darwin') {
|
|
1666
|
+
darwinLaunch = prepareDarwinSealedLaunch({
|
|
1667
|
+
binding: config.providerExecutableBinding, harnessId: actualProvider,
|
|
1668
|
+
projectDir: cwd, args: spawnArgs, hasPrompt: Boolean(promptRecord),
|
|
1669
|
+
promptDirectory: promptRecord?.directory,
|
|
1670
|
+
});
|
|
1671
|
+
} else if (process.platform === 'linux') {
|
|
1672
|
+
sealedProvider = openPinnedProviderExecutable(config.providerExecutableBinding, {
|
|
1673
|
+
harnessId: actualProvider,
|
|
1674
|
+
projectDir: cwd,
|
|
1675
|
+
});
|
|
1676
|
+
} else {
|
|
1085
1677
|
throw Object.assign(
|
|
1086
|
-
new Error('sealed Planning provider execution
|
|
1678
|
+
new Error('sealed Planning provider execution is unavailable on this platform'),
|
|
1087
1679
|
{ code: 'PROVIDER_EXECUTABLE_UNSUPPORTED' },
|
|
1088
1680
|
);
|
|
1089
1681
|
}
|
|
1090
|
-
sealedProvider = openPinnedProviderExecutable(config.providerExecutableBinding, {
|
|
1091
|
-
harnessId: actualProvider,
|
|
1092
|
-
projectDir: cwd,
|
|
1093
|
-
});
|
|
1094
1682
|
}
|
|
1095
1683
|
const auditProjectDir = config.auditProjectDir ? resolve(config.auditProjectDir) : null;
|
|
1096
1684
|
if (containment && auditProjectDir && auditProjectDir !== resolve(cwd)
|
|
@@ -1121,28 +1709,9 @@ export function spawnTerminal(config) {
|
|
|
1121
1709
|
}
|
|
1122
1710
|
|
|
1123
1711
|
if (config.beforeProcessSpawn) config.beforeProcessSpawn();
|
|
1712
|
+
if (darwinLaunch) assertDarwinSealedLaunch(darwinLaunch);
|
|
1124
1713
|
if (containment) {
|
|
1125
|
-
|
|
1126
|
-
currentBubblewrapTrust(containment.inheritedDescriptors[0]);
|
|
1127
|
-
digestPinnedExecutable(containment.inheritedDescriptors[0], containment.trustedExecutable);
|
|
1128
|
-
const providerExecutable = digestProviderExecutable(
|
|
1129
|
-
containment.providerExecutableDescriptor,
|
|
1130
|
-
containment.trustedProviderExecutable.snapshot,
|
|
1131
|
-
);
|
|
1132
|
-
if (providerExecutable.sha256 !== containment.providerExecutableBinding.executable_sha256) {
|
|
1133
|
-
throw Object.assign(new Error('provider executable changed after installation binding validation'), { code: 'PROVIDER_EXECUTABLE_DRIFT' });
|
|
1134
|
-
}
|
|
1135
|
-
containment.supportingProviderExecutableDescriptors.forEach((descriptor, index) => {
|
|
1136
|
-
const supportingExecutable = digestProviderExecutable(
|
|
1137
|
-
descriptor, containment.trustedSupportingProviderExecutables[index].snapshot,
|
|
1138
|
-
);
|
|
1139
|
-
if (supportingExecutable.sha256 !== containment.supportingProviderExecutableBindings[index].executable_sha256) {
|
|
1140
|
-
throw Object.assign(new Error('provider supporting executable changed after installation binding validation'), { code: 'PROVIDER_EXECUTABLE_DRIFT' });
|
|
1141
|
-
}
|
|
1142
|
-
});
|
|
1143
|
-
assertPinnedDirectoryChain(containment.pinnedProjectChain);
|
|
1144
|
-
for (const chain of containment.maskedReadChains) assertPinnedDirectoryChain(chain);
|
|
1145
|
-
assertPinnedWriteScopes(containment.pinnedWriteScopes);
|
|
1714
|
+
assertAuthorityContainmentStable(containment);
|
|
1146
1715
|
} else if (sealedProvider) {
|
|
1147
1716
|
const providerExecutable = digestProviderExecutable(
|
|
1148
1717
|
sealedProvider.descriptor,
|
|
@@ -1155,26 +1724,65 @@ export function spawnTerminal(config) {
|
|
|
1155
1724
|
if (executionWorktreeBaseline) {
|
|
1156
1725
|
executionWorktreeBaseline = assertRailExecutionWorktreeBaselineUnchanged(executionWorktreeBaseline);
|
|
1157
1726
|
}
|
|
1727
|
+
if (darwinLaunch?.supervisorChannels) darwinHostBootIdentity = captureDarwinHostBootIdentity();
|
|
1728
|
+
if (containment?.admissionHelper || (darwinLaunch?.supervisorChannels && config.afterAuthoritySpawn)) {
|
|
1729
|
+
if (containment.admissionHelper) processInfoPipe = [...createLinuxProcessInfoPipe()];
|
|
1730
|
+
if (!executionWorktreeBaseline) {
|
|
1731
|
+
throw Object.assign(new Error('execution admission requires an observed canonical repository baseline'), { code: 'EXECUTION_ADMISSION_BASE_REQUIRED' });
|
|
1732
|
+
}
|
|
1733
|
+
executionAdmissionReceipt = terminalAuthorityReceipt({
|
|
1734
|
+
schema_version: 1, purpose: 'terminal_execution_admission', authority_kind: 'rail_execution',
|
|
1735
|
+
execution_admission_protocol: 'native-positive-go-v1',
|
|
1736
|
+
execution_admission_helper_sha256: containment.admissionHelper?.executable.digest ?? darwinLaunch.helperSha256,
|
|
1737
|
+
invocation_id: authorityContext.invocation_id, launch_nonce: authorityContext.launch_nonce,
|
|
1738
|
+
terminal_id: terminalId, task_id: config.taskId,
|
|
1739
|
+
supervisor_process_id: `process://chati/${process.pid}`, supervisor_hostname: hostname(),
|
|
1740
|
+
...(darwinHostBootIdentity ? { darwin_host_boot_identity: darwinHostBootIdentity } : {}),
|
|
1741
|
+
working_dir: resolve(cwd), project_identity_digest: containment.projectIdentityDigest,
|
|
1742
|
+
prompt_digest: sha256(config.prompt ?? ''), provider_id: authorityContext.provider_id,
|
|
1743
|
+
harness_id: actualProvider, model_id: actualModel, reasoning_configuration: actualReasoningConfiguration,
|
|
1744
|
+
provider_executable_path: containment.providerExecutableBinding.executable_path,
|
|
1745
|
+
provider_executable_sha256: containment.providerExecutableBinding.executable_sha256,
|
|
1746
|
+
execution_base_commit_ref: executionWorktreeBaseline.base_commit_ref,
|
|
1747
|
+
execution_launch_head_ref: executionWorktreeBaseline.launch_head_ref,
|
|
1748
|
+
execution_repository_identity_digest: executionWorktreeBaseline.repository_identity_digest,
|
|
1749
|
+
execution_preexisting_commits_digest: executionWorktreeBaseline.preexisting_commits_digest,
|
|
1750
|
+
execution_worktree_clean: true,
|
|
1751
|
+
});
|
|
1752
|
+
}
|
|
1158
1753
|
if (config.beforeAuthoritySpawn) {
|
|
1159
1754
|
config.beforeAuthoritySpawn(Object.freeze({
|
|
1160
1755
|
working_dir: resolve(cwd),
|
|
1161
1756
|
project_identity_digest: containment.projectIdentityDigest,
|
|
1757
|
+
...(executionAdmissionReceipt ? { admission_receipt: executionAdmissionReceipt } : {}),
|
|
1162
1758
|
}));
|
|
1759
|
+
runTerminalSecurityTestHook('afterAuthorityLaunchReservation', {});
|
|
1163
1760
|
}
|
|
1164
1761
|
if (executionWorktreeBaseline) {
|
|
1165
1762
|
executionWorktreeBaseline = assertRailExecutionWorktreeBaselineUnchanged(executionWorktreeBaseline);
|
|
1166
1763
|
}
|
|
1167
1764
|
const sealedProviderDescriptorNumber = 3 + (promptRecord ? 1 : 0);
|
|
1168
1765
|
const spawnCommand = containment?.command
|
|
1766
|
+
?? darwinLaunch?.command
|
|
1169
1767
|
?? (sealedProvider ? `/proc/self/fd/${sealedProviderDescriptorNumber}` : command);
|
|
1170
|
-
|
|
1171
|
-
|
|
1768
|
+
processStartedTick = process.hrtime.bigint();
|
|
1769
|
+
child = spawn(spawnCommand, containment?.args ?? darwinLaunch?.args ?? spawnArgs, {
|
|
1770
|
+
cwd: containment || darwinLaunch ? '/' : cwd,
|
|
1172
1771
|
env: {
|
|
1173
|
-
...(containment ? cleanAuthorityEnv(process.env, actualProvider
|
|
1174
|
-
|
|
1772
|
+
...(containment ? cleanAuthorityEnv(process.env, actualProvider, {
|
|
1773
|
+
allowProviderSecrets: config.councilToolIsolation !== true,
|
|
1774
|
+
}) : cleanParentEnv(process.env, {
|
|
1775
|
+
provider: actualProvider, allowProviderSecrets: config.councilToolIsolation !== true,
|
|
1776
|
+
})), ...env,
|
|
1777
|
+
...(containment && councilClaudeTransportAuth && typeof process.env.CLAUDE_CODE_OAUTH_TOKEN === 'string'
|
|
1778
|
+
? { CLAUDE_CODE_OAUTH_TOKEN: process.env.CLAUDE_CODE_OAUTH_TOKEN } : {}),
|
|
1779
|
+
...(containment ? Object.fromEntries(['TMPDIR', 'TMP', 'TEMP'].map(key => [key, darwinLaunch?.temporaryDirectory ?? '/tmp'])) : {}),
|
|
1175
1780
|
},
|
|
1176
|
-
stdio:
|
|
1177
|
-
? ['pipe', 'pipe', 'pipe',
|
|
1781
|
+
stdio: darwinLaunch
|
|
1782
|
+
? ['pipe', 'pipe', 'pipe', promptRecord ? promptRecord.descriptor : 'ignore', darwinLaunch.projectDescriptor,
|
|
1783
|
+
...(darwinLaunch.supervisorChannels?.inheritedDescriptors ?? [])]
|
|
1784
|
+
: containment
|
|
1785
|
+
? ['pipe', 'pipe', 'pipe', ...containment.inheritedDescriptors, ...(containment.admissionHelper ? ['pipe', processInfoPipe[1]] : [])]
|
|
1178
1786
|
: [
|
|
1179
1787
|
'pipe', 'pipe', 'pipe',
|
|
1180
1788
|
...(promptRecord ? [promptRecord.descriptor] : []),
|
|
@@ -1182,23 +1790,49 @@ export function spawnTerminal(config) {
|
|
|
1182
1790
|
],
|
|
1183
1791
|
detached: detachedProcessGroup,
|
|
1184
1792
|
});
|
|
1793
|
+
// Post-spawn identity reads may throw synchronously. Register ownership
|
|
1794
|
+
// cleanup first so the error path cannot strand a private file prompt.
|
|
1185
1795
|
if (promptRecord) {
|
|
1186
1796
|
child.once('exit', () => cleanupPrivatePromptFile(promptRecord));
|
|
1187
1797
|
child.once('error', () => cleanupPrivatePromptFile(promptRecord));
|
|
1188
1798
|
}
|
|
1799
|
+
if (darwinLaunch) {
|
|
1800
|
+
// Until the trusted runtime owns completion, failed setup still owns its
|
|
1801
|
+
// resources. Authority launches must keep the completion pipe alive past
|
|
1802
|
+
// exit and read it only after stdio has drained on close.
|
|
1803
|
+
if (darwinLaunch.supervisorChannels) {
|
|
1804
|
+
child.once('close', () => { if (!darwinRuntimeOwned) disposeDarwin(true); });
|
|
1805
|
+
} else child.once('exit', () => disposeDarwin(true));
|
|
1806
|
+
child.once('error', () => { if (!darwinRuntimeOwned) disposeDarwin(); });
|
|
1807
|
+
}
|
|
1808
|
+
if (processInfoPipe) {
|
|
1809
|
+
closeSync(processInfoPipe[1]); processInfoPipe[1] = null;
|
|
1810
|
+
const info = JSON.parse(readLinuxProcessInfoPipe(processInfoPipe[0]));
|
|
1811
|
+
runTerminalSecurityTestHook('beforeLinuxNamespaceIdentityCapture', { info, child });
|
|
1812
|
+
executionNamespaceIdentity = captureLinuxNamespaceIdentity(info, child.pid);
|
|
1813
|
+
}
|
|
1814
|
+
if (darwinLaunch?.supervisorChannels) {
|
|
1815
|
+
darwinReady = darwinLaunch.supervisorChannels.readReady(child.pid, darwinLaunch.verifiedCdhash);
|
|
1816
|
+
assertAuthorityContainmentStable(containment);
|
|
1817
|
+
}
|
|
1189
1818
|
if (containment) {
|
|
1190
1819
|
assertPinnedDirectoryChain(containment.pinnedProjectChain);
|
|
1191
1820
|
}
|
|
1192
1821
|
} catch (error) {
|
|
1822
|
+
if (darwinLaunch?.supervisorChannels) {
|
|
1823
|
+
try { darwinLaunch.supervisorChannels.dispose(); } catch (cleanupError) { error.darwinChannelCleanupError = cleanupError; }
|
|
1824
|
+
}
|
|
1193
1825
|
if (child?.pid) {
|
|
1194
1826
|
try { signalProcessTree(child, detachedProcessGroup ? child.pid : null, 'SIGKILL'); } catch { /* reservation remains fail-closed */ }
|
|
1195
1827
|
}
|
|
1196
1828
|
throw error;
|
|
1197
1829
|
} finally {
|
|
1830
|
+
for (const fd of processInfoPipe ?? []) if (fd !== null) closeSync(fd);
|
|
1198
1831
|
if (containment) closeAuthorityContainment(containment);
|
|
1199
1832
|
if (sealedProvider?.descriptor !== undefined) try { closeSync(sealedProvider.descriptor); } catch { /* child inherited its own descriptor */ }
|
|
1200
1833
|
if (promptRecord) try { closeSync(promptRecord.descriptor); } catch { /* containment already closed it */ }
|
|
1201
1834
|
if (promptRecord && !child) cleanupPrivatePromptFile(promptRecord);
|
|
1835
|
+
if (darwinLaunch && !child) disposeDarwin();
|
|
1202
1836
|
}
|
|
1203
1837
|
const processGroupId = detachedProcessGroup ? child.pid : null;
|
|
1204
1838
|
|
|
@@ -1222,13 +1856,17 @@ export function spawnTerminal(config) {
|
|
|
1222
1856
|
stderrBytes: 0,
|
|
1223
1857
|
outputOverflow: false,
|
|
1224
1858
|
completedAt: null,
|
|
1859
|
+
processElapsedMs: null,
|
|
1225
1860
|
killRequested: false,
|
|
1226
1861
|
finalized: false,
|
|
1227
1862
|
exitPromise: new Promise((resolveExit) => { resolveAuthorityExit = resolveExit; }),
|
|
1228
1863
|
cleanupFailureSignal: new Promise((resolveFailure) => { signalAuthorityCleanupFailure = resolveFailure; }),
|
|
1864
|
+
signalCleanupFailure: signalAuthorityCleanupFailure,
|
|
1229
1865
|
timeoutCleanupPromise: null,
|
|
1230
1866
|
timeoutCleanupSettled: false,
|
|
1231
1867
|
timeoutCleanupFailure: null,
|
|
1868
|
+
darwinCompletion: null,
|
|
1869
|
+
darwinStopPromise: null,
|
|
1232
1870
|
};
|
|
1233
1871
|
const processFacade = runtime ? new EventEmitter() : child;
|
|
1234
1872
|
if (runtime) {
|
|
@@ -1238,7 +1876,9 @@ export function spawnTerminal(config) {
|
|
|
1238
1876
|
});
|
|
1239
1877
|
Object.defineProperty(processFacade, 'kill', {
|
|
1240
1878
|
enumerable: true,
|
|
1241
|
-
value: (signal) =>
|
|
1879
|
+
value: (signal) => darwinLaunch?.supervisorChannels
|
|
1880
|
+
? (child.exitCode === null && child.signalCode === null ? child.kill(signal) : false)
|
|
1881
|
+
: signalProcessTree(child, processGroupId, signal),
|
|
1242
1882
|
});
|
|
1243
1883
|
}
|
|
1244
1884
|
|
|
@@ -1255,6 +1895,21 @@ export function spawnTerminal(config) {
|
|
|
1255
1895
|
startedAt: new Date().toISOString(),
|
|
1256
1896
|
timeout,
|
|
1257
1897
|
};
|
|
1898
|
+
const diagnosticObservation = {
|
|
1899
|
+
timeout, elapsedMs: null, exitCode: null, timedOut: false, terminationCause: 'unknown',
|
|
1900
|
+
};
|
|
1901
|
+
TERMINAL_DIAGNOSTIC_OBSERVATIONS.set(handle, diagnosticObservation);
|
|
1902
|
+
const observeTermination = (code, signal, error = false) => {
|
|
1903
|
+
if (diagnosticObservation.elapsedMs !== null) return;
|
|
1904
|
+
diagnosticObservation.elapsedMs = Number(process.hrtime.bigint() - processStartedTick) / 1e6;
|
|
1905
|
+
diagnosticObservation.exitCode = Number.isInteger(code) ? code : null;
|
|
1906
|
+
if (diagnosticObservation.terminationCause === 'unknown') {
|
|
1907
|
+
diagnosticObservation.terminationCause = error ? 'spawn_error'
|
|
1908
|
+
: signal ? 'signal' : Number.isInteger(code) ? 'exit' : 'unknown';
|
|
1909
|
+
}
|
|
1910
|
+
};
|
|
1911
|
+
child.once(runtime ? 'close' : 'exit', (code, signal) => observeTermination(code, signal));
|
|
1912
|
+
child.once('error', () => observeTermination(null, null, true));
|
|
1258
1913
|
|
|
1259
1914
|
if (runtime) {
|
|
1260
1915
|
Object.defineProperties(handle, {
|
|
@@ -1277,7 +1932,8 @@ export function spawnTerminal(config) {
|
|
|
1277
1932
|
provider_executable_path: containment.providerExecutableBinding.executable_path,
|
|
1278
1933
|
provider_executable_sha256: containment.providerExecutableBinding.executable_sha256,
|
|
1279
1934
|
});
|
|
1280
|
-
|
|
1935
|
+
let launchReceipt;
|
|
1936
|
+
try { launchReceipt = terminalAuthorityReceipt({
|
|
1281
1937
|
schema_version: 1,
|
|
1282
1938
|
purpose: 'terminal_launch',
|
|
1283
1939
|
authority_kind: context.authority_kind,
|
|
@@ -1290,6 +1946,17 @@ export function spawnTerminal(config) {
|
|
|
1290
1946
|
process_containment_kind: containment.kind,
|
|
1291
1947
|
process_containment_id: containment.id,
|
|
1292
1948
|
process_containment_digest: containment.digest,
|
|
1949
|
+
...(executionNamespaceIdentity ? { process_namespace_identity: executionNamespaceIdentity } : {}),
|
|
1950
|
+
...(darwinReady ? {
|
|
1951
|
+
darwin_launch_binding: createDarwinTerminalLaunchBinding({ helperSha256: darwinLaunch.helperSha256,
|
|
1952
|
+
policyDigest: darwinLaunch.policyDigest, ready: darwinReady, writeScopes: containment.pinnedWriteScopes,
|
|
1953
|
+
hostBootIdentity: darwinHostBootIdentity }),
|
|
1954
|
+
} : {}),
|
|
1955
|
+
...(executionAdmissionReceipt ? {
|
|
1956
|
+
execution_admission_protocol: 'native-positive-go-v1',
|
|
1957
|
+
execution_admission_helper_sha256: containment.admissionHelper?.executable.digest ?? darwinLaunch.helperSha256,
|
|
1958
|
+
execution_admission_receipt_digest: sha256(executionAdmissionReceipt),
|
|
1959
|
+
} : {}),
|
|
1293
1960
|
project_identity_digest: containment.projectIdentityDigest,
|
|
1294
1961
|
agent: config.agent,
|
|
1295
1962
|
task_id: config.taskId,
|
|
@@ -1314,7 +1981,13 @@ export function spawnTerminal(config) {
|
|
|
1314
1981
|
execution_worktree_clean: true,
|
|
1315
1982
|
} : {}),
|
|
1316
1983
|
started_at: handle.startedAt,
|
|
1317
|
-
});
|
|
1984
|
+
}); } catch (error) {
|
|
1985
|
+
if (darwinLaunch?.supervisorChannels) {
|
|
1986
|
+
try { darwinLaunch.supervisorChannels.dispose(); } catch (cleanupError) { error.darwinChannelCleanupError = cleanupError; }
|
|
1987
|
+
try { signalProcessTree(child, processGroupId, 'SIGKILL'); } catch { /* no positive GO has been sent */ }
|
|
1988
|
+
}
|
|
1989
|
+
throw error;
|
|
1990
|
+
}
|
|
1318
1991
|
TERMINAL_AUTHORITY_STATE.set(handle, {
|
|
1319
1992
|
context: Object.freeze({ ...context }),
|
|
1320
1993
|
identity,
|
|
@@ -1338,6 +2011,7 @@ export function spawnTerminal(config) {
|
|
|
1338
2011
|
if (runtime[key] + bytes > MAX_AUTHORITY_OUTPUT_BYTES) {
|
|
1339
2012
|
if (!runtime.outputOverflow) {
|
|
1340
2013
|
runtime.outputOverflow = true;
|
|
2014
|
+
if (diagnosticObservation.terminationCause === 'unknown') diagnosticObservation.terminationCause = 'output_limit';
|
|
1341
2015
|
runtime.stderr.push(`terminal output exceeded ${MAX_AUTHORITY_OUTPUT_BYTES} byte authority limit`);
|
|
1342
2016
|
runtime.killRequested = true;
|
|
1343
2017
|
try { signalProcessTree(child, processGroupId, 'SIGKILL'); } catch { /* process already exited */ }
|
|
@@ -1375,6 +2049,18 @@ export function spawnTerminal(config) {
|
|
|
1375
2049
|
runtime.status = runtime.killRequested ? 'killed' : 'exited';
|
|
1376
2050
|
runtime.exitCode = Number.isInteger(code) ? code : -1;
|
|
1377
2051
|
runtime.completedAt = new Date().toISOString();
|
|
2052
|
+
runtime.processElapsedMs = Number(process.hrtime.bigint() - processStartedTick) / 1e6;
|
|
2053
|
+
if (darwinLaunch?.supervisorChannels) {
|
|
2054
|
+
try {
|
|
2055
|
+
if (error) throw error;
|
|
2056
|
+
runtime.darwinCompletion = darwinLaunch.supervisorChannels.readCompletion(code);
|
|
2057
|
+
} catch (completionError) { darwinCompletionError = completionError; }
|
|
2058
|
+
disposeDarwin(true);
|
|
2059
|
+
if (darwinCompletionError) {
|
|
2060
|
+
runtime.timeoutCleanupFailure = darwinCompletionError;
|
|
2061
|
+
signalAuthorityCleanupFailure(darwinCompletionError);
|
|
2062
|
+
}
|
|
2063
|
+
}
|
|
1378
2064
|
if (error) runtime.stderr.push(`spawn error: ${error.message}`);
|
|
1379
2065
|
if (error && processFacade.listenerCount('error') > 0) processFacade.emit('error', error);
|
|
1380
2066
|
processFacade.emit('exit', runtime.exitCode);
|
|
@@ -1385,10 +2071,12 @@ export function spawnTerminal(config) {
|
|
|
1385
2071
|
// never omit output that arrived after the OS process exited.
|
|
1386
2072
|
child.once('close', (code) => finalize(code));
|
|
1387
2073
|
child.once('error', (error) => finalize(-1, error));
|
|
2074
|
+
darwinRuntimeOwned = Boolean(darwinLaunch?.supervisorChannels);
|
|
1388
2075
|
} else {
|
|
1389
2076
|
child.on('exit', (code) => {
|
|
1390
2077
|
handle.status = 'exited';
|
|
1391
|
-
handle.exitCode = code;
|
|
2078
|
+
handle.exitCode = darwinCompletionError ? -1 : code;
|
|
2079
|
+
if (darwinCompletionError) handle.stderr.push(`Darwin sealed execution cleanup: ${darwinCompletionError.message}`);
|
|
1392
2080
|
});
|
|
1393
2081
|
|
|
1394
2082
|
child.on('error', (err) => {
|
|
@@ -1401,6 +2089,8 @@ export function spawnTerminal(config) {
|
|
|
1401
2089
|
// Enforce timeout — kill process if it exceeds max execution time
|
|
1402
2090
|
const timeoutTimer = setTimeout(() => {
|
|
1403
2091
|
if (handle.status === 'running') {
|
|
2092
|
+
diagnosticObservation.timedOut = true;
|
|
2093
|
+
if (diagnosticObservation.terminationCause === 'unknown') diagnosticObservation.terminationCause = 'runtime_timeout';
|
|
1404
2094
|
(runtime?.stderr ?? handle.stderr).push(`timeout: process exceeded ${timeout}ms limit`);
|
|
1405
2095
|
if (runtime) {
|
|
1406
2096
|
runtime.timeoutCleanupPromise = killTerminal(handle).then(
|
|
@@ -1426,6 +2116,32 @@ export function spawnTerminal(config) {
|
|
|
1426
2116
|
// Clear timer when process exits normally
|
|
1427
2117
|
child.on('close', () => clearTimeout(timeoutTimer));
|
|
1428
2118
|
|
|
2119
|
+
if (config.afterAuthoritySpawn || darwinLaunch?.supervisorChannels) {
|
|
2120
|
+
const channel = darwinLaunch?.supervisorChannels ? null : child.stdio[containment.admissionControlDescriptorNumber];
|
|
2121
|
+
// No provider code has executed. A failed commit closes the private channel
|
|
2122
|
+
// and the native helper rejects EOF; it never interprets closure as GO.
|
|
2123
|
+
channel?.on('error', () => {});
|
|
2124
|
+
try {
|
|
2125
|
+
// Hand ownership to the caller before invoking its journal write, so a
|
|
2126
|
+
// synchronous failure still allows it to await normal cleanup proof.
|
|
2127
|
+
const committed = config.afterAuthoritySpawn?.(getTerminalLaunchAuthorityReceipt(handle), handle);
|
|
2128
|
+
if (committed && typeof committed.then === 'function') {
|
|
2129
|
+
Promise.resolve(committed).catch(() => {});
|
|
2130
|
+
throw Object.assign(new Error('execution admission cannot await an uncommitted asynchronous dispatch'), { code: 'ASYNC_TERMINAL_ADMISSION_FORBIDDEN' });
|
|
2131
|
+
}
|
|
2132
|
+
runTerminalSecurityTestHook('beforeExecutionAdmissionGo', { handle });
|
|
2133
|
+
if (darwinLaunch?.supervisorChannels) darwinLaunch.supervisorChannels.authorize();
|
|
2134
|
+
else channel.end('G');
|
|
2135
|
+
} catch (error) {
|
|
2136
|
+
if (darwinLaunch?.supervisorChannels) {
|
|
2137
|
+
try { darwinLaunch.supervisorChannels.dispose(); } catch (cleanupError) { error.darwinChannelCleanupError = cleanupError; }
|
|
2138
|
+
} else channel.destroy();
|
|
2139
|
+
runtime.killRequested = true;
|
|
2140
|
+
try { signalProcessTree(child, processGroupId, 'SIGKILL'); } catch { /* gate still rejects EOF */ }
|
|
2141
|
+
throw error;
|
|
2142
|
+
}
|
|
2143
|
+
}
|
|
2144
|
+
|
|
1429
2145
|
return handle;
|
|
1430
2146
|
}
|
|
1431
2147
|
|
|
@@ -1572,9 +2288,23 @@ export function killTerminal(handle) {
|
|
|
1572
2288
|
return Promise.resolve({ killed: false, exitCode: handle?.exitCode ?? null });
|
|
1573
2289
|
}
|
|
1574
2290
|
|
|
2291
|
+
const diagnosticObservation = TERMINAL_DIAGNOSTIC_OBSERVATIONS.get(handle);
|
|
2292
|
+
if (diagnosticObservation?.elapsedMs === null && diagnosticObservation.terminationCause === 'unknown') {
|
|
2293
|
+
diagnosticObservation.terminationCause = 'stop_requested';
|
|
2294
|
+
}
|
|
2295
|
+
|
|
1575
2296
|
const authorityState = TERMINAL_AUTHORITY_STATE.get(handle);
|
|
1576
2297
|
const rawProcess = authorityState?.runtime?.child ?? handle.process;
|
|
1577
2298
|
const processGroupId = authorityState?.runtime?.processGroupId ?? null;
|
|
2299
|
+
if (authorityState?.launchReceipt.darwin_launch_binding) {
|
|
2300
|
+
const { runtime } = authorityState;
|
|
2301
|
+
runtime.darwinStopPromise ??= stopDarwinAuthority(runtime).catch(error => {
|
|
2302
|
+
runtime.timeoutCleanupFailure = error;
|
|
2303
|
+
runtime.signalCleanupFailure(error);
|
|
2304
|
+
throw error;
|
|
2305
|
+
});
|
|
2306
|
+
return runtime.darwinStopPromise;
|
|
2307
|
+
}
|
|
1578
2308
|
if (authorityState) {
|
|
1579
2309
|
authorityState.runtime.killRequested = true;
|
|
1580
2310
|
return (async () => {
|
|
@@ -1702,7 +2432,7 @@ export function isTransientFailure(exitCode, stderr) {
|
|
|
1702
2432
|
* Non-transient failures are returned immediately without retry.
|
|
1703
2433
|
*
|
|
1704
2434
|
* @param {SpawnConfig} config
|
|
1705
|
-
* @param {{ maxRetries?: number, baseDelay?: number, shouldRetry?: function }} [retryOptions={}]
|
|
2435
|
+
* @param {{ maxRetries?: number, baseDelay?: number, shouldRetry?: function, beforeAttempt?: function }} [retryOptions={}]
|
|
1706
2436
|
* @returns {Promise<TerminalHandle>}
|
|
1707
2437
|
*/
|
|
1708
2438
|
export async function spawnTerminalWithRetry(config, retryOptions = {}) {
|
|
@@ -1713,57 +2443,84 @@ export async function spawnTerminalWithRetry(config, retryOptions = {}) {
|
|
|
1713
2443
|
const enableModelFallback = retryOptions.enableModelFallback === true;
|
|
1714
2444
|
|
|
1715
2445
|
let lastHandle = null;
|
|
1716
|
-
|
|
1717
|
-
|
|
1718
|
-
|
|
1719
|
-
|
|
1720
|
-
|
|
1721
|
-
|
|
2446
|
+
let currentHandle = null;
|
|
2447
|
+
let exceptionStage = null;
|
|
2448
|
+
const attempts = [];
|
|
2449
|
+
const observeCurrent = () => {
|
|
2450
|
+
if (!currentHandle) return;
|
|
2451
|
+
const observation = terminalAttemptSnapshot(currentHandle, attempts.length);
|
|
2452
|
+
if (observation) attempts.push(observation);
|
|
2453
|
+
currentHandle = null;
|
|
2454
|
+
};
|
|
2455
|
+
const spawnObserved = async (attemptConfig, beforeAttempt) => {
|
|
2456
|
+
exceptionStage = 'before_attempt';
|
|
2457
|
+
beforeAttempt?.();
|
|
2458
|
+
exceptionStage = 'spawn';
|
|
2459
|
+
currentHandle = spawnTerminal(attemptConfig);
|
|
2460
|
+
const handle = currentHandle;
|
|
2461
|
+
exceptionStage = 'wait';
|
|
1722
2462
|
await waitForTerminal(handle);
|
|
2463
|
+
observeCurrent();
|
|
2464
|
+
exceptionStage = null;
|
|
2465
|
+
return handle;
|
|
2466
|
+
};
|
|
1723
2467
|
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
handle
|
|
1727
|
-
|
|
1728
|
-
}
|
|
1729
|
-
|
|
1730
|
-
// Check if failure is transient and retries remain
|
|
1731
|
-
const combinedOutput = [...(handle.stdout || []), ...(handle.stderr || [])];
|
|
1732
|
-
if (attempt < maxRetries && shouldRetry(handle.exitCode, combinedOutput, handle)) {
|
|
1733
|
-
const delay = baseDelay * Math.pow(2, attempt);
|
|
1734
|
-
await new Promise(resolve => setTimeout(resolve, delay));
|
|
1735
|
-
continue;
|
|
1736
|
-
}
|
|
1737
|
-
|
|
1738
|
-
// Non-transient or out of retries — return as-is
|
|
1739
|
-
break;
|
|
1740
|
-
}
|
|
2468
|
+
try {
|
|
2469
|
+
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
|
2470
|
+
const handle = await spawnObserved(config, () => retryOptions.beforeAttempt?.({ attempt }));
|
|
2471
|
+
lastHandle = handle;
|
|
1741
2472
|
|
|
1742
|
-
|
|
1743
|
-
if (enableModelFallback && lastHandle && lastHandle.exitCode !== 0) {
|
|
1744
|
-
const stderrStr = Array.isArray(lastHandle.stderr) ? lastHandle.stderr.join('') : (lastHandle.stderr || '');
|
|
1745
|
-
const isOverload = OVERLOAD_PATTERNS.some(p => p.test(stderrStr));
|
|
1746
|
-
const fallbackModel = MODEL_DOWNGRADE_MAP[config.model];
|
|
2473
|
+
handle.retryCount = attempt;
|
|
1747
2474
|
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
2475
|
+
// Success — return immediately
|
|
2476
|
+
if (handle.exitCode === 0) {
|
|
2477
|
+
handle.retryCount = attempt;
|
|
2478
|
+
return rememberAttemptDiagnostics(handle, attempts);
|
|
2479
|
+
}
|
|
1751
2480
|
|
|
1752
|
-
|
|
2481
|
+
// Check if failure is transient and retries remain
|
|
2482
|
+
const combinedOutput = [...(handle.stdout || []), ...(handle.stderr || [])];
|
|
2483
|
+
exceptionStage = 'retry_decision';
|
|
2484
|
+
const retry = attempt < maxRetries && shouldRetry(handle.exitCode, combinedOutput, handle);
|
|
2485
|
+
exceptionStage = null;
|
|
2486
|
+
if (retry) {
|
|
2487
|
+
const delay = baseDelay * Math.pow(2, attempt);
|
|
2488
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
2489
|
+
continue;
|
|
2490
|
+
}
|
|
1753
2491
|
|
|
1754
|
-
//
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
actual: fallbackModel,
|
|
1758
|
-
reason: 'overload_exhausted_retries',
|
|
1759
|
-
timestamp: new Date().toISOString(),
|
|
1760
|
-
};
|
|
2492
|
+
// Non-transient or out of retries — return as-is
|
|
2493
|
+
break;
|
|
2494
|
+
}
|
|
1761
2495
|
|
|
1762
|
-
|
|
1763
|
-
|
|
2496
|
+
// Model fallback: if all retries exhausted with overload errors, try with downgraded model
|
|
2497
|
+
if (enableModelFallback && lastHandle && lastHandle.exitCode !== 0) {
|
|
2498
|
+
const stderrStr = Array.isArray(lastHandle.stderr) ? lastHandle.stderr.join('') : (lastHandle.stderr || '');
|
|
2499
|
+
const isOverload = OVERLOAD_PATTERNS.some(p => p.test(stderrStr));
|
|
2500
|
+
const fallbackModel = MODEL_DOWNGRADE_MAP[config.model];
|
|
2501
|
+
|
|
2502
|
+
if (isOverload && fallbackModel) {
|
|
2503
|
+
const fallbackConfig = { ...config, model: fallbackModel };
|
|
2504
|
+
const fallbackHandle = await spawnObserved(fallbackConfig);
|
|
2505
|
+
|
|
2506
|
+
// Record fallback metadata
|
|
2507
|
+
fallbackHandle.modelFallback = {
|
|
2508
|
+
original: config.model,
|
|
2509
|
+
actual: fallbackModel,
|
|
2510
|
+
reason: 'overload_exhausted_retries',
|
|
2511
|
+
timestamp: new Date().toISOString(),
|
|
2512
|
+
};
|
|
2513
|
+
|
|
2514
|
+
fallbackHandle.retryCount = maxRetries;
|
|
2515
|
+
return rememberAttemptDiagnostics(fallbackHandle, attempts);
|
|
2516
|
+
}
|
|
1764
2517
|
}
|
|
1765
|
-
}
|
|
1766
2518
|
|
|
1767
|
-
|
|
1768
|
-
|
|
2519
|
+
if (lastHandle) lastHandle.retryCount = lastHandle.retryCount ?? maxRetries;
|
|
2520
|
+
return rememberAttemptDiagnostics(lastHandle, attempts);
|
|
2521
|
+
} catch (error) {
|
|
2522
|
+
observeCurrent();
|
|
2523
|
+
rememberAttemptDiagnostics(error, attempts, exceptionStage);
|
|
2524
|
+
throw error;
|
|
2525
|
+
}
|
|
1769
2526
|
}
|