chati-dev 4.5.16 → 4.5.27
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -3
- package/bin/chati.js +124 -66
- package/framework/agents/build/dev.md +5 -5
- package/framework/agents/deploy/devops.md +7 -7
- package/framework/agents/discover/brief.md +4 -4
- package/framework/agents/discover/brownfield-wu.md +3 -3
- package/framework/agents/discover/greenfield-wu.md +3 -3
- package/framework/agents/plan/architect.md +2 -2
- package/framework/agents/plan/detail.md +4 -4
- package/framework/agents/plan/phases.md +2 -2
- package/framework/agents/plan/tasks.md +2 -2
- package/framework/agents/plan/ux.md +2 -2
- package/framework/agents/quality/qa-implementation.md +11 -10
- package/framework/agents/quality/qa-planning.md +3 -3
- package/framework/agents/quality/qa-visual.md +1 -1
- package/framework/config.yaml +3 -3
- package/framework/constitution.md +18 -18
- package/framework/context/protocols.md +1 -1
- package/framework/context/quality.md +1 -1
- package/framework/context/root.md +4 -4
- package/framework/data/entity-registry.yaml +1 -1
- package/framework/domains/agents/orchestrator.yaml +2 -2
- package/framework/domains/constitution.yaml +1 -1
- package/framework/hooks/advance-trigger.js +4 -6
- package/framework/hooks/git-push-authority.js +45 -37
- package/framework/hooks/mode-governance.js +149 -40
- package/framework/hooks/model-governance.js +13 -20
- package/framework/hooks/prism-engine.js +74 -92
- package/framework/hooks/reasoning-escalator.js +23 -40
- package/framework/hooks/session-digest.js +12 -13
- package/framework/hooks/session-reader.js +224 -0
- package/framework/hooks/session-writer.js +195 -0
- package/framework/hooks/team-quality-gate.js +34 -24
- package/framework/i18n/en.yaml +2 -2
- package/framework/i18n/es.yaml +2 -2
- package/framework/i18n/fr.yaml +2 -2
- package/framework/i18n/pt.yaml +2 -2
- package/framework/intelligence/context-engine.md +4 -4
- package/framework/intelligence/memory-layer.md +1 -1
- package/framework/manifest.json +129 -119
- package/framework/manifest.sig +1 -1
- package/framework/orchestrator/chati-router.js +252 -25
- package/framework/orchestrator/chati.md +238 -70
- package/framework/schemas/session.schema.json +1 -1
- package/framework/tasks/orchestrator-deviation.md +1 -1
- package/framework/tasks/orchestrator-escalate.md +1 -1
- package/framework/tasks/orchestrator-handoff.md +6 -6
- package/framework/tasks/orchestrator-health.md +5 -9
- package/framework/tasks/orchestrator-mode-switch.md +3 -7
- package/framework/tasks/orchestrator-resume.md +10 -14
- package/framework/tasks/orchestrator-route.md +3 -3
- package/framework/tasks/orchestrator-spawn-terminal.md +1 -1
- package/framework/tasks/orchestrator-status.md +9 -9
- package/framework/tasks/orchestrator-suggest-mode.md +1 -1
- package/framework/tasks/qa-impl-consolidate.md +2 -2
- package/framework/tasks/qa-impl-performance-test.md +4 -4
- package/framework/tasks/qa-impl-regression-check.md +4 -4
- package/framework/tasks/qa-impl-sast-scan.md +1 -1
- package/framework/tasks/qa-impl-test-execute.md +1 -1
- package/framework/tasks/qa-impl-verdict.md +2 -2
- package/framework/tasks/qa-planning-consolidate.md +3 -3
- package/framework/tasks/qa-planning-coverage-plan.md +2 -2
- package/framework/tasks/qa-planning-gate-define.md +4 -4
- package/framework/tasks/qa-planning-risk-matrix.md +4 -4
- package/framework/tasks/qa-planning-test-strategy.md +2 -2
- package/node_modules/@chati/browser-capability/src/index.js +12 -2
- package/node_modules/@chati/planning/src/index.js +24 -4
- package/node_modules/@chati/provider-registry/src/index.js +11 -0
- package/node_modules/@chati/rail/src/index.js +1967 -83
- package/node_modules/@chati/release-lane/README.md +12 -8
- package/node_modules/@chati/release-lane/package.json +1 -1
- package/node_modules/@chati/release-lane/src/index.js +1426 -58
- package/node_modules/@chati/review-council/src/index.js +63 -0
- package/node_modules/@chati/tracking-clickup/README.md +13 -0
- package/node_modules/@chati/tracking-clickup/src/index.js +690 -30
- package/package-artifact-manifest.json +1 -0
- package/package-artifact-manifest.sig +1 -0
- package/package.json +16 -7
- package/scripts/verify-real-harness-e2e.js +1581 -0
- package/src/config/framework-adapter.js +4 -4
- package/src/installer/core.js +148 -53
- package/src/installer/manifest.js +140 -9
- package/src/installer/package-artifact.js +249 -0
- package/src/installer/templates.js +65 -20
- package/src/installer-v2/catalog-client.js +531 -23
- package/src/installer-v2/index.js +91 -34
- package/src/installer-v2/installation-authority.js +327 -0
- package/src/installer-v2/provider-executable.js +235 -0
- package/src/installer-v2/wizard-installation.js +1 -1
- package/src/orchestrator/browser-runtime.js +44 -13
- package/src/orchestrator/cli.js +1545 -174
- package/src/orchestrator/clickup-projection.js +43 -1
- package/src/orchestrator/clickup-runtime.js +355 -39
- package/src/orchestrator/doctor.js +16 -3
- package/src/orchestrator/index.js +16 -0
- package/src/orchestrator/planning-runtime.js +20 -2
- package/src/orchestrator/rail-adjudication-evidence.js +234 -0
- package/src/orchestrator/rail-evidence-authority.js +147 -0
- package/src/orchestrator/rail-execution-evidence.js +45 -0
- package/src/orchestrator/rail-runtime.js +836 -56
- package/src/orchestrator/release-runtime.js +65 -6
- package/src/orchestrator/review-runtime.js +186 -41
- package/src/orchestrator/runtime-installation-v2.js +236 -20
- package/src/orchestrator/session-manager.js +1167 -53
- package/src/terminal/adapters/claude-adapter.js +3 -1
- package/src/terminal/adapters/codex-adapter.js +2 -0
- package/src/terminal/adapters/grok-adapter.js +7 -4
- package/src/terminal/handoff-parser.js +19 -1
- package/src/terminal/prompt-builder.js +9 -1
- package/src/terminal/provider-preflight.js +36 -3
- package/src/terminal/rail-execution-worktree.js +213 -0
- package/src/terminal/rail-prompts.js +169 -0
- package/src/terminal/rail-readonly-workspace.js +324 -0
- package/src/terminal/run-agent.js +384 -24
- package/src/terminal/run-parallel.js +5 -0
- package/src/terminal/run-rail-adjudication.js +323 -0
- package/src/terminal/run-rail-review.js +291 -0
- package/src/terminal/run-rail-rework.js +325 -0
- package/src/terminal/run-rail-task.js +380 -0
- package/src/terminal/run-team.js +5 -0
- package/src/terminal/spawner.js +1225 -77
- package/src/wizard/index.js +3 -2
package/src/terminal/spawner.js
CHANGED
|
@@ -7,11 +7,31 @@
|
|
|
7
7
|
* (spawnTerminal) that actually calls child_process.spawn.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
import { spawn } from 'child_process';
|
|
10
|
+
import { spawn, spawnSync } from 'child_process';
|
|
11
|
+
import { createHash } from 'node:crypto';
|
|
12
|
+
import { EventEmitter } from 'node:events';
|
|
13
|
+
import {
|
|
14
|
+
closeSync, constants, fstatSync, fsyncSync, lstatSync, mkdirSync, mkdtempSync, openSync,
|
|
15
|
+
readFileSync, readSync, realpathSync, rmSync, writeSync,
|
|
16
|
+
} from 'node:fs';
|
|
17
|
+
import { homedir, hostname, tmpdir } from 'node:os';
|
|
18
|
+
import { basename, delimiter, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
19
|
+
import { canonicalize, sha256 } from '@chati/core';
|
|
11
20
|
import { validateWriteScopes, buildIsolationEnv } from './isolation.js';
|
|
12
21
|
import { getProvider } from './cli-registry.js';
|
|
13
22
|
import { getRateLimiter } from './rate-limiter.js';
|
|
14
23
|
import { recordModelSelection } from '../orchestrator/session-manager.js';
|
|
24
|
+
import {
|
|
25
|
+
digestProviderExecutable, openPinnedProviderExecutable, openPinnedProviderSupportingExecutables,
|
|
26
|
+
} from '../installer-v2/provider-executable.js';
|
|
27
|
+
import {
|
|
28
|
+
installationAuthorityStateDirectory, signHostAuthorityPayload, verifyHostAuthorityPayload,
|
|
29
|
+
} from '../installer-v2/installation-authority.js';
|
|
30
|
+
import {
|
|
31
|
+
assertRailExecutionWorktreeBaselineUnchanged,
|
|
32
|
+
captureRailExecutionWorktree,
|
|
33
|
+
verifyRailExecutionWorktreeResult,
|
|
34
|
+
} from './rail-execution-worktree.js';
|
|
15
35
|
|
|
16
36
|
// ---------------------------------------------------------------------------
|
|
17
37
|
// Constants
|
|
@@ -38,6 +58,768 @@ const OVERLOAD_PATTERNS = [/529/, /overloaded/i, /capacity/i, /too many requests
|
|
|
38
58
|
// ---------------------------------------------------------------------------
|
|
39
59
|
|
|
40
60
|
let _counter = 0;
|
|
61
|
+
const TERMINAL_AUTHORITY_STATE = new WeakMap();
|
|
62
|
+
const TERMINAL_AUTHORITY_DOMAIN = 'chati-terminal-receipt-v1';
|
|
63
|
+
const TERMINAL_SECURITY_TEST_HOOKS = Symbol.for('chati.terminal.security-test-hooks');
|
|
64
|
+
const READ_ONLY_PROVIDERS = new Set(['claude', 'codex', 'grok']);
|
|
65
|
+
const BUBBLEWRAP_PATH = '/usr/bin/bwrap';
|
|
66
|
+
const PROVIDER_CONTROL_PLANE_DIRECTORIES = Object.freeze([
|
|
67
|
+
'.chati', '.chati.dev', '.claude', '.codex', '.grok', '.agents',
|
|
68
|
+
]);
|
|
69
|
+
const PROCESS_TREE_SETTLE_WAIT = new Int32Array(new SharedArrayBuffer(4));
|
|
70
|
+
const PRIVATE_PROMPT_MARKER = '<chati-private-prompt-file>';
|
|
71
|
+
const CONTAINED_PROMPT_PATH = '/run/user/chati-private-prompt';
|
|
72
|
+
const CONTAINED_PROVIDER_EXECUTABLE_PATH = '/run/user/chati-provider-executable';
|
|
73
|
+
const AUTHORITY_SYSTEM_PATH = '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin';
|
|
74
|
+
const TEAM_CONTEXT_ENV_KEYS = Object.freeze([
|
|
75
|
+
'CHATI_TEAM_ID', 'CHATI_TEAM_MEMBER', 'CHATI_TEAM_TASK_LIST', 'CHATI_TEAM_MAILBOX',
|
|
76
|
+
]);
|
|
77
|
+
|
|
78
|
+
function runTerminalSecurityTestHook(name, payload) {
|
|
79
|
+
const hooks = globalThis[TERMINAL_SECURITY_TEST_HOOKS];
|
|
80
|
+
if (hooks && typeof hooks[name] === 'function') hooks[name](payload);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function openPrivatePromptFile(prompt) {
|
|
84
|
+
const directory = mkdtempSync(join(tmpdir(), 'chati-terminal-prompt-'));
|
|
85
|
+
const path = join(directory, 'prompt');
|
|
86
|
+
const bytes = Buffer.from(String(prompt), 'utf8');
|
|
87
|
+
let writer;
|
|
88
|
+
let descriptor;
|
|
89
|
+
try {
|
|
90
|
+
writer = openSync(path, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | (constants.O_NOFOLLOW || 0), 0o600);
|
|
91
|
+
let offset = 0;
|
|
92
|
+
while (offset < bytes.length) offset += writeSync(writer, bytes, offset, bytes.length - offset, null);
|
|
93
|
+
fsyncSync(writer);
|
|
94
|
+
const written = fstatSync(writer);
|
|
95
|
+
closeSync(writer);
|
|
96
|
+
writer = undefined;
|
|
97
|
+
descriptor = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW || 0));
|
|
98
|
+
const pinned = fstatSync(descriptor);
|
|
99
|
+
if (!pinned.isFile() || pinned.dev !== written.dev || pinned.ino !== written.ino
|
|
100
|
+
|| pinned.nlink !== 1 || pinned.size !== bytes.length || (pinned.mode & 0o777) !== 0o600) {
|
|
101
|
+
return failContainment('private provider prompt file could not be descriptor-pinned');
|
|
102
|
+
}
|
|
103
|
+
const record = Object.freeze({ descriptor, directory, path, size: bytes.length });
|
|
104
|
+
runTerminalSecurityTestHook('afterPrivatePromptFileOpen', { path, directory, size: bytes.length });
|
|
105
|
+
return record;
|
|
106
|
+
} catch (error) {
|
|
107
|
+
if (writer !== undefined) try { closeSync(writer); } catch { /* preserve primary error */ }
|
|
108
|
+
if (descriptor !== undefined) try { closeSync(descriptor); } catch { /* preserve primary error */ }
|
|
109
|
+
rmSync(directory, { recursive: true, force: true });
|
|
110
|
+
throw error;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function cleanupPrivatePromptFile(record) {
|
|
115
|
+
if (record?.directory) rmSync(record.directory, { recursive: true, force: true });
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function materializePrivatePromptArgs(args, promptPath) {
|
|
119
|
+
const matches = args.filter((argument) => argument === PRIVATE_PROMPT_MARKER).length;
|
|
120
|
+
if (matches !== 1) return failContainment('private provider prompt marker is missing or ambiguous');
|
|
121
|
+
return args.map((argument) => argument === PRIVATE_PROMPT_MARKER ? promptPath : argument);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function failContainment(message) {
|
|
125
|
+
throw Object.assign(new Error(message), { code: 'TERMINAL_CONTAINMENT_UNAVAILABLE' });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function hostRootIsUnmapped(uidMap) {
|
|
129
|
+
return String(uidMap).trim().split(/\n+/).some((line) => {
|
|
130
|
+
const [inside, outside, length] = line.trim().split(/\s+/).map(Number);
|
|
131
|
+
return inside === 0 && outside === 0 && Number.isInteger(length) && length > 0;
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function _validateBubblewrapTrust({
|
|
136
|
+
trustedDirectories, pathStat, descriptorStat, uidMap, effectiveUid,
|
|
137
|
+
} = {}) {
|
|
138
|
+
if (!Array.isArray(trustedDirectories) || trustedDirectories.length !== 3
|
|
139
|
+
|| !Number.isInteger(effectiveUid) || effectiveUid < 0
|
|
140
|
+
|| !hostRootIsUnmapped(uidMap)) {
|
|
141
|
+
return failContainment('Bubblewrap trust requires an unmapped host-root identity');
|
|
142
|
+
}
|
|
143
|
+
if (trustedDirectories.some(({ stat }) => !stat || stat.isSymbolicLink() || !stat.isDirectory()
|
|
144
|
+
|| stat.uid !== 0 || (stat.mode & 0o022) !== 0)) {
|
|
145
|
+
return failContainment('Bubblewrap containment ancestors must be host-root-owned, non-writable real directories');
|
|
146
|
+
}
|
|
147
|
+
if (!pathStat || !descriptorStat
|
|
148
|
+
|| pathStat.isSymbolicLink() || !pathStat.isFile() || !descriptorStat.isFile()
|
|
149
|
+
|| pathStat.dev !== descriptorStat.dev || pathStat.ino !== descriptorStat.ino
|
|
150
|
+
|| descriptorStat.uid !== 0 || (descriptorStat.mode & 0o022) !== 0
|
|
151
|
+
|| descriptorStat.nlink < 1) {
|
|
152
|
+
return failContainment('Bubblewrap containment binary must be host-root-owned, non-writable and descriptor-pinned');
|
|
153
|
+
}
|
|
154
|
+
return true;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function executableSnapshot(stat) {
|
|
158
|
+
return Object.freeze({
|
|
159
|
+
dev: stat.dev, ino: stat.ino, mode: stat.mode, uid: stat.uid,
|
|
160
|
+
gid: stat.gid, nlink: stat.nlink, size: stat.size,
|
|
161
|
+
mtimeMs: stat.mtimeMs, ctimeMs: stat.ctimeMs,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function sameExecutableSnapshot(left, right) {
|
|
166
|
+
return Object.keys(left).every((key) => left[key] === right[key]);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function digestPinnedExecutable(descriptor, expected = null) {
|
|
170
|
+
const before = executableSnapshot(fstatSync(descriptor));
|
|
171
|
+
if (before.size < 1 || before.size > 16 * 1024 * 1024) {
|
|
172
|
+
return failContainment('Bubblewrap containment binary has an invalid bounded size');
|
|
173
|
+
}
|
|
174
|
+
const hash = createHash('sha256');
|
|
175
|
+
const buffer = Buffer.allocUnsafe(64 * 1024);
|
|
176
|
+
let position = 0;
|
|
177
|
+
while (position < before.size) {
|
|
178
|
+
const bytesRead = readSync(descriptor, buffer, 0, Math.min(buffer.length, before.size - position), position);
|
|
179
|
+
if (bytesRead === 0) return failContainment('Bubblewrap containment binary changed during hashing');
|
|
180
|
+
hash.update(buffer.subarray(0, bytesRead));
|
|
181
|
+
position += bytesRead;
|
|
182
|
+
}
|
|
183
|
+
const after = executableSnapshot(fstatSync(descriptor));
|
|
184
|
+
const digest = hash.digest('hex');
|
|
185
|
+
if (!sameExecutableSnapshot(before, after)
|
|
186
|
+
|| (expected && (!sameExecutableSnapshot(after, expected.snapshot) || digest !== expected.digest))) {
|
|
187
|
+
return failContainment('Bubblewrap containment binary changed after trust validation');
|
|
188
|
+
}
|
|
189
|
+
return Object.freeze({ digest, snapshot: after });
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function currentBubblewrapTrust(descriptor) {
|
|
193
|
+
const trustedDirectories = ['/', '/usr', '/usr/bin'].map((path) => ({ path, stat: lstatSync(path) }));
|
|
194
|
+
const pathStat = lstatSync(BUBBLEWRAP_PATH);
|
|
195
|
+
const descriptorStat = fstatSync(descriptor);
|
|
196
|
+
_validateBubblewrapTrust({
|
|
197
|
+
trustedDirectories, pathStat, descriptorStat,
|
|
198
|
+
uidMap: readFileSync('/proc/self/uid_map', 'utf8'),
|
|
199
|
+
effectiveUid: process.geteuid?.() ?? process.getuid?.() ?? -1,
|
|
200
|
+
});
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function requireBubblewrapContainment() {
|
|
204
|
+
if (process.platform !== 'linux') {
|
|
205
|
+
throw Object.assign(new Error('authority-bearing RAIL processes require Linux PID-namespace containment'), { code: 'TERMINAL_CONTAINMENT_UNAVAILABLE' });
|
|
206
|
+
}
|
|
207
|
+
let descriptor;
|
|
208
|
+
try { descriptor = openSync(BUBBLEWRAP_PATH, constants.O_RDONLY | (constants.O_NOFOLLOW || 0)); }
|
|
209
|
+
catch (error) {
|
|
210
|
+
throw Object.assign(new Error(`authority-bearing RAIL processes require ${BUBBLEWRAP_PATH}: ${error.message}`), { code: 'TERMINAL_CONTAINMENT_UNAVAILABLE' });
|
|
211
|
+
}
|
|
212
|
+
let probeDirectoryDescriptor;
|
|
213
|
+
try {
|
|
214
|
+
currentBubblewrapTrust(descriptor);
|
|
215
|
+
const executable = digestPinnedExecutable(descriptor);
|
|
216
|
+
probeDirectoryDescriptor = openSync('/', constants.O_RDONLY | (constants.O_DIRECTORY || 0) | (constants.O_NOFOLLOW || 0));
|
|
217
|
+
const probe = spawnSync('/proc/self/fd/3', [
|
|
218
|
+
'--die-with-parent', '--unshare-pid', '--unshare-ipc', '--unshare-cgroup-try',
|
|
219
|
+
'--ro-bind', '/', '/', '--dev', '/dev', '--proc', '/proc', '--tmpfs', '/run/user',
|
|
220
|
+
'--ro-bind-fd', '4', '/mnt', '--', '/bin/sh', '-c',
|
|
221
|
+
'test ! -e /proc/self/fd/4 && test -r /mnt/usr/bin/true',
|
|
222
|
+
], { encoding: 'utf8', timeout: 10_000, stdio: ['ignore', 'pipe', 'pipe', descriptor, probeDirectoryDescriptor] });
|
|
223
|
+
closeSync(probeDirectoryDescriptor);
|
|
224
|
+
probeDirectoryDescriptor = undefined;
|
|
225
|
+
if (probe.status !== 0 || probe.error) {
|
|
226
|
+
throw Object.assign(new Error(`Bubblewrap descriptor-consuming PID-namespace containment is unavailable: ${probe.error?.message || probe.stderr || `status ${probe.status}`}`), { code: 'TERMINAL_CONTAINMENT_UNAVAILABLE' });
|
|
227
|
+
}
|
|
228
|
+
currentBubblewrapTrust(descriptor);
|
|
229
|
+
digestPinnedExecutable(descriptor, executable);
|
|
230
|
+
return Object.freeze({ descriptor, binaryDigest: executable.digest, executable });
|
|
231
|
+
} catch (error) {
|
|
232
|
+
if (probeDirectoryDescriptor !== undefined) closeSync(probeDirectoryDescriptor);
|
|
233
|
+
closeSync(descriptor);
|
|
234
|
+
throw error;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function pathContains(root, candidate) {
|
|
239
|
+
const pathFromRoot = relative(resolve(root), resolve(candidate));
|
|
240
|
+
return pathFromRoot === '' || (pathFromRoot !== '..' && !pathFromRoot.startsWith(`..${sep}`) && !isAbsolute(pathFromRoot));
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function directorySnapshot(stat) {
|
|
244
|
+
return Object.freeze({
|
|
245
|
+
dev: stat.dev, ino: stat.ino, mode: stat.mode, uid: stat.uid,
|
|
246
|
+
gid: stat.gid,
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function sameDirectorySnapshot(left, right) {
|
|
251
|
+
return Object.keys(left).every((key) => left[key] === right[key]);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function closeDirectoryChain(chain) {
|
|
255
|
+
for (const descriptor of [...(chain?.descriptors ?? [])].reverse()) {
|
|
256
|
+
try { closeSync(descriptor); } catch { /* preserve the primary containment result */ }
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function openPinnedDirectoryChain(path) {
|
|
261
|
+
if (process.platform !== 'linux') {
|
|
262
|
+
return failContainment('authority-bearing RAIL processes require Linux descriptor-pinned project storage');
|
|
263
|
+
}
|
|
264
|
+
const absolute = resolve(path);
|
|
265
|
+
const descriptors = [];
|
|
266
|
+
const snapshots = [];
|
|
267
|
+
let descriptor;
|
|
268
|
+
try {
|
|
269
|
+
descriptor = openSync('/', constants.O_RDONLY | (constants.O_DIRECTORY || 0) | (constants.O_NOFOLLOW || 0));
|
|
270
|
+
descriptors.push(descriptor);
|
|
271
|
+
snapshots.push(directorySnapshot(fstatSync(descriptor)));
|
|
272
|
+
for (const segment of absolute.slice(1).split(sep).filter(Boolean)) {
|
|
273
|
+
descriptor = openSync(
|
|
274
|
+
join(`/proc/self/fd/${descriptor}`, segment),
|
|
275
|
+
constants.O_RDONLY | (constants.O_DIRECTORY || 0) | (constants.O_NOFOLLOW || 0),
|
|
276
|
+
);
|
|
277
|
+
const stat = fstatSync(descriptor);
|
|
278
|
+
if (!stat.isDirectory()) return failContainment('authority project chain must contain only real directories');
|
|
279
|
+
descriptors.push(descriptor);
|
|
280
|
+
snapshots.push(directorySnapshot(stat));
|
|
281
|
+
}
|
|
282
|
+
return Object.freeze({
|
|
283
|
+
path: absolute,
|
|
284
|
+
reference: `/proc/self/fd/${descriptors.at(-1)}`,
|
|
285
|
+
descriptors: Object.freeze(descriptors),
|
|
286
|
+
snapshots: Object.freeze(snapshots),
|
|
287
|
+
identity_digest: sha256({ path: absolute, snapshots }),
|
|
288
|
+
});
|
|
289
|
+
} catch (error) {
|
|
290
|
+
closeDirectoryChain({ descriptors });
|
|
291
|
+
if (error?.code === 'TERMINAL_CONTAINMENT_UNAVAILABLE') throw error;
|
|
292
|
+
return failContainment(`authority project chain cannot be pinned: ${error.message}`);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function assertPinnedDirectoryChain(chain) {
|
|
297
|
+
const current = openPinnedDirectoryChain(chain.path);
|
|
298
|
+
try {
|
|
299
|
+
if (current.snapshots.length !== chain.snapshots.length
|
|
300
|
+
|| current.snapshots.some((snapshot, index) => !sameDirectorySnapshot(snapshot, chain.snapshots[index]))) {
|
|
301
|
+
return failContainment('authority project chain changed before provider launch');
|
|
302
|
+
}
|
|
303
|
+
for (const [index, descriptor] of chain.descriptors.entries()) {
|
|
304
|
+
if (!sameDirectorySnapshot(directorySnapshot(fstatSync(descriptor)), chain.snapshots[index])) {
|
|
305
|
+
return failContainment('pinned authority project directory changed before provider launch');
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
return true;
|
|
309
|
+
} finally { closeDirectoryChain(current); }
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function ensureScopedDirectory(projectChain, relativeDirectory) {
|
|
313
|
+
const descriptors = [];
|
|
314
|
+
const snapshots = [];
|
|
315
|
+
let reference = projectChain.reference;
|
|
316
|
+
try {
|
|
317
|
+
for (const segment of relativeDirectory.split(sep).filter(Boolean)) {
|
|
318
|
+
const target = join(reference, segment);
|
|
319
|
+
try { mkdirSync(target, { mode: 0o700 }); }
|
|
320
|
+
catch (error) { if (error?.code !== 'EEXIST') throw error; }
|
|
321
|
+
const pathStat = lstatSync(target);
|
|
322
|
+
if (pathStat.isSymbolicLink() || !pathStat.isDirectory()) {
|
|
323
|
+
return failContainment('Planning write scope ancestors must be real directories');
|
|
324
|
+
}
|
|
325
|
+
const descriptor = openSync(
|
|
326
|
+
target,
|
|
327
|
+
constants.O_RDONLY | (constants.O_DIRECTORY || 0) | (constants.O_NOFOLLOW || 0),
|
|
328
|
+
);
|
|
329
|
+
const pinned = fstatSync(descriptor);
|
|
330
|
+
if (pathStat.dev !== pinned.dev || pathStat.ino !== pinned.ino) {
|
|
331
|
+
closeSync(descriptor);
|
|
332
|
+
return failContainment('Planning write scope changed while it was pinned');
|
|
333
|
+
}
|
|
334
|
+
descriptors.push(descriptor);
|
|
335
|
+
snapshots.push(directorySnapshot(pinned));
|
|
336
|
+
reference = `/proc/self/fd/${descriptor}`;
|
|
337
|
+
}
|
|
338
|
+
return Object.freeze({ descriptors, snapshots, reference });
|
|
339
|
+
} catch (error) {
|
|
340
|
+
descriptors.reverse().forEach((descriptor) => { try { closeSync(descriptor); } catch { /* already closed */ } });
|
|
341
|
+
if (error?.code === 'TERMINAL_CONTAINMENT_UNAVAILABLE') throw error;
|
|
342
|
+
return failContainment(`Planning write scope cannot be pinned: ${error.message}`);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
function preparePinnedWriteScopes(projectChain, writeScope = []) {
|
|
347
|
+
if (!Array.isArray(writeScope)) return failContainment('terminal write scope must be an array');
|
|
348
|
+
const normalized = [...new Set(writeScope.map((scope) => String(scope || '').trim()).filter(Boolean))];
|
|
349
|
+
const fullProject = normalized.some((scope) => ['.', './'].includes(scope));
|
|
350
|
+
if (fullProject && normalized.length !== 1) return failContainment('full-project write scope cannot be combined with narrower scopes');
|
|
351
|
+
if (fullProject) return Object.freeze({ fullProject: true, entries: Object.freeze([]), descriptors: Object.freeze([]) });
|
|
352
|
+
|
|
353
|
+
const entries = [];
|
|
354
|
+
const descriptors = [];
|
|
355
|
+
try {
|
|
356
|
+
for (const scope of normalized) {
|
|
357
|
+
if (isAbsolute(scope)) return failContainment('terminal write scopes must be project-relative');
|
|
358
|
+
const directoryScope = scope.endsWith('/') || scope.endsWith(sep);
|
|
359
|
+
const absolute = resolve(projectChain.path, scope);
|
|
360
|
+
if (!pathContains(projectChain.path, absolute)) return failContainment('terminal write scope escapes the project');
|
|
361
|
+
for (const protectedPath of ['.chati', '.chati.dev']) {
|
|
362
|
+
const protectedAbsolute = resolve(projectChain.path, protectedPath);
|
|
363
|
+
if (pathContains(protectedAbsolute, absolute) || pathContains(absolute, protectedAbsolute)) {
|
|
364
|
+
return failContainment('terminal write scope overlaps the CHATI control plane');
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
const rel = relative(projectChain.path, absolute);
|
|
368
|
+
if (!rel) return failContainment('full-project write authority must use the explicit dot scope');
|
|
369
|
+
const parentRel = directoryScope ? rel : dirname(rel);
|
|
370
|
+
const parent = ensureScopedDirectory(projectChain, parentRel === '.' ? '' : parentRel);
|
|
371
|
+
descriptors.push(...parent.descriptors);
|
|
372
|
+
let targetDescriptor;
|
|
373
|
+
let targetSnapshot;
|
|
374
|
+
if (directoryScope) {
|
|
375
|
+
targetDescriptor = parent.descriptors.at(-1);
|
|
376
|
+
targetSnapshot = parent.snapshots.at(-1);
|
|
377
|
+
} else {
|
|
378
|
+
const name = rel.split(sep).at(-1);
|
|
379
|
+
const target = join(parent.reference, name);
|
|
380
|
+
const pathFlags = constants.O_RDWR | constants.O_CREAT | (constants.O_NOFOLLOW || 0);
|
|
381
|
+
targetDescriptor = openSync(target, pathFlags, 0o600);
|
|
382
|
+
const stat = fstatSync(targetDescriptor);
|
|
383
|
+
if (!stat.isFile() || stat.nlink !== 1) {
|
|
384
|
+
closeSync(targetDescriptor);
|
|
385
|
+
return failContainment('Planning file write scope must be a regular single-link file');
|
|
386
|
+
}
|
|
387
|
+
descriptors.push(targetDescriptor);
|
|
388
|
+
targetSnapshot = executableSnapshot(stat);
|
|
389
|
+
}
|
|
390
|
+
entries.push(Object.freeze({
|
|
391
|
+
target: absolute,
|
|
392
|
+
descriptor: targetDescriptor,
|
|
393
|
+
snapshot: targetSnapshot,
|
|
394
|
+
kind: directoryScope ? 'directory' : 'file',
|
|
395
|
+
}));
|
|
396
|
+
}
|
|
397
|
+
return Object.freeze({
|
|
398
|
+
fullProject: false,
|
|
399
|
+
entries: Object.freeze(entries),
|
|
400
|
+
descriptors: Object.freeze([...new Set(descriptors)]),
|
|
401
|
+
});
|
|
402
|
+
} catch (error) {
|
|
403
|
+
[...new Set(descriptors)].reverse().forEach((descriptor) => { try { closeSync(descriptor); } catch { /* already closed */ } });
|
|
404
|
+
if (error?.code === 'TERMINAL_CONTAINMENT_UNAVAILABLE') throw error;
|
|
405
|
+
return failContainment(`Planning write scope cannot be prepared: ${error.message}`);
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function assertPinnedWriteScopes(scopes) {
|
|
410
|
+
for (const entry of scopes?.entries ?? []) {
|
|
411
|
+
const current = entry.kind === 'directory'
|
|
412
|
+
? directorySnapshot(fstatSync(entry.descriptor))
|
|
413
|
+
: executableSnapshot(fstatSync(entry.descriptor));
|
|
414
|
+
const matches = entry.kind === 'directory'
|
|
415
|
+
? sameDirectorySnapshot(current, entry.snapshot)
|
|
416
|
+
: sameExecutableSnapshot(current, entry.snapshot);
|
|
417
|
+
if (!matches) return failContainment('Planning write scope changed before provider launch');
|
|
418
|
+
}
|
|
419
|
+
return true;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
function readOnlyTmpPathEntries() {
|
|
423
|
+
const entries = [...new Set(String(process.env.PATH || '').split(delimiter).filter(Boolean).map((entry) => resolve(entry)))];
|
|
424
|
+
return entries.filter((entry) => pathContains('/tmp', entry)).flatMap((entry) => {
|
|
425
|
+
try {
|
|
426
|
+
const stat = lstatSync(entry);
|
|
427
|
+
return !stat.isSymbolicLink() && stat.isDirectory() ? ['--ro-bind', entry, entry] : [];
|
|
428
|
+
} catch { return []; }
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function hiddenInstallationAuthorityArgs() {
|
|
433
|
+
const directory = installationAuthorityStateDirectory();
|
|
434
|
+
try {
|
|
435
|
+
const stat = lstatSync(directory);
|
|
436
|
+
if (stat.isSymbolicLink() || !stat.isDirectory()) {
|
|
437
|
+
return failContainment('installation authority state must be a real directory');
|
|
438
|
+
}
|
|
439
|
+
return ['--tmpfs', directory];
|
|
440
|
+
} catch (error) {
|
|
441
|
+
if (error?.code === 'ENOENT') return [];
|
|
442
|
+
if (error?.code === 'TERMINAL_CONTAINMENT_UNAVAILABLE') throw error;
|
|
443
|
+
return failContainment(`installation authority state cannot be isolated: ${error.message}`);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function buildAuthorityContainment({
|
|
448
|
+
args, cwd, readOnly, writeScope, terminalId, provider, providerExecutableBinding,
|
|
449
|
+
promptRecord = null, maskedReadPaths = [],
|
|
450
|
+
}) {
|
|
451
|
+
const trustedBubblewrap = requireBubblewrapContainment();
|
|
452
|
+
if (providerExecutableBinding === undefined) {
|
|
453
|
+
closeSync(trustedBubblewrap.descriptor);
|
|
454
|
+
throw Object.assign(new Error('authority-bearing provider launch requires an executable binding sealed by the installation'), { code: 'PROVIDER_EXECUTABLE_BINDING_REQUIRED' });
|
|
455
|
+
}
|
|
456
|
+
let projectChain;
|
|
457
|
+
const maskedChains = [];
|
|
458
|
+
let trustedProvider;
|
|
459
|
+
let trustedSupportingProviders = [];
|
|
460
|
+
let pinnedWriteScopes;
|
|
461
|
+
const stateDirectory = join(homedir(), provider === 'claude' ? '.claude' : provider === 'codex' ? '.codex' : '.grok');
|
|
462
|
+
let stateDirectoryArgs;
|
|
463
|
+
let stateDirectoryDescriptor;
|
|
464
|
+
const controlPlaneDescriptors = [];
|
|
465
|
+
const controlPlaneArgs = [];
|
|
466
|
+
try {
|
|
467
|
+
trustedProvider = openPinnedProviderExecutable(providerExecutableBinding, { harnessId: provider, projectDir: cwd });
|
|
468
|
+
trustedSupportingProviders = openPinnedProviderSupportingExecutables(
|
|
469
|
+
providerExecutableBinding, { harnessId: provider, projectDir: cwd },
|
|
470
|
+
);
|
|
471
|
+
projectChain = openPinnedDirectoryChain(cwd);
|
|
472
|
+
if (!Array.isArray(maskedReadPaths)) return failContainment('masked read paths must be an array');
|
|
473
|
+
for (const requested of [...new Set(maskedReadPaths.map((entry) => resolve(String(entry))))]) {
|
|
474
|
+
const physical = realpathSync(requested);
|
|
475
|
+
if (pathContains(physical, cwd) || pathContains(cwd, physical)) {
|
|
476
|
+
return failContainment('masked read path must not overlap the authority working directory');
|
|
477
|
+
}
|
|
478
|
+
maskedChains.push(openPinnedDirectoryChain(physical));
|
|
479
|
+
}
|
|
480
|
+
pinnedWriteScopes = preparePinnedWriteScopes(projectChain, readOnly ? [] : writeScope);
|
|
481
|
+
let stateStat;
|
|
482
|
+
try { stateStat = lstatSync(stateDirectory); }
|
|
483
|
+
catch (error) {
|
|
484
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
485
|
+
mkdirSync(stateDirectory, { mode: 0o700 });
|
|
486
|
+
stateStat = lstatSync(stateDirectory);
|
|
487
|
+
}
|
|
488
|
+
if (!stateStat.isDirectory()) {
|
|
489
|
+
throw Object.assign(new Error('provider state path must be a real directory'), { code: 'TERMINAL_CONTAINMENT_UNAVAILABLE' });
|
|
490
|
+
}
|
|
491
|
+
stateDirectoryDescriptor = openSync(stateDirectory, constants.O_RDONLY | (constants.O_DIRECTORY || 0) | (constants.O_NOFOLLOW || 0));
|
|
492
|
+
const descriptorStat = fstatSync(stateDirectoryDescriptor);
|
|
493
|
+
if (descriptorStat.dev !== stateStat.dev || descriptorStat.ino !== stateStat.ino) {
|
|
494
|
+
throw Object.assign(new Error('provider state directory changed before containment'), { code: 'TERMINAL_CONTAINMENT_UNAVAILABLE' });
|
|
495
|
+
}
|
|
496
|
+
const physicalProject = realpathSync(projectChain.reference);
|
|
497
|
+
const physicalState = realpathSync(stateDirectory);
|
|
498
|
+
if (pathContains(physicalProject, physicalState) || pathContains(physicalState, physicalProject)) {
|
|
499
|
+
throw Object.assign(new Error('provider state and project directories must not physically overlap inside authority containment'), { code: 'TERMINAL_CONTAINMENT_UNAVAILABLE' });
|
|
500
|
+
}
|
|
501
|
+
// The project bind is applied after the authority key mask. Rejecting an
|
|
502
|
+
// overlap prevents that later bind from remounting the private key into
|
|
503
|
+
// the provider namespace through a caller-selected project path.
|
|
504
|
+
try {
|
|
505
|
+
const physicalAuthority = realpathSync(installationAuthorityStateDirectory());
|
|
506
|
+
if (pathContains(physicalProject, physicalAuthority) || pathContains(physicalAuthority, physicalProject)) {
|
|
507
|
+
throw Object.assign(new Error('installation authority and project directories must not physically overlap inside authority containment'), { code: 'TERMINAL_CONTAINMENT_UNAVAILABLE' });
|
|
508
|
+
}
|
|
509
|
+
} catch (error) {
|
|
510
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
511
|
+
}
|
|
512
|
+
for (const masked of maskedChains) {
|
|
513
|
+
if (pathContains(masked.path, physicalState) || pathContains(physicalState, masked.path)
|
|
514
|
+
|| pathContains(masked.path, trustedProvider.binding.executable_path)) {
|
|
515
|
+
throw Object.assign(new Error('masked read path overlaps provider state or executable authority'), { code: 'TERMINAL_CONTAINMENT_UNAVAILABLE' });
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
stateDirectoryArgs = ['--bind-fd', '4', stateDirectory];
|
|
519
|
+
// Provider-specific configuration is control-plane material too. A RAIL
|
|
520
|
+
// execution may write its scoped product files, but cannot rewrite a
|
|
521
|
+
// harness hook, skill, rule or config and then rely on that change in the
|
|
522
|
+
// same contained process.
|
|
523
|
+
for (const name of PROVIDER_CONTROL_PLANE_DIRECTORIES) {
|
|
524
|
+
const target = join(projectChain.reference, name);
|
|
525
|
+
let targetStat;
|
|
526
|
+
try { targetStat = lstatSync(target); }
|
|
527
|
+
catch (error) { if (error?.code === 'ENOENT') continue; throw error; }
|
|
528
|
+
if (targetStat.isSymbolicLink() || !targetStat.isDirectory()) {
|
|
529
|
+
throw Object.assign(new Error(`${name} control plane must be a real directory`), { code: 'TERMINAL_CONTAINMENT_UNAVAILABLE' });
|
|
530
|
+
}
|
|
531
|
+
const descriptor = openSync(target, constants.O_RDONLY | (constants.O_DIRECTORY || 0) | (constants.O_NOFOLLOW || 0));
|
|
532
|
+
const pinned = fstatSync(descriptor);
|
|
533
|
+
if (pinned.dev !== targetStat.dev || pinned.ino !== targetStat.ino
|
|
534
|
+
|| !pathContains(physicalProject, realpathSync(target))) {
|
|
535
|
+
closeSync(descriptor);
|
|
536
|
+
throw Object.assign(new Error(`${name} control plane changed before containment`), { code: 'TERMINAL_CONTAINMENT_UNAVAILABLE' });
|
|
537
|
+
}
|
|
538
|
+
const descriptorNumber = 6 + controlPlaneDescriptors.length;
|
|
539
|
+
controlPlaneDescriptors.push(descriptor);
|
|
540
|
+
controlPlaneArgs.push('--ro-bind-fd', String(descriptorNumber), join(cwd, name));
|
|
541
|
+
}
|
|
542
|
+
} catch (error) {
|
|
543
|
+
controlPlaneDescriptors.forEach((descriptor) => closeSync(descriptor));
|
|
544
|
+
if (stateDirectoryDescriptor !== undefined) closeSync(stateDirectoryDescriptor);
|
|
545
|
+
if (trustedProvider?.descriptor !== undefined) closeSync(trustedProvider.descriptor);
|
|
546
|
+
trustedSupportingProviders.forEach((support) => closeSync(support.descriptor));
|
|
547
|
+
for (const descriptor of pinnedWriteScopes?.descriptors ?? []) try { closeSync(descriptor); } catch { /* already closed */ }
|
|
548
|
+
for (const chain of maskedChains) closeDirectoryChain(chain);
|
|
549
|
+
closeDirectoryChain(projectChain);
|
|
550
|
+
closeSync(trustedBubblewrap.descriptor);
|
|
551
|
+
if (error?.code === 'TERMINAL_CONTAINMENT_UNAVAILABLE' || error?.code?.startsWith('PROVIDER_EXECUTABLE_')) throw error;
|
|
552
|
+
throw Object.assign(new Error(`cannot prepare provider state directory: ${error.message}`), { code: 'TERMINAL_CONTAINMENT_UNAVAILABLE' });
|
|
553
|
+
}
|
|
554
|
+
const writableScopeDescriptors = pinnedWriteScopes.entries.map((entry) => entry.descriptor);
|
|
555
|
+
const writableScopeArgs = pinnedWriteScopes.entries.flatMap((entry, index) => [
|
|
556
|
+
'--bind-fd', String(6 + controlPlaneDescriptors.length + index), entry.target,
|
|
557
|
+
]);
|
|
558
|
+
const promptDescriptorNumber = 6 + controlPlaneDescriptors.length + writableScopeDescriptors.length;
|
|
559
|
+
const providerDescriptorNumber = promptDescriptorNumber + (promptRecord ? 1 : 0);
|
|
560
|
+
const supportingProviderDescriptorNumber = providerDescriptorNumber + 1;
|
|
561
|
+
const supportingProviderArgs = trustedSupportingProviders.flatMap((support, index) => [
|
|
562
|
+
'--ro-bind-fd', String(supportingProviderDescriptorNumber + index),
|
|
563
|
+
join(dirname(CONTAINED_PROVIDER_EXECUTABLE_PATH), basename(support.binding.executable_path)),
|
|
564
|
+
]);
|
|
565
|
+
const maskedPathArgs = maskedChains
|
|
566
|
+
.filter((chain) => !pathContains('/tmp', chain.path))
|
|
567
|
+
.flatMap((chain) => ['--tmpfs', chain.path]);
|
|
568
|
+
const containmentArgs = [
|
|
569
|
+
'--die-with-parent', '--unshare-pid', '--unshare-ipc', '--unshare-cgroup-try',
|
|
570
|
+
'--ro-bind', '/', '/',
|
|
571
|
+
'--dev', '/dev', '--proc', '/proc',
|
|
572
|
+
'--tmpfs', '/run/user',
|
|
573
|
+
'--tmpfs', '/tmp',
|
|
574
|
+
...readOnlyTmpPathEntries(),
|
|
575
|
+
...maskedPathArgs,
|
|
576
|
+
...hiddenInstallationAuthorityArgs(),
|
|
577
|
+
...stateDirectoryArgs,
|
|
578
|
+
pinnedWriteScopes.fullProject ? '--bind-fd' : '--ro-bind-fd', '5', cwd,
|
|
579
|
+
...controlPlaneArgs,
|
|
580
|
+
...writableScopeArgs,
|
|
581
|
+
...(promptRecord ? ['--ro-bind-fd', String(promptDescriptorNumber), CONTAINED_PROMPT_PATH] : []),
|
|
582
|
+
'--ro-bind-fd', String(providerDescriptorNumber), CONTAINED_PROVIDER_EXECUTABLE_PATH,
|
|
583
|
+
...supportingProviderArgs,
|
|
584
|
+
'--chdir', cwd,
|
|
585
|
+
'--', CONTAINED_PROVIDER_EXECUTABLE_PATH, ...args,
|
|
586
|
+
];
|
|
587
|
+
return Object.freeze({
|
|
588
|
+
kind: 'bubblewrap_pid_namespace_v1',
|
|
589
|
+
id: `bwrap:${terminalId}`,
|
|
590
|
+
command: '/proc/self/fd/3',
|
|
591
|
+
args: Object.freeze(containmentArgs),
|
|
592
|
+
inheritedDescriptors: Object.freeze([
|
|
593
|
+
trustedBubblewrap.descriptor, stateDirectoryDescriptor, projectChain.descriptors.at(-1),
|
|
594
|
+
...controlPlaneDescriptors,
|
|
595
|
+
...writableScopeDescriptors,
|
|
596
|
+
...(promptRecord ? [promptRecord.descriptor] : []),
|
|
597
|
+
trustedProvider.descriptor,
|
|
598
|
+
...trustedSupportingProviders.map((support) => support.descriptor),
|
|
599
|
+
]),
|
|
600
|
+
pinnedProjectChain: projectChain,
|
|
601
|
+
maskedReadChains: Object.freeze(maskedChains),
|
|
602
|
+
pinnedWriteScopes,
|
|
603
|
+
projectReference: projectChain.reference,
|
|
604
|
+
projectIdentityDigest: projectChain.identity_digest,
|
|
605
|
+
trustedExecutable: trustedBubblewrap.executable,
|
|
606
|
+
trustedProviderExecutable: trustedProvider.executable,
|
|
607
|
+
trustedSupportingProviderExecutables: Object.freeze(trustedSupportingProviders.map((support) => support.executable)),
|
|
608
|
+
providerExecutableBinding: trustedProvider.binding,
|
|
609
|
+
providerExecutableDescriptor: trustedProvider.descriptor,
|
|
610
|
+
supportingProviderExecutableBindings: Object.freeze(trustedSupportingProviders.map((support) => support.binding)),
|
|
611
|
+
supportingProviderExecutableDescriptors: Object.freeze(trustedSupportingProviders.map((support) => support.descriptor)),
|
|
612
|
+
digest: sha256({
|
|
613
|
+
binary: BUBBLEWRAP_PATH,
|
|
614
|
+
binary_sha256: trustedBubblewrap.binaryDigest,
|
|
615
|
+
provider_executable: trustedProvider.binding,
|
|
616
|
+
supporting_provider_executables: trustedSupportingProviders.map((support) => support.binding),
|
|
617
|
+
args: containmentArgs.slice(0, -args.length - 1),
|
|
618
|
+
}),
|
|
619
|
+
});
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
function closeAuthorityContainment(containment) {
|
|
623
|
+
const descriptors = new Set([
|
|
624
|
+
...(containment?.inheritedDescriptors ?? []),
|
|
625
|
+
...(containment?.pinnedProjectChain?.descriptors ?? []),
|
|
626
|
+
...(containment?.pinnedWriteScopes?.descriptors ?? []),
|
|
627
|
+
]);
|
|
628
|
+
for (const descriptor of descriptors) {
|
|
629
|
+
try { closeSync(descriptor); } catch { /* preserve the primary spawn result */ }
|
|
630
|
+
}
|
|
631
|
+
for (const chain of containment?.maskedReadChains ?? []) closeDirectoryChain(chain);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
function signalProcessTree(child, processGroupId, signal) {
|
|
635
|
+
if (Number.isInteger(processGroupId) && processGroupId > 0 && process.platform !== 'win32') {
|
|
636
|
+
try { process.kill(-processGroupId, signal); return true; }
|
|
637
|
+
catch (error) { if (error?.code !== 'ESRCH') throw error; return false; }
|
|
638
|
+
}
|
|
639
|
+
return child.kill(signal);
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
function processTreeIsRunning(childPid, processGroupId) {
|
|
643
|
+
const target = Number.isInteger(processGroupId) && processGroupId > 0 && process.platform !== 'win32'
|
|
644
|
+
? -processGroupId
|
|
645
|
+
: childPid;
|
|
646
|
+
try { process.kill(target, 0); return true; }
|
|
647
|
+
catch (error) { if (error?.code === 'ESRCH') return false; throw error; }
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function processTreeStopsAfterClose(childPid, processGroupId) {
|
|
651
|
+
for (let attempt = 0; attempt < 100; attempt += 1) {
|
|
652
|
+
if (!processTreeIsRunning(childPid, processGroupId)) return true;
|
|
653
|
+
Atomics.wait(PROCESS_TREE_SETTLE_WAIT, 0, 0, 5);
|
|
654
|
+
}
|
|
655
|
+
return false;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
function terminalAuthorityReceipt(material) {
|
|
659
|
+
const signed = signHostAuthorityPayload(canonicalize(material), {
|
|
660
|
+
domain: TERMINAL_AUTHORITY_DOMAIN,
|
|
661
|
+
});
|
|
662
|
+
return Object.freeze(canonicalize({
|
|
663
|
+
...material,
|
|
664
|
+
authority_id: signed.authority_id,
|
|
665
|
+
authority_signature: signed.signature,
|
|
666
|
+
}));
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function parseRailExecutionAuthorityResult(stdout, launchNonce) {
|
|
670
|
+
const matches = [...stdout.matchAll(/<CHATI_RAIL_EXECUTION>\s*([\s\S]*?)\s*<\/CHATI_RAIL_EXECUTION>/g)];
|
|
671
|
+
if (matches.length !== 1) {
|
|
672
|
+
throw Object.assign(new Error('successful RAIL execution must return exactly one result envelope on stdout'), { code: 'RAIL_EXECUTION_OUTPUT_INVALID' });
|
|
673
|
+
}
|
|
674
|
+
let payload;
|
|
675
|
+
try { payload = JSON.parse(matches[0][1]); }
|
|
676
|
+
catch {
|
|
677
|
+
throw Object.assign(new Error('successful RAIL execution returned malformed result JSON'), { code: 'RAIL_EXECUTION_OUTPUT_INVALID' });
|
|
678
|
+
}
|
|
679
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload)
|
|
680
|
+
|| Object.keys(payload).sort().join(',') !== 'launch_nonce,result_commit_ref'
|
|
681
|
+
|| !/^[a-f0-9]{40,64}$/.test(payload.result_commit_ref || '')
|
|
682
|
+
|| !/^[A-Za-z0-9._-]{16,128}$/.test(payload.launch_nonce || '')) {
|
|
683
|
+
throw Object.assign(new Error('RAIL execution result must bind one full commit id to the exact launch nonce'), { code: 'RAIL_EXECUTION_OUTPUT_INVALID' });
|
|
684
|
+
}
|
|
685
|
+
if (payload.launch_nonce !== launchNonce) {
|
|
686
|
+
throw Object.assign(new Error('RAIL execution output does not echo the canonical launch nonce'), { code: 'RAIL_EXECUTION_LAUNCH_NONCE_MISMATCH' });
|
|
687
|
+
}
|
|
688
|
+
return Object.freeze({ result_commit_ref: payload.result_commit_ref });
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* Verifies a receipt minted by the persistent host terminal authority. The
|
|
693
|
+
* private key remains outside provider containment, while persisted receipts
|
|
694
|
+
* remain verifiable after a supervisor restart.
|
|
695
|
+
*/
|
|
696
|
+
export function verifyTerminalAuthorityReceipt(receipt, expected = {}) {
|
|
697
|
+
if (!receipt || typeof receipt !== 'object' || Array.isArray(receipt)
|
|
698
|
+
|| typeof receipt.authority_id !== 'string'
|
|
699
|
+
|| typeof receipt.authority_signature !== 'string') return false;
|
|
700
|
+
const { authority_signature, authority_id, ...material } = receipt;
|
|
701
|
+
if (!verifyHostAuthorityPayload(material, {
|
|
702
|
+
domain: TERMINAL_AUTHORITY_DOMAIN,
|
|
703
|
+
authorityId: authority_id,
|
|
704
|
+
signature: authority_signature,
|
|
705
|
+
})) return false;
|
|
706
|
+
return Object.entries(expected).every(([key, value]) => Object.hasOwn(material, key)
|
|
707
|
+
&& sha256(material[key]) === sha256(value));
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
export function getTerminalLaunchAuthorityReceipt(handle) {
|
|
711
|
+
const state = TERMINAL_AUTHORITY_STATE.get(handle);
|
|
712
|
+
if (!state) throw Object.assign(new Error('terminal handle was not minted by spawnTerminal'), { code: 'UNTRUSTED_TERMINAL_HANDLE' });
|
|
713
|
+
return state.launchReceipt;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
export function getTerminalCompletionAuthorityReceipt(handle) {
|
|
717
|
+
const state = TERMINAL_AUTHORITY_STATE.get(handle);
|
|
718
|
+
if (!state) throw Object.assign(new Error('terminal handle was not minted by spawnTerminal'), { code: 'UNTRUSTED_TERMINAL_HANDLE' });
|
|
719
|
+
const { runtime, identity } = state;
|
|
720
|
+
if (runtime.timeoutCleanupFailure) throw runtime.timeoutCleanupFailure;
|
|
721
|
+
if (runtime.timeoutCleanupPromise && !runtime.timeoutCleanupSettled) {
|
|
722
|
+
throw Object.assign(new Error('terminal timeout cleanup proof is still pending'), { code: 'TERMINAL_CLEANUP_PENDING' });
|
|
723
|
+
}
|
|
724
|
+
if (runtime.status === 'running' || !Number.isInteger(runtime.exitCode)) {
|
|
725
|
+
throw Object.assign(new Error('terminal completion receipt requires an exited process'), { code: 'TERMINAL_STILL_RUNNING' });
|
|
726
|
+
}
|
|
727
|
+
if (runtime.outputOverflow) {
|
|
728
|
+
throw Object.assign(new Error('terminal output exceeded the authority capture limit'), { code: 'TERMINAL_OUTPUT_OVERFLOW' });
|
|
729
|
+
}
|
|
730
|
+
if (!processTreeStopsAfterClose(runtime.child.pid, runtime.processGroupId)) {
|
|
731
|
+
try { signalProcessTree(runtime.child, runtime.processGroupId, 'SIGKILL'); } catch { /* fail closed below */ }
|
|
732
|
+
throw Object.assign(new Error('terminal process group remained active after the provider CLI exited'), { code: 'TERMINAL_PROCESS_GROUP_STILL_RUNNING' });
|
|
733
|
+
}
|
|
734
|
+
if (!state.completionReceipt) {
|
|
735
|
+
const stdout = runtime.stdout.join('');
|
|
736
|
+
let railResult = null;
|
|
737
|
+
let executionWorktreeProof = null;
|
|
738
|
+
let executionResultStatus = null;
|
|
739
|
+
let executionResultErrorCode = null;
|
|
740
|
+
if (state.context.authority_kind === 'rail_execution') {
|
|
741
|
+
if (runtime.exitCode === 0) {
|
|
742
|
+
try {
|
|
743
|
+
railResult = parseRailExecutionAuthorityResult(stdout, state.context.launch_nonce);
|
|
744
|
+
if (state.executionWorktreeBaseline) {
|
|
745
|
+
executionWorktreeProof = verifyRailExecutionWorktreeResult({
|
|
746
|
+
baseline: state.executionWorktreeBaseline,
|
|
747
|
+
resultCommitRef: railResult.result_commit_ref,
|
|
748
|
+
});
|
|
749
|
+
}
|
|
750
|
+
executionResultStatus = 'valid';
|
|
751
|
+
} catch (error) {
|
|
752
|
+
railResult = null;
|
|
753
|
+
executionWorktreeProof = null;
|
|
754
|
+
executionResultStatus = 'invalid_output';
|
|
755
|
+
executionResultErrorCode = error.code || 'RAIL_EXECUTION_OUTPUT_INVALID';
|
|
756
|
+
}
|
|
757
|
+
} else {
|
|
758
|
+
executionResultStatus = 'process_failed';
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
state.completionReceipt = terminalAuthorityReceipt({
|
|
762
|
+
schema_version: 1,
|
|
763
|
+
purpose: 'terminal_completion',
|
|
764
|
+
launch_receipt_digest: sha256(state.launchReceipt),
|
|
765
|
+
terminal_id: identity.terminal_id,
|
|
766
|
+
invocation_id: state.context.invocation_id,
|
|
767
|
+
launch_nonce: state.context.launch_nonce,
|
|
768
|
+
authority_kind: state.context.authority_kind,
|
|
769
|
+
provider_id: state.context.provider_id,
|
|
770
|
+
harness_id: identity.harness_id,
|
|
771
|
+
model_id: identity.model_id,
|
|
772
|
+
reasoning_configuration: identity.reasoning_configuration,
|
|
773
|
+
provider_executable_path: identity.provider_executable_path,
|
|
774
|
+
provider_executable_sha256: identity.provider_executable_sha256,
|
|
775
|
+
exit_status: runtime.exitCode,
|
|
776
|
+
stdout_digest: sha256(stdout),
|
|
777
|
+
stderr_digest: sha256(runtime.stderr.join('')),
|
|
778
|
+
...(executionResultStatus ? { execution_result_status: executionResultStatus } : {}),
|
|
779
|
+
...(executionResultErrorCode ? { execution_result_error_code: executionResultErrorCode } : {}),
|
|
780
|
+
...(state.executionWorktreeBaseline ? {
|
|
781
|
+
execution_base_commit_ref: state.executionWorktreeBaseline.base_commit_ref,
|
|
782
|
+
execution_launch_head_ref: state.executionWorktreeBaseline.launch_head_ref,
|
|
783
|
+
execution_repository_identity_digest: state.executionWorktreeBaseline.repository_identity_digest,
|
|
784
|
+
execution_preexisting_commits_digest: state.executionWorktreeBaseline.preexisting_commits_digest,
|
|
785
|
+
} : {}),
|
|
786
|
+
...(executionWorktreeProof ? {
|
|
787
|
+
execution_result_head_ref: executionWorktreeProof.result_head_ref,
|
|
788
|
+
execution_result_preexisted: executionWorktreeProof.result_preexisted,
|
|
789
|
+
execution_worktree_clean: executionWorktreeProof.worktree_clean,
|
|
790
|
+
} : {}),
|
|
791
|
+
...(railResult || {}),
|
|
792
|
+
completed_at: runtime.completedAt,
|
|
793
|
+
});
|
|
794
|
+
}
|
|
795
|
+
return state.completionReceipt;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
/** Wait for an authority terminal and propagate timeout cleanup proof failures. */
|
|
799
|
+
export async function waitForTerminal(handle) {
|
|
800
|
+
const state = TERMINAL_AUTHORITY_STATE.get(handle);
|
|
801
|
+
if (!state) {
|
|
802
|
+
if (!handle?.process || handle.status !== 'running') return handle;
|
|
803
|
+
await new Promise((done) => {
|
|
804
|
+
handle.process.once('exit', done);
|
|
805
|
+
handle.process.once('error', done);
|
|
806
|
+
});
|
|
807
|
+
if (handle.timeoutCleanupFailure) throw handle.timeoutCleanupFailure;
|
|
808
|
+
return handle;
|
|
809
|
+
}
|
|
810
|
+
const { runtime } = state;
|
|
811
|
+
const outcome = await Promise.race([
|
|
812
|
+
runtime.exitPromise.then(() => Object.freeze({ kind: 'exit' })),
|
|
813
|
+
runtime.cleanupFailureSignal.then((error) => Object.freeze({ kind: 'cleanup_failure', error })),
|
|
814
|
+
]);
|
|
815
|
+
if (outcome.kind === 'cleanup_failure') throw outcome.error;
|
|
816
|
+
if (runtime.timeoutCleanupPromise) {
|
|
817
|
+
const cleanup = await runtime.timeoutCleanupPromise;
|
|
818
|
+
if (!cleanup.ok) throw cleanup.error;
|
|
819
|
+
}
|
|
820
|
+
if (runtime.timeoutCleanupFailure) throw runtime.timeoutCleanupFailure;
|
|
821
|
+
return handle;
|
|
822
|
+
}
|
|
41
823
|
|
|
42
824
|
/**
|
|
43
825
|
* Generate a unique terminal identifier.
|
|
@@ -99,6 +881,24 @@ export function cleanParentEnv(env) {
|
|
|
99
881
|
return cleaned;
|
|
100
882
|
}
|
|
101
883
|
|
|
884
|
+
const AUTHORITY_PROVIDER_SECRET_PREFIXES = Object.freeze({
|
|
885
|
+
claude: Object.freeze(['ANTHROPIC_', 'CLAUDE_']),
|
|
886
|
+
codex: Object.freeze(['OPENAI_', 'CODEX_']),
|
|
887
|
+
grok: Object.freeze(['XAI_', 'GROK_']),
|
|
888
|
+
});
|
|
889
|
+
const AUTHORITY_SECRET_NAME = /(API[_-]?KEY|ACCESS[_-]?KEY|PRIVATE[_-]?KEY|PASSWORD|SECRET|TOKEN|CREDENTIAL|AUTH[_-]?SOCK)/i;
|
|
890
|
+
|
|
891
|
+
export function cleanAuthorityEnv(env, provider) {
|
|
892
|
+
const allowedPrefixes = AUTHORITY_PROVIDER_SECRET_PREFIXES[provider] || [];
|
|
893
|
+
const cleaned = {};
|
|
894
|
+
for (const [key, value] of Object.entries(cleanParentEnv(env))) {
|
|
895
|
+
if (AUTHORITY_SECRET_NAME.test(key) && !allowedPrefixes.some((prefix) => key.startsWith(prefix))) continue;
|
|
896
|
+
cleaned[key] = value;
|
|
897
|
+
}
|
|
898
|
+
cleaned.PATH = AUTHORITY_SYSTEM_PATH;
|
|
899
|
+
return cleaned;
|
|
900
|
+
}
|
|
901
|
+
|
|
102
902
|
// ---------------------------------------------------------------------------
|
|
103
903
|
// Public API
|
|
104
904
|
// ---------------------------------------------------------------------------
|
|
@@ -112,11 +912,16 @@ export function cleanParentEnv(env) {
|
|
|
112
912
|
* @property {string} [providerId] - Vendor provider identifier (anthropic, openai, xai)
|
|
113
913
|
* @property {string} [reasoningConfiguration] - Requested reasoning effort
|
|
114
914
|
* @property {string} [catalogSnapshotRef] - Capability catalog snapshot used for routing
|
|
115
|
-
* @property {string} [
|
|
915
|
+
* @property {{executable_path:string,executable_sha256:string}} [providerExecutableBinding] - Sealed native provider executable
|
|
916
|
+
* @property {string} [prompt] - Full prompt string transported outside argv by the provider adapter
|
|
116
917
|
* @property {object} [contextPayload] - Context to inject via env var
|
|
117
918
|
* @property {string[]} [writeScope] - Override write scope
|
|
118
919
|
* @property {string} [workingDir] - Working directory for the process
|
|
119
920
|
* @property {number} [timeout] - Max execution time in ms
|
|
921
|
+
* @property {function} [beforeProcessSpawn] - Read-only preparation after containment is pinned
|
|
922
|
+
* @property {function} [beforeAuthoritySpawn] - Final synchronous authority reservation before spawn
|
|
923
|
+
* @property {string[]} [maskedReadPaths] - Canonical host paths hidden from an authority provider
|
|
924
|
+
* @property {string} [auditProjectDir] - Canonical CHATI project used only for the host-side model audit
|
|
120
925
|
*/
|
|
121
926
|
|
|
122
927
|
/**
|
|
@@ -140,7 +945,7 @@ export function cleanParentEnv(env) {
|
|
|
140
945
|
* perform any I/O and is therefore fully testable in isolation.
|
|
141
946
|
*
|
|
142
947
|
* @param {SpawnConfig} config
|
|
143
|
-
* @returns {{ command: string, args: string[], env: Record<string, string>, terminalId: string, prompt: string|null }}
|
|
948
|
+
* @returns {{ command: string, args: string[], env: Record<string, string>, terminalId: string, prompt: string|null, promptFile: string|null }}
|
|
144
949
|
*/
|
|
145
950
|
export function buildSpawnCommand(config) {
|
|
146
951
|
if (!config || typeof config !== 'object') {
|
|
@@ -157,9 +962,17 @@ export function buildSpawnCommand(config) {
|
|
|
157
962
|
failure.code = 'PROVIDER_REQUIRED';
|
|
158
963
|
throw failure;
|
|
159
964
|
}
|
|
965
|
+
const readOnly = Array.isArray(config.writeScope) && config.writeScope.length === 0;
|
|
966
|
+
if (readOnly && !READ_ONLY_PROVIDERS.has(config.provider)) {
|
|
967
|
+
const failure = new Error(`Selected provider "${config.provider}" has no enforced read-only terminal mode`);
|
|
968
|
+
failure.code = 'READ_ONLY_PROVIDER_UNSUPPORTED';
|
|
969
|
+
throw failure;
|
|
970
|
+
}
|
|
160
971
|
|
|
161
972
|
const terminalId = generateTerminalId(config.agent);
|
|
162
|
-
const isolationEnv =
|
|
973
|
+
const isolationEnv = Array.isArray(config.writeScope)
|
|
974
|
+
? { CHATI_WRITE_SCOPE: config.writeScope.join(','), CHATI_READ_SCOPE: '*' }
|
|
975
|
+
: buildIsolationEnv(config.agent);
|
|
163
976
|
|
|
164
977
|
const env = {
|
|
165
978
|
...isolationEnv,
|
|
@@ -167,6 +980,7 @@ export function buildSpawnCommand(config) {
|
|
|
167
980
|
CHATI_AGENT: config.agent,
|
|
168
981
|
CHATI_TASK_ID: config.taskId,
|
|
169
982
|
CHATI_SPAWNED: 'true',
|
|
983
|
+
...(readOnly ? { CHATI_EXECUTION_MODE: 'read-only' } : {}),
|
|
170
984
|
...(config.reasoningConfiguration ? { CHATI_REASONING_CONFIGURATION: config.reasoningConfiguration } : {}),
|
|
171
985
|
};
|
|
172
986
|
|
|
@@ -177,12 +991,16 @@ export function buildSpawnCommand(config) {
|
|
|
177
991
|
process.stderr.write(`[chati] spawner context-serialize: ${err.message}\n`);
|
|
178
992
|
env.CHATI_CONTEXT = '{}';
|
|
179
993
|
}
|
|
994
|
+
for (const key of TEAM_CONTEXT_ENV_KEYS) {
|
|
995
|
+
const value = config.contextPayload[key];
|
|
996
|
+
if (typeof value === 'string' && value.trim() !== '') env[key] = value;
|
|
997
|
+
}
|
|
180
998
|
}
|
|
181
999
|
|
|
182
1000
|
// Resolve the exact provider selected by the router. Never substitute a
|
|
183
1001
|
// different vendor when credentials, catalog data, or a CLI are missing.
|
|
184
1002
|
const providerName = config.provider;
|
|
185
|
-
let command, args, prompt;
|
|
1003
|
+
let command, args, prompt, promptFile;
|
|
186
1004
|
const providerFallback = null;
|
|
187
1005
|
const actualProvider = providerName;
|
|
188
1006
|
let actualModel;
|
|
@@ -196,6 +1014,7 @@ export function buildSpawnCommand(config) {
|
|
|
196
1014
|
command = adapterResult.command;
|
|
197
1015
|
args = adapterResult.args;
|
|
198
1016
|
prompt = adapterResult.stdinPrompt;
|
|
1017
|
+
promptFile = adapterResult.promptFile ?? null;
|
|
199
1018
|
actualModel = adapterResult.effectiveModel;
|
|
200
1019
|
actualReasoningConfiguration = adapterResult.effectiveReasoningConfiguration;
|
|
201
1020
|
} catch (err) {
|
|
@@ -205,7 +1024,7 @@ export function buildSpawnCommand(config) {
|
|
|
205
1024
|
}
|
|
206
1025
|
|
|
207
1026
|
return {
|
|
208
|
-
command, args, env, terminalId, prompt, providerFallback,
|
|
1027
|
+
command, args, env, terminalId, prompt, promptFile, providerFallback,
|
|
209
1028
|
actualProvider, actualModel, actualReasoningConfiguration,
|
|
210
1029
|
};
|
|
211
1030
|
}
|
|
@@ -218,100 +1037,394 @@ export function buildSpawnCommand(config) {
|
|
|
218
1037
|
*/
|
|
219
1038
|
export function spawnTerminal(config) {
|
|
220
1039
|
const {
|
|
221
|
-
command, args, env, terminalId, prompt, providerFallback,
|
|
1040
|
+
command, args, env, terminalId, prompt, promptFile, providerFallback,
|
|
222
1041
|
actualProvider, actualModel, actualReasoningConfiguration,
|
|
223
1042
|
} = buildSpawnCommand(config);
|
|
224
1043
|
|
|
225
1044
|
const cwd = config.workingDir || process.cwd();
|
|
226
1045
|
const timeout = config.timeout || 300_000; // default 5 minutes
|
|
1046
|
+
const authorityContext = config.authorityContext;
|
|
1047
|
+
if (authorityContext !== undefined) {
|
|
1048
|
+
for (const field of ['authority_kind', 'invocation_id', 'launch_nonce', 'provider_id']) {
|
|
1049
|
+
if (typeof authorityContext?.[field] !== 'string' || authorityContext[field].trim() === '') {
|
|
1050
|
+
throw Object.assign(new Error(`authorityContext.${field} is required`), { code: 'INVALID_TERMINAL_AUTHORITY_CONTEXT' });
|
|
1051
|
+
}
|
|
1052
|
+
}
|
|
1053
|
+
}
|
|
227
1054
|
|
|
228
|
-
const
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
catalogSnapshotRef: config.catalogSnapshotRef || null,
|
|
236
|
-
});
|
|
237
|
-
if (!selectionRecord.saved) {
|
|
238
|
-
const auditError = new Error(selectionRecord.error || 'Failed to persist model selection audit');
|
|
239
|
-
auditError.code = 'MODEL_SELECTION_AUDIT_FAILED';
|
|
240
|
-
throw auditError;
|
|
1055
|
+
const readOnly = Array.isArray(config.writeScope) && config.writeScope.length === 0;
|
|
1056
|
+
if (config.beforeProcessSpawn !== undefined && typeof config.beforeProcessSpawn !== 'function') {
|
|
1057
|
+
throw Object.assign(new Error('beforeProcessSpawn must be a function'), { code: 'INVALID_TERMINAL_PRESPAWN_HOOK' });
|
|
1058
|
+
}
|
|
1059
|
+
if (config.beforeAuthoritySpawn !== undefined
|
|
1060
|
+
&& (authorityContext === undefined || typeof config.beforeAuthoritySpawn !== 'function')) {
|
|
1061
|
+
throw Object.assign(new Error('beforeAuthoritySpawn requires an authority context and callback'), { code: 'INVALID_TERMINAL_PRESPAWN_HOOK' });
|
|
241
1062
|
}
|
|
242
1063
|
|
|
243
|
-
const
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
1064
|
+
const detachedProcessGroup = authorityContext !== undefined && process.platform !== 'win32';
|
|
1065
|
+
let promptRecord;
|
|
1066
|
+
let containment;
|
|
1067
|
+
let sealedProvider;
|
|
1068
|
+
let executionWorktreeBaseline;
|
|
1069
|
+
let spawnArgs = args;
|
|
1070
|
+
let child;
|
|
1071
|
+
try {
|
|
1072
|
+
if (promptFile !== null) {
|
|
1073
|
+
promptRecord = openPrivatePromptFile(promptFile);
|
|
1074
|
+
spawnArgs = materializePrivatePromptArgs(args, authorityContext === undefined
|
|
1075
|
+
? '/proc/self/fd/3'
|
|
1076
|
+
: CONTAINED_PROMPT_PATH);
|
|
1077
|
+
}
|
|
1078
|
+
containment = authorityContext === undefined ? null : buildAuthorityContainment({
|
|
1079
|
+
args: spawnArgs, cwd: resolve(cwd), readOnly, terminalId, provider: actualProvider,
|
|
1080
|
+
writeScope: config.writeScope, providerExecutableBinding: config.providerExecutableBinding, promptRecord,
|
|
1081
|
+
maskedReadPaths: config.maskedReadPaths,
|
|
1082
|
+
});
|
|
1083
|
+
if (!containment && config.providerExecutableBinding !== undefined) {
|
|
1084
|
+
if (process.platform !== 'linux') {
|
|
1085
|
+
throw Object.assign(
|
|
1086
|
+
new Error('sealed Planning provider execution requires Linux descriptor execution'),
|
|
1087
|
+
{ code: 'PROVIDER_EXECUTABLE_UNSUPPORTED' },
|
|
1088
|
+
);
|
|
1089
|
+
}
|
|
1090
|
+
sealedProvider = openPinnedProviderExecutable(config.providerExecutableBinding, {
|
|
1091
|
+
harnessId: actualProvider,
|
|
1092
|
+
projectDir: cwd,
|
|
1093
|
+
});
|
|
1094
|
+
}
|
|
1095
|
+
const auditProjectDir = config.auditProjectDir ? resolve(config.auditProjectDir) : null;
|
|
1096
|
+
if (containment && auditProjectDir && auditProjectDir !== resolve(cwd)
|
|
1097
|
+
&& !(config.maskedReadPaths || []).map((entry) => realpathSync(resolve(entry))).includes(realpathSync(auditProjectDir))) {
|
|
1098
|
+
throw Object.assign(new Error('external model audit project must be hidden from the authority provider'), { code: 'TERMINAL_CONTAINMENT_UNAVAILABLE' });
|
|
1099
|
+
}
|
|
1100
|
+
const selectionRecord = recordModelSelection(auditProjectDir ?? containment?.projectReference ?? cwd, {
|
|
1101
|
+
agent: config.agent,
|
|
1102
|
+
taskId: config.taskId,
|
|
1103
|
+
provider: actualProvider,
|
|
1104
|
+
providerId: config.providerId || null,
|
|
1105
|
+
model: actualModel,
|
|
1106
|
+
reasoningConfiguration: actualReasoningConfiguration,
|
|
1107
|
+
catalogSnapshotRef: config.catalogSnapshotRef || null,
|
|
1108
|
+
});
|
|
1109
|
+
if (!selectionRecord.saved) {
|
|
1110
|
+
const auditError = new Error(selectionRecord.error || 'Failed to persist model selection audit');
|
|
1111
|
+
auditError.code = 'MODEL_SELECTION_AUDIT_FAILED';
|
|
1112
|
+
throw auditError;
|
|
1113
|
+
}
|
|
248
1114
|
|
|
249
|
-
|
|
1115
|
+
if (authorityContext?.authority_kind === 'rail_execution'
|
|
1116
|
+
&& config.executionBaseCommitRef !== undefined) {
|
|
1117
|
+
executionWorktreeBaseline = captureRailExecutionWorktree({
|
|
1118
|
+
projectDir: cwd,
|
|
1119
|
+
baseCommitRef: config.executionBaseCommitRef,
|
|
1120
|
+
});
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
if (config.beforeProcessSpawn) config.beforeProcessSpawn();
|
|
1124
|
+
if (containment) {
|
|
1125
|
+
runTerminalSecurityTestHook('beforeAuthorityExecutableRevalidation', { containment });
|
|
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);
|
|
1146
|
+
} else if (sealedProvider) {
|
|
1147
|
+
const providerExecutable = digestProviderExecutable(
|
|
1148
|
+
sealedProvider.descriptor,
|
|
1149
|
+
sealedProvider.executable.snapshot,
|
|
1150
|
+
);
|
|
1151
|
+
if (providerExecutable.sha256 !== sealedProvider.binding.executable_sha256) {
|
|
1152
|
+
throw Object.assign(new Error('provider executable changed after installation binding validation'), { code: 'PROVIDER_EXECUTABLE_DRIFT' });
|
|
1153
|
+
}
|
|
1154
|
+
}
|
|
1155
|
+
if (executionWorktreeBaseline) {
|
|
1156
|
+
executionWorktreeBaseline = assertRailExecutionWorktreeBaselineUnchanged(executionWorktreeBaseline);
|
|
1157
|
+
}
|
|
1158
|
+
if (config.beforeAuthoritySpawn) {
|
|
1159
|
+
config.beforeAuthoritySpawn(Object.freeze({
|
|
1160
|
+
working_dir: resolve(cwd),
|
|
1161
|
+
project_identity_digest: containment.projectIdentityDigest,
|
|
1162
|
+
}));
|
|
1163
|
+
}
|
|
1164
|
+
if (executionWorktreeBaseline) {
|
|
1165
|
+
executionWorktreeBaseline = assertRailExecutionWorktreeBaselineUnchanged(executionWorktreeBaseline);
|
|
1166
|
+
}
|
|
1167
|
+
const sealedProviderDescriptorNumber = 3 + (promptRecord ? 1 : 0);
|
|
1168
|
+
const spawnCommand = containment?.command
|
|
1169
|
+
?? (sealedProvider ? `/proc/self/fd/${sealedProviderDescriptorNumber}` : command);
|
|
1170
|
+
child = spawn(spawnCommand, containment?.args ?? spawnArgs, {
|
|
1171
|
+
cwd: containment ? '/' : cwd,
|
|
1172
|
+
env: {
|
|
1173
|
+
...(containment ? cleanAuthorityEnv(process.env, actualProvider) : cleanParentEnv(process.env)), ...env,
|
|
1174
|
+
...(containment ? { TMPDIR: '/tmp', TMP: '/tmp', TEMP: '/tmp' } : {}),
|
|
1175
|
+
},
|
|
1176
|
+
stdio: containment
|
|
1177
|
+
? ['pipe', 'pipe', 'pipe', ...containment.inheritedDescriptors]
|
|
1178
|
+
: [
|
|
1179
|
+
'pipe', 'pipe', 'pipe',
|
|
1180
|
+
...(promptRecord ? [promptRecord.descriptor] : []),
|
|
1181
|
+
...(sealedProvider ? [sealedProvider.descriptor] : []),
|
|
1182
|
+
],
|
|
1183
|
+
detached: detachedProcessGroup,
|
|
1184
|
+
});
|
|
1185
|
+
if (promptRecord) {
|
|
1186
|
+
child.once('exit', () => cleanupPrivatePromptFile(promptRecord));
|
|
1187
|
+
child.once('error', () => cleanupPrivatePromptFile(promptRecord));
|
|
1188
|
+
}
|
|
1189
|
+
if (containment) {
|
|
1190
|
+
assertPinnedDirectoryChain(containment.pinnedProjectChain);
|
|
1191
|
+
}
|
|
1192
|
+
} catch (error) {
|
|
1193
|
+
if (child?.pid) {
|
|
1194
|
+
try { signalProcessTree(child, detachedProcessGroup ? child.pid : null, 'SIGKILL'); } catch { /* reservation remains fail-closed */ }
|
|
1195
|
+
}
|
|
1196
|
+
throw error;
|
|
1197
|
+
} finally {
|
|
1198
|
+
if (containment) closeAuthorityContainment(containment);
|
|
1199
|
+
if (sealedProvider?.descriptor !== undefined) try { closeSync(sealedProvider.descriptor); } catch { /* child inherited its own descriptor */ }
|
|
1200
|
+
if (promptRecord) try { closeSync(promptRecord.descriptor); } catch { /* containment already closed it */ }
|
|
1201
|
+
if (promptRecord && !child) cleanupPrivatePromptFile(promptRecord);
|
|
1202
|
+
}
|
|
1203
|
+
const processGroupId = detachedProcessGroup ? child.pid : null;
|
|
1204
|
+
|
|
1205
|
+
// Stdin-capable adapters transport prompts here. File-capable adapters use
|
|
1206
|
+
// the private descriptor prepared before spawn and leave stdin empty.
|
|
250
1207
|
if (prompt) {
|
|
251
1208
|
child.stdin.write(prompt);
|
|
252
1209
|
}
|
|
253
1210
|
child.stdin.end();
|
|
254
1211
|
|
|
1212
|
+
let resolveAuthorityExit;
|
|
1213
|
+
let signalAuthorityCleanupFailure;
|
|
1214
|
+
const runtime = authorityContext === undefined ? null : {
|
|
1215
|
+
child,
|
|
1216
|
+
processGroupId,
|
|
1217
|
+
status: 'running',
|
|
1218
|
+
exitCode: null,
|
|
1219
|
+
stdout: [],
|
|
1220
|
+
stderr: [],
|
|
1221
|
+
stdoutBytes: 0,
|
|
1222
|
+
stderrBytes: 0,
|
|
1223
|
+
outputOverflow: false,
|
|
1224
|
+
completedAt: null,
|
|
1225
|
+
killRequested: false,
|
|
1226
|
+
finalized: false,
|
|
1227
|
+
exitPromise: new Promise((resolveExit) => { resolveAuthorityExit = resolveExit; }),
|
|
1228
|
+
cleanupFailureSignal: new Promise((resolveFailure) => { signalAuthorityCleanupFailure = resolveFailure; }),
|
|
1229
|
+
timeoutCleanupPromise: null,
|
|
1230
|
+
timeoutCleanupSettled: false,
|
|
1231
|
+
timeoutCleanupFailure: null,
|
|
1232
|
+
};
|
|
1233
|
+
const processFacade = runtime ? new EventEmitter() : child;
|
|
1234
|
+
if (runtime) {
|
|
1235
|
+
Object.defineProperties(processFacade, {
|
|
1236
|
+
pid: { enumerable: true, get: () => child.pid },
|
|
1237
|
+
killed: { enumerable: true, get: () => child.killed },
|
|
1238
|
+
});
|
|
1239
|
+
Object.defineProperty(processFacade, 'kill', {
|
|
1240
|
+
enumerable: true,
|
|
1241
|
+
value: (signal) => signalProcessTree(child, processGroupId, signal),
|
|
1242
|
+
});
|
|
1243
|
+
}
|
|
1244
|
+
|
|
255
1245
|
/** @type {TerminalHandle} */
|
|
256
1246
|
const handle = {
|
|
257
1247
|
id: terminalId,
|
|
258
|
-
process:
|
|
1248
|
+
process: processFacade,
|
|
259
1249
|
agent: config.agent,
|
|
260
1250
|
taskId: config.taskId,
|
|
261
1251
|
model: actualModel || config.model || 'provider-default',
|
|
262
1252
|
provider: actualProvider,
|
|
263
|
-
reasoningConfiguration:
|
|
1253
|
+
reasoningConfiguration: actualReasoningConfiguration || null,
|
|
264
1254
|
providerFallback,
|
|
265
1255
|
startedAt: new Date().toISOString(),
|
|
266
|
-
status: 'running',
|
|
267
|
-
exitCode: null,
|
|
268
|
-
stdout: [],
|
|
269
|
-
stderr: [],
|
|
270
1256
|
timeout,
|
|
271
1257
|
};
|
|
272
1258
|
|
|
1259
|
+
if (runtime) {
|
|
1260
|
+
Object.defineProperties(handle, {
|
|
1261
|
+
status: { enumerable: true, configurable: false, get: () => runtime.status },
|
|
1262
|
+
exitCode: { enumerable: true, configurable: false, get: () => runtime.exitCode },
|
|
1263
|
+
stdout: { enumerable: true, configurable: false, get: () => Object.freeze([...runtime.stdout]) },
|
|
1264
|
+
stderr: { enumerable: true, configurable: false, get: () => Object.freeze([...runtime.stderr]) },
|
|
1265
|
+
});
|
|
1266
|
+
} else {
|
|
1267
|
+
Object.assign(handle, { status: 'running', exitCode: null, stdout: [], stderr: [] });
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
if (authorityContext !== undefined) {
|
|
1271
|
+
const context = authorityContext;
|
|
1272
|
+
const identity = Object.freeze({
|
|
1273
|
+
terminal_id: terminalId,
|
|
1274
|
+
harness_id: actualProvider,
|
|
1275
|
+
model_id: actualModel,
|
|
1276
|
+
reasoning_configuration: actualReasoningConfiguration,
|
|
1277
|
+
provider_executable_path: containment.providerExecutableBinding.executable_path,
|
|
1278
|
+
provider_executable_sha256: containment.providerExecutableBinding.executable_sha256,
|
|
1279
|
+
});
|
|
1280
|
+
const launchReceipt = terminalAuthorityReceipt({
|
|
1281
|
+
schema_version: 1,
|
|
1282
|
+
purpose: 'terminal_launch',
|
|
1283
|
+
authority_kind: context.authority_kind,
|
|
1284
|
+
invocation_id: context.invocation_id,
|
|
1285
|
+
launch_nonce: context.launch_nonce,
|
|
1286
|
+
terminal_id: terminalId,
|
|
1287
|
+
process_pid: child.pid,
|
|
1288
|
+
process_group_id: processGroupId,
|
|
1289
|
+
process_hostname: hostname(),
|
|
1290
|
+
process_containment_kind: containment.kind,
|
|
1291
|
+
process_containment_id: containment.id,
|
|
1292
|
+
process_containment_digest: containment.digest,
|
|
1293
|
+
project_identity_digest: containment.projectIdentityDigest,
|
|
1294
|
+
agent: config.agent,
|
|
1295
|
+
task_id: config.taskId,
|
|
1296
|
+
working_dir: resolve(cwd),
|
|
1297
|
+
prompt_digest: sha256(config.prompt ?? ''),
|
|
1298
|
+
provider_id: context.provider_id,
|
|
1299
|
+
harness_id: actualProvider,
|
|
1300
|
+
model_id: actualModel,
|
|
1301
|
+
reasoning_configuration: actualReasoningConfiguration,
|
|
1302
|
+
provider_executable_path: containment.providerExecutableBinding.executable_path,
|
|
1303
|
+
provider_executable_sha256: containment.providerExecutableBinding.executable_sha256,
|
|
1304
|
+
command_digest: sha256({
|
|
1305
|
+
command: containment.providerExecutableBinding.executable_path,
|
|
1306
|
+
command_sha256: containment.providerExecutableBinding.executable_sha256,
|
|
1307
|
+
args,
|
|
1308
|
+
}),
|
|
1309
|
+
...(executionWorktreeBaseline ? {
|
|
1310
|
+
execution_base_commit_ref: executionWorktreeBaseline.base_commit_ref,
|
|
1311
|
+
execution_launch_head_ref: executionWorktreeBaseline.launch_head_ref,
|
|
1312
|
+
execution_repository_identity_digest: executionWorktreeBaseline.repository_identity_digest,
|
|
1313
|
+
execution_preexisting_commits_digest: executionWorktreeBaseline.preexisting_commits_digest,
|
|
1314
|
+
execution_worktree_clean: true,
|
|
1315
|
+
} : {}),
|
|
1316
|
+
started_at: handle.startedAt,
|
|
1317
|
+
});
|
|
1318
|
+
TERMINAL_AUTHORITY_STATE.set(handle, {
|
|
1319
|
+
context: Object.freeze({ ...context }),
|
|
1320
|
+
identity,
|
|
1321
|
+
runtime,
|
|
1322
|
+
executionWorktreeBaseline,
|
|
1323
|
+
launchReceipt,
|
|
1324
|
+
completionReceipt: null,
|
|
1325
|
+
});
|
|
1326
|
+
}
|
|
1327
|
+
|
|
273
1328
|
// Record spawn in rate limiter for throttling
|
|
274
1329
|
const providerForRate = actualProvider;
|
|
275
1330
|
getRateLimiter(providerForRate).recordSpawn();
|
|
276
1331
|
|
|
277
1332
|
// Capture output (capped at ~10MB to prevent unbounded memory growth)
|
|
278
1333
|
const MAX_BUFFER_CHUNKS = 10_000;
|
|
1334
|
+
const MAX_AUTHORITY_OUTPUT_BYTES = 10 * 1024 * 1024;
|
|
1335
|
+
const captureAuthorityOutput = (stream, chunk) => {
|
|
1336
|
+
const key = stream === 'stdout' ? 'stdoutBytes' : 'stderrBytes';
|
|
1337
|
+
const bytes = Buffer.byteLength(chunk);
|
|
1338
|
+
if (runtime[key] + bytes > MAX_AUTHORITY_OUTPUT_BYTES) {
|
|
1339
|
+
if (!runtime.outputOverflow) {
|
|
1340
|
+
runtime.outputOverflow = true;
|
|
1341
|
+
runtime.stderr.push(`terminal output exceeded ${MAX_AUTHORITY_OUTPUT_BYTES} byte authority limit`);
|
|
1342
|
+
runtime.killRequested = true;
|
|
1343
|
+
try { signalProcessTree(child, processGroupId, 'SIGKILL'); } catch { /* process already exited */ }
|
|
1344
|
+
}
|
|
1345
|
+
return;
|
|
1346
|
+
}
|
|
1347
|
+
runtime[key] += bytes;
|
|
1348
|
+
runtime[stream].push(chunk);
|
|
1349
|
+
};
|
|
279
1350
|
if (child.stdout) {
|
|
280
1351
|
child.stdout.on('data', (chunk) => {
|
|
281
|
-
|
|
282
|
-
|
|
1352
|
+
const text = chunk.toString();
|
|
1353
|
+
if (runtime) { captureAuthorityOutput('stdout', text); return; }
|
|
1354
|
+
const output = handle.stdout;
|
|
1355
|
+
if (output.length < MAX_BUFFER_CHUNKS) {
|
|
1356
|
+
output.push(text);
|
|
283
1357
|
}
|
|
284
1358
|
});
|
|
285
1359
|
}
|
|
286
1360
|
if (child.stderr) {
|
|
287
1361
|
child.stderr.on('data', (chunk) => {
|
|
288
|
-
|
|
289
|
-
|
|
1362
|
+
const text = chunk.toString();
|
|
1363
|
+
if (runtime) { captureAuthorityOutput('stderr', text); return; }
|
|
1364
|
+
const output = handle.stderr;
|
|
1365
|
+
if (output.length < MAX_BUFFER_CHUNKS) {
|
|
1366
|
+
output.push(text);
|
|
290
1367
|
}
|
|
291
1368
|
});
|
|
292
1369
|
}
|
|
293
1370
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
1371
|
+
if (runtime) {
|
|
1372
|
+
const finalize = (code, error = null) => {
|
|
1373
|
+
if (runtime.finalized) return;
|
|
1374
|
+
runtime.finalized = true;
|
|
1375
|
+
runtime.status = runtime.killRequested ? 'killed' : 'exited';
|
|
1376
|
+
runtime.exitCode = Number.isInteger(code) ? code : -1;
|
|
1377
|
+
runtime.completedAt = new Date().toISOString();
|
|
1378
|
+
if (error) runtime.stderr.push(`spawn error: ${error.message}`);
|
|
1379
|
+
if (error && processFacade.listenerCount('error') > 0) processFacade.emit('error', error);
|
|
1380
|
+
processFacade.emit('exit', runtime.exitCode);
|
|
1381
|
+
processFacade.emit('close', runtime.exitCode);
|
|
1382
|
+
resolveAuthorityExit(handle);
|
|
1383
|
+
};
|
|
1384
|
+
// `close` fires only after stdio has drained, so an authority receipt can
|
|
1385
|
+
// never omit output that arrived after the OS process exited.
|
|
1386
|
+
child.once('close', (code) => finalize(code));
|
|
1387
|
+
child.once('error', (error) => finalize(-1, error));
|
|
1388
|
+
} else {
|
|
1389
|
+
child.on('exit', (code) => {
|
|
1390
|
+
handle.status = 'exited';
|
|
1391
|
+
handle.exitCode = code;
|
|
1392
|
+
});
|
|
298
1393
|
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
1394
|
+
child.on('error', (err) => {
|
|
1395
|
+
handle.status = 'exited';
|
|
1396
|
+
handle.exitCode = -1;
|
|
1397
|
+
handle.stderr.push(`spawn error: ${err.message}`);
|
|
1398
|
+
});
|
|
1399
|
+
}
|
|
304
1400
|
|
|
305
1401
|
// Enforce timeout — kill process if it exceeds max execution time
|
|
306
1402
|
const timeoutTimer = setTimeout(() => {
|
|
307
1403
|
if (handle.status === 'running') {
|
|
308
|
-
handle.stderr.push(`timeout: process exceeded ${timeout}ms limit`);
|
|
309
|
-
|
|
1404
|
+
(runtime?.stderr ?? handle.stderr).push(`timeout: process exceeded ${timeout}ms limit`);
|
|
1405
|
+
if (runtime) {
|
|
1406
|
+
runtime.timeoutCleanupPromise = killTerminal(handle).then(
|
|
1407
|
+
(result) => {
|
|
1408
|
+
runtime.timeoutCleanupSettled = true;
|
|
1409
|
+
return Object.freeze({ ok: true, result });
|
|
1410
|
+
},
|
|
1411
|
+
(error) => {
|
|
1412
|
+
runtime.timeoutCleanupSettled = true;
|
|
1413
|
+
runtime.timeoutCleanupFailure = error;
|
|
1414
|
+
signalAuthorityCleanupFailure(error);
|
|
1415
|
+
return Object.freeze({ ok: false, error });
|
|
1416
|
+
},
|
|
1417
|
+
);
|
|
1418
|
+
} else {
|
|
1419
|
+
void killTerminal(handle).catch((error) => {
|
|
1420
|
+
handle.timeoutCleanupFailure = error;
|
|
1421
|
+
});
|
|
1422
|
+
}
|
|
310
1423
|
}
|
|
311
1424
|
}, timeout);
|
|
312
1425
|
|
|
313
1426
|
// Clear timer when process exits normally
|
|
314
|
-
child.on('
|
|
1427
|
+
child.on('close', () => clearTimeout(timeoutTimer));
|
|
315
1428
|
|
|
316
1429
|
return handle;
|
|
317
1430
|
}
|
|
@@ -411,16 +1524,7 @@ export async function spawnParallelGroupAsync(configs, options = {}) {
|
|
|
411
1524
|
* when the terminal's process emits 'exit'.
|
|
412
1525
|
*/
|
|
413
1526
|
function waitForExit(handle) {
|
|
414
|
-
|
|
415
|
-
return Promise.resolve();
|
|
416
|
-
}
|
|
417
|
-
return new Promise(resolve => {
|
|
418
|
-
if (!handle.process) {
|
|
419
|
-
resolve();
|
|
420
|
-
return;
|
|
421
|
-
}
|
|
422
|
-
handle.process.once('exit', () => resolve());
|
|
423
|
-
});
|
|
1527
|
+
return waitForTerminal(handle).then(() => undefined);
|
|
424
1528
|
}
|
|
425
1529
|
|
|
426
1530
|
// Fill initial pool
|
|
@@ -468,14 +1572,58 @@ export function killTerminal(handle) {
|
|
|
468
1572
|
return Promise.resolve({ killed: false, exitCode: handle?.exitCode ?? null });
|
|
469
1573
|
}
|
|
470
1574
|
|
|
471
|
-
|
|
1575
|
+
const authorityState = TERMINAL_AUTHORITY_STATE.get(handle);
|
|
1576
|
+
const rawProcess = authorityState?.runtime?.child ?? handle.process;
|
|
1577
|
+
const processGroupId = authorityState?.runtime?.processGroupId ?? null;
|
|
1578
|
+
if (authorityState) {
|
|
1579
|
+
authorityState.runtime.killRequested = true;
|
|
1580
|
+
return (async () => {
|
|
1581
|
+
runTerminalSecurityTestHook('beforeAuthorityProcessTreeObservation', { handle });
|
|
1582
|
+
let active;
|
|
1583
|
+
try { active = processTreeIsRunning(rawProcess.pid, processGroupId); }
|
|
1584
|
+
catch (error) {
|
|
1585
|
+
throw Object.assign(new Error(`cannot observe the contained provider process tree: ${error.message}`), { code: 'TERMINAL_PROCESS_TREE_UNRESOLVED' });
|
|
1586
|
+
}
|
|
1587
|
+
if (!active) return { killed: false, exitCode: handle.exitCode };
|
|
1588
|
+
try { signalProcessTree(rawProcess, processGroupId, 'SIGTERM'); }
|
|
1589
|
+
catch { /* the containment may have exited between observation and signal */ }
|
|
1590
|
+
const waitForStop = async (attempts) => {
|
|
1591
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
1592
|
+
try { if (!processTreeIsRunning(rawProcess.pid, processGroupId)) return true; }
|
|
1593
|
+
catch (error) {
|
|
1594
|
+
throw Object.assign(new Error(`cannot observe the contained provider process tree: ${error.message}`), { code: 'TERMINAL_PROCESS_TREE_UNRESOLVED' });
|
|
1595
|
+
}
|
|
1596
|
+
await new Promise((done) => setTimeout(done, 25));
|
|
1597
|
+
}
|
|
1598
|
+
return false;
|
|
1599
|
+
};
|
|
1600
|
+
if (!(await waitForStop(200))) {
|
|
1601
|
+
try { signalProcessTree(rawProcess, processGroupId, 'SIGKILL'); }
|
|
1602
|
+
catch { /* the containment may have exited between observation and signal */ }
|
|
1603
|
+
if (!(await waitForStop(200))) {
|
|
1604
|
+
throw Object.assign(new Error('contained provider process tree survived SIGKILL'), { code: 'TERMINAL_PROCESS_TREE_STILL_RUNNING' });
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
for (let attempt = 0; attempt < 80 && handle.status === 'running'; attempt += 1) {
|
|
1608
|
+
await new Promise((done) => setTimeout(done, 25));
|
|
1609
|
+
}
|
|
1610
|
+
return { killed: true, exitCode: handle.exitCode };
|
|
1611
|
+
})();
|
|
1612
|
+
}
|
|
1613
|
+
if (handle.status === 'exited' || handle.status === 'killed') {
|
|
472
1614
|
return Promise.resolve({ killed: false, exitCode: handle.exitCode });
|
|
473
1615
|
}
|
|
474
1616
|
|
|
475
1617
|
return new Promise((resolve) => {
|
|
1618
|
+
let settled = false;
|
|
1619
|
+
const settle = (result) => {
|
|
1620
|
+
if (settled) return;
|
|
1621
|
+
settled = true;
|
|
1622
|
+
resolve(result);
|
|
1623
|
+
};
|
|
476
1624
|
const forceKillTimer = setTimeout(() => {
|
|
477
1625
|
try {
|
|
478
|
-
|
|
1626
|
+
signalProcessTree(rawProcess, processGroupId, 'SIGKILL');
|
|
479
1627
|
} catch { /* expected: process may already be dead */
|
|
480
1628
|
// already dead -- ignore
|
|
481
1629
|
}
|
|
@@ -483,17 +1631,24 @@ export function killTerminal(handle) {
|
|
|
483
1631
|
|
|
484
1632
|
handle.process.once('exit', (code) => {
|
|
485
1633
|
clearTimeout(forceKillTimer);
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
1634
|
+
if (!authorityState) {
|
|
1635
|
+
handle.status = 'killed';
|
|
1636
|
+
handle.exitCode = code;
|
|
1637
|
+
}
|
|
1638
|
+
try {
|
|
1639
|
+
if (processTreeIsRunning(rawProcess.pid, processGroupId)) {
|
|
1640
|
+
signalProcessTree(rawProcess, processGroupId, 'SIGKILL');
|
|
1641
|
+
}
|
|
1642
|
+
} catch { /* completion authority remains fail-closed if the tree cannot be observed */ }
|
|
1643
|
+
settle({ killed: true, exitCode: code });
|
|
489
1644
|
});
|
|
490
1645
|
|
|
491
1646
|
try {
|
|
492
|
-
|
|
1647
|
+
signalProcessTree(rawProcess, processGroupId, 'SIGTERM');
|
|
493
1648
|
} catch { /* expected: process may already be dead */
|
|
494
1649
|
clearTimeout(forceKillTimer);
|
|
495
|
-
handle.status = 'killed';
|
|
496
|
-
|
|
1650
|
+
if (!authorityState) handle.status = 'killed';
|
|
1651
|
+
settle({ killed: false, exitCode: handle.exitCode });
|
|
497
1652
|
}
|
|
498
1653
|
});
|
|
499
1654
|
}
|
|
@@ -554,7 +1709,8 @@ export async function spawnTerminalWithRetry(config, retryOptions = {}) {
|
|
|
554
1709
|
const maxRetries = retryOptions.maxRetries ?? 2;
|
|
555
1710
|
const baseDelay = retryOptions.baseDelay ?? 2000;
|
|
556
1711
|
const shouldRetry = retryOptions.shouldRetry || isTransientFailure;
|
|
557
|
-
|
|
1712
|
+
// Model substitution is authority-sensitive and therefore opt-in only.
|
|
1713
|
+
const enableModelFallback = retryOptions.enableModelFallback === true;
|
|
558
1714
|
|
|
559
1715
|
let lastHandle = null;
|
|
560
1716
|
|
|
@@ -563,11 +1719,7 @@ export async function spawnTerminalWithRetry(config, retryOptions = {}) {
|
|
|
563
1719
|
lastHandle = handle;
|
|
564
1720
|
|
|
565
1721
|
// Wait for process to exit
|
|
566
|
-
await
|
|
567
|
-
if (!handle.process) { resolve(); return; }
|
|
568
|
-
if (handle.status !== 'running') { resolve(); return; }
|
|
569
|
-
handle.process.once('exit', () => resolve());
|
|
570
|
-
});
|
|
1722
|
+
await waitForTerminal(handle);
|
|
571
1723
|
|
|
572
1724
|
// Success — return immediately
|
|
573
1725
|
if (handle.exitCode === 0) {
|
|
@@ -597,11 +1749,7 @@ export async function spawnTerminalWithRetry(config, retryOptions = {}) {
|
|
|
597
1749
|
const fallbackConfig = { ...config, model: fallbackModel };
|
|
598
1750
|
const fallbackHandle = spawnTerminal(fallbackConfig);
|
|
599
1751
|
|
|
600
|
-
await
|
|
601
|
-
if (!fallbackHandle.process) { resolve(); return; }
|
|
602
|
-
if (fallbackHandle.status !== 'running') { resolve(); return; }
|
|
603
|
-
fallbackHandle.process.once('exit', () => resolve());
|
|
604
|
-
});
|
|
1752
|
+
await waitForTerminal(fallbackHandle);
|
|
605
1753
|
|
|
606
1754
|
// Record fallback metadata
|
|
607
1755
|
fallbackHandle.modelFallback = {
|