@commonlyai/cli 0.1.173 → 0.1.174
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/node_modules/@commonlyai/seats/README.md +11 -0
- package/node_modules/@commonlyai/seats/package.json +22 -0
- package/node_modules/@commonlyai/seats/src/adapters/hermes.js +206 -0
- package/node_modules/@commonlyai/seats/src/adapters/stub.js +24 -0
- package/node_modules/@commonlyai/seats/src/contract.js +48 -0
- package/node_modules/@commonlyai/seats/src/failures.js +38 -0
- package/node_modules/@commonlyai/seats/src/index.js +22 -0
- package/node_modules/@commonlyai/seats/src/process-group.js +261 -0
- package/node_modules/@commonlyai/seats/src/record/format.js +635 -0
- package/node_modules/@commonlyai/seats/src/record/otel.js +238 -0
- package/node_modules/@commonlyai/seats/src/runtime-observation.js +29 -0
- package/node_modules/@commonlyai/seats/src/seat-model.js +17 -0
- package/node_modules/@commonlyai/seats/src/supervisor.js +308 -0
- package/node_modules/@commonlyai/seats/src/trace/events.js +45 -0
- package/node_modules/@commonlyai/seats/src/trace/store.js +276 -0
- package/node_modules/@commonlyai/seats/src/upstream-refusal.js +362 -0
- package/node_modules/@commonlyai/seats/src/valet/env.js +164 -0
- package/node_modules/@commonlyai/seats/src/valet/grant.js +118 -0
- package/node_modules/@commonlyai/seats/src/valet/permissions.js +213 -0
- package/package.json +6 -1
- package/src/commands/agent.js +7 -2
- package/src/lib/adapters/claude.js +8 -3
- package/src/lib/adapters/codex.js +5 -2
- package/src/lib/adapters/hermes.js +13 -194
- package/src/lib/adapters/seat-model.js +5 -12
- package/src/lib/adapters/stub.js +1 -24
- package/src/lib/runtime-observation.js +3 -27
- package/src/lib/seat-stop.js +96 -0
- package/src/lib/upstream-refusal.js +1 -354
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# packages/seats
|
|
2
|
+
|
|
3
|
+
The adapters that run an agent CLI as a supervised seat: one prompt in, one CLI run, one reply out. The package holds the contract, the registry, the failure naming, the spawn observation, the run supervisor (`superviseRun`: a turn with no progress signal for `stallMs` is traced as stuck, cancelled, and settled even if its adapter ignores the cancel), and the first adapters (`stub`, `hermes`).
|
|
4
|
+
|
|
5
|
+
It is private and is not published on its own. `@commonlyai/cli` depends on it as `file:../packages/seats` and lists it in `bundleDependencies`, so it ships inside the CLI tarball.
|
|
6
|
+
|
|
7
|
+
**Rule:** nothing here imports Commonly code. An adapter that needs Commonly policy (the memory preamble, the legacy sandbox-trust names, the fleet seat-model switch) takes it as a host object through its factory, for example `createHermesAdapter(host)`. The CLI binds that host in `cli/src/lib/adapters/<name>.js`. `cli/__tests__/seats-package.test.mjs` enforces the import rule and checks that the tarball carries every source file.
|
|
8
|
+
|
|
9
|
+
The extraction plan is D62, extract-in-place.
|
|
10
|
+
|
|
11
|
+
**Run record.** The trace store writes each run as a versioned, hash-chained JSONL record. It covers who granted which agent what access for which task, who approved each call, what was attempted, and the result. A tool outside this package can read, verify and export it to OTLP. The format is specified in [`docs/record-v1.md`](docs/record-v1.md), and a generated sample is in [`docs/examples/`](docs/examples/).
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@commonlyai/seats",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"description": "Adapters that run agent CLIs as supervised seats. Bundled inside @commonlyai/cli; not published on its own.",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": "./src/index.js",
|
|
10
|
+
"./*": "./src/*.js"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"src",
|
|
14
|
+
"README.md"
|
|
15
|
+
],
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=20.0.0"
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"test": "node --test \"test/**/*.test.js\""
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/** Hermes 0.22.2 local CLI wrapper. Hermes owns provider I/O; this file only spawns it. */
|
|
2
|
+
import { spawn as childSpawn, spawnSync } from 'child_process';
|
|
3
|
+
import { createHash } from 'crypto';
|
|
4
|
+
import { mkdir } from 'fs/promises';
|
|
5
|
+
import { homedir } from 'os';
|
|
6
|
+
import { join } from 'path';
|
|
7
|
+
import { StringDecoder } from 'string_decoder';
|
|
8
|
+
import { observeSpawn, controlsFromArguments } from '../runtime-observation.js';
|
|
9
|
+
import { requireSeatModel } from '../seat-model.js';
|
|
10
|
+
import { adapterFailure, spawnCredentials } from '../upstream-refusal.js';
|
|
11
|
+
|
|
12
|
+
export const TESTED_HERMES_VERSION = '0.22.2';
|
|
13
|
+
const DEFAULT_TIMEOUT_MS = 15 * 60 * 1000;
|
|
14
|
+
const VERSION_TIMEOUT_MS = 15000;
|
|
15
|
+
const MAX_STDERR_CHARS = 4096;
|
|
16
|
+
const MAX_LINE_CHARS = 1024 * 1024;
|
|
17
|
+
|
|
18
|
+
const readVersion = (env, spawnSyncImpl = spawnSync) => {
|
|
19
|
+
let probe;
|
|
20
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
21
|
+
probe = spawnSyncImpl('hermes', ['--version'], {
|
|
22
|
+
encoding: 'utf8', env, timeout: VERSION_TIMEOUT_MS,
|
|
23
|
+
});
|
|
24
|
+
if (probe.error?.code === 'ENOENT') return null;
|
|
25
|
+
if (probe.error || probe.status !== 0) continue;
|
|
26
|
+
const version = String(probe.stdout || '').match(/Hermes Agent v([^\s]+)/i)?.[1];
|
|
27
|
+
if (version !== TESTED_HERMES_VERSION) {
|
|
28
|
+
throw new Error(`Hermes ${version || 'unknown'} is unsupported; this adapter requires ${TESTED_HERMES_VERSION}`);
|
|
29
|
+
}
|
|
30
|
+
return version;
|
|
31
|
+
}
|
|
32
|
+
const status = probe.status ?? 'none';
|
|
33
|
+
const signal = probe.signal ?? 'none';
|
|
34
|
+
const code = probe.error?.code ?? 'none';
|
|
35
|
+
throw new Error(`Hermes version probe failed after 2 attempts (status=${status}, signal=${signal}, code=${code}, timeout=${VERSION_TIMEOUT_MS}ms); check the Hermes installation or startup log`);
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const readHermesJsonl = (line, state) => {
|
|
39
|
+
let event;
|
|
40
|
+
try { event = JSON.parse(line); } catch { throw new Error('Hermes emitted invalid JSONL'); }
|
|
41
|
+
if (state.result) throw new Error('Hermes emitted data after its terminal result');
|
|
42
|
+
if (event?.type === 'system' && event.subtype === 'init') {
|
|
43
|
+
state.started = true;
|
|
44
|
+
} else if (event?.type === 'result') {
|
|
45
|
+
state.result = event;
|
|
46
|
+
} else if (!state.started) {
|
|
47
|
+
throw new Error('Hermes emitted an event before init');
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const runHermes = ({ args, prompt, cwd, env, timeoutMs, spawnImpl, credentials, onSpawn, controls = {} }) => new Promise((resolve, reject) => {
|
|
52
|
+
const child = (spawnImpl || childSpawn)('hermes', args, {
|
|
53
|
+
cwd, env, stdio: ['pipe', 'pipe', 'pipe'],
|
|
54
|
+
});
|
|
55
|
+
observeSpawn(child, onSpawn, controls);
|
|
56
|
+
const state = { started: false, result: null };
|
|
57
|
+
const decoder = new StringDecoder('utf8');
|
|
58
|
+
let buffer = '';
|
|
59
|
+
let stderr = '';
|
|
60
|
+
let timedOut = false;
|
|
61
|
+
let settled = false;
|
|
62
|
+
let closed = false;
|
|
63
|
+
let stopping = false;
|
|
64
|
+
let killTimer;
|
|
65
|
+
const finish = (error, result) => {
|
|
66
|
+
if (settled) return;
|
|
67
|
+
settled = true;
|
|
68
|
+
clearTimeout(timer);
|
|
69
|
+
if (error) reject(error); else resolve(result);
|
|
70
|
+
};
|
|
71
|
+
const stop = () => {
|
|
72
|
+
if (stopping || closed) return;
|
|
73
|
+
stopping = true;
|
|
74
|
+
child.kill('SIGTERM');
|
|
75
|
+
killTimer = setTimeout(() => {
|
|
76
|
+
if (!closed) child.kill('SIGKILL');
|
|
77
|
+
}, 5000);
|
|
78
|
+
};
|
|
79
|
+
const failAndStop = (error) => {
|
|
80
|
+
stop();
|
|
81
|
+
finish(error);
|
|
82
|
+
};
|
|
83
|
+
const timer = setTimeout(() => {
|
|
84
|
+
timedOut = true;
|
|
85
|
+
stop();
|
|
86
|
+
}, timeoutMs);
|
|
87
|
+
child.on('error', (error) => failAndStop(adapterFailure('Hermes', error?.message || 'spawn failed', { credentials })));
|
|
88
|
+
child.stdout.on('data', (chunk) => {
|
|
89
|
+
if (settled) return;
|
|
90
|
+
buffer += decoder.write(chunk);
|
|
91
|
+
let newline = buffer.indexOf('\n');
|
|
92
|
+
while (newline !== -1) {
|
|
93
|
+
if (newline > MAX_LINE_CHARS) {
|
|
94
|
+
failAndStop(new Error('Hermes JSONL line exceeded 1 MiB'));
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
const line = buffer.slice(0, newline).trim();
|
|
98
|
+
buffer = buffer.slice(newline + 1);
|
|
99
|
+
if (line) {
|
|
100
|
+
try { readHermesJsonl(line, state); } catch (error) {
|
|
101
|
+
failAndStop(error);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
newline = buffer.indexOf('\n');
|
|
106
|
+
}
|
|
107
|
+
if (buffer.length > MAX_LINE_CHARS) {
|
|
108
|
+
failAndStop(new Error('Hermes JSONL line exceeded 1 MiB'));
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
child.stdout.on('error', (error) => failAndStop(adapterFailure('Hermes stdout', error?.message || 'read failed', { credentials })));
|
|
112
|
+
child.stderr.on('data', (chunk) => {
|
|
113
|
+
stderr = `${stderr}${chunk.toString()}`.slice(-MAX_STDERR_CHARS);
|
|
114
|
+
});
|
|
115
|
+
child.on('close', (code) => {
|
|
116
|
+
closed = true;
|
|
117
|
+
clearTimeout(killTimer);
|
|
118
|
+
if (settled) return;
|
|
119
|
+
if (timedOut) return finish(new Error(`Hermes timed out after ${timeoutMs}ms`));
|
|
120
|
+
buffer += decoder.end();
|
|
121
|
+
if (buffer.trim()) {
|
|
122
|
+
try { readHermesJsonl(buffer.trim(), state); } catch (error) { return finish(error); }
|
|
123
|
+
}
|
|
124
|
+
const result = state.result;
|
|
125
|
+
if (!result || !state.started) return finish(new Error('Hermes exited without a complete JSONL result'));
|
|
126
|
+
if (code !== 0 || result.exit_code !== 0) {
|
|
127
|
+
return finish(adapterFailure('Hermes', result.error || stderr || `exit ${code}`, {
|
|
128
|
+
credentials, exitCode: code,
|
|
129
|
+
}));
|
|
130
|
+
}
|
|
131
|
+
if (typeof result.text !== 'string' || !result.session_id) {
|
|
132
|
+
return finish(new Error('Hermes result omitted text or session_id'));
|
|
133
|
+
}
|
|
134
|
+
return finish(null, { text: result.text, newSessionId: result.session_id });
|
|
135
|
+
});
|
|
136
|
+
child.stdin.on('error', (error) => failAndStop(adapterFailure('Hermes stdin', error?.message || 'write failed', { credentials })));
|
|
137
|
+
child.stdin.end(prompt);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* @param {import('../contract.js').SeatHost & { profileRoot?: string }} [host]
|
|
142
|
+
* profileRoot is where each seat's isolated HERMES_HOME is created.
|
|
143
|
+
*/
|
|
144
|
+
export const createHermesAdapter = ({
|
|
145
|
+
memoryPreamble = (prompt) => prompt,
|
|
146
|
+
effectiveTrust = (trust) => trust,
|
|
147
|
+
seatModelRequired = () => false,
|
|
148
|
+
profileRoot = join(homedir(), '.seats', 'hermes', 'profiles'),
|
|
149
|
+
} = {}) => ({
|
|
150
|
+
get requiresModel() { return seatModelRequired(); },
|
|
151
|
+
name: 'hermes',
|
|
152
|
+
runtimeType: 'hermes',
|
|
153
|
+
supportsProvider: false,
|
|
154
|
+
providerKeyEnv: 'OPENROUTER_API_KEY',
|
|
155
|
+
|
|
156
|
+
async validateEnvironment(environment) {
|
|
157
|
+
if (environment?.provider !== undefined) {
|
|
158
|
+
throw new Error('Hermes adapter does not consume environment.provider; supply OPENROUTER_API_KEY in the host environment');
|
|
159
|
+
}
|
|
160
|
+
if (environment?.mcp?.length) {
|
|
161
|
+
throw new Error('Hermes adapter does not yet consume environment.mcp declarations');
|
|
162
|
+
}
|
|
163
|
+
if (effectiveTrust(environment?.sandbox?.trust) === 'public'
|
|
164
|
+
|| (environment?.sandbox?.mode && environment.sandbox.mode !== 'none')) {
|
|
165
|
+
throw new Error('Hermes public or sandbox-declared seats are unsupported: no enforced confinement adapter exists');
|
|
166
|
+
}
|
|
167
|
+
},
|
|
168
|
+
|
|
169
|
+
async detect() {
|
|
170
|
+
const version = readVersion(process.env);
|
|
171
|
+
return version ? { path: 'hermes', version } : null;
|
|
172
|
+
},
|
|
173
|
+
|
|
174
|
+
async spawn(prompt, ctx = {}) {
|
|
175
|
+
const seatModel = requireSeatModel(ctx.environment, 'hermes', seatModelRequired());
|
|
176
|
+
await this.validateEnvironment(ctx.environment);
|
|
177
|
+
// Keep Hermes away from the operator's default profile, including any
|
|
178
|
+
// Nous Portal/Cloud credentials there. The wrapper provides the sole key.
|
|
179
|
+
const seat = `${ctx.agentName || ''}\0${ctx.cwd || ''}`;
|
|
180
|
+
const home = ctx._hermesHome || join(profileRoot,
|
|
181
|
+
createHash('sha256').update(seat).digest('hex').slice(0, 24));
|
|
182
|
+
await mkdir(home, { recursive: true, mode: 0o700 });
|
|
183
|
+
const env = { ...(ctx.env || process.env), HERMES_HOME: home };
|
|
184
|
+
if (!readVersion(env, ctx._spawnSyncImpl)) {
|
|
185
|
+
throw new Error('Hermes Agent 0.22.2 is not on PATH');
|
|
186
|
+
}
|
|
187
|
+
if (!env.OPENROUTER_API_KEY) {
|
|
188
|
+
throw new Error('Supply the operator OPENROUTER_API_KEY through Secret Manager before running Hermes');
|
|
189
|
+
}
|
|
190
|
+
const args = ['chat', '--oneshot', '--query-file', '-', '--format', 'stream-json',
|
|
191
|
+
'--provider', 'openrouter', '--source', 'tool', '--no-restore-cwd'];
|
|
192
|
+
if (seatModel) args.push('--model', seatModel);
|
|
193
|
+
if (ctx.sessionId) args.push('--resume', ctx.sessionId);
|
|
194
|
+
const fullPrompt = memoryPreamble(prompt, ctx.memoryLongTerm, {
|
|
195
|
+
freshSession: !ctx.sessionId,
|
|
196
|
+
});
|
|
197
|
+
const requestedTimeout = Number(ctx.timeoutMs);
|
|
198
|
+
const timeoutMs = Number.isFinite(requestedTimeout) && requestedTimeout > 0
|
|
199
|
+
? Math.min(requestedTimeout, DEFAULT_TIMEOUT_MS) : DEFAULT_TIMEOUT_MS;
|
|
200
|
+
return runHermes({
|
|
201
|
+
args, prompt: fullPrompt, cwd: ctx.cwd, env, timeoutMs,
|
|
202
|
+
spawnImpl: ctx._spawnImpl, onSpawn: ctx.onSpawn, controls: controlsFromArguments(args, { modelFlag: '--model', valueFlags: ['--query-file', '--format', '--provider', '--source', '--resume'] }),
|
|
203
|
+
credentials: spawnCredentials(ctx, ['OPENROUTER_API_KEY']),
|
|
204
|
+
});
|
|
205
|
+
},
|
|
206
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Stub adapter — the no-op reference implementation of the ADR-005 adapter
|
|
3
|
+
* contract. Used by `commonly agent attach stub` and the Phase-1a test
|
|
4
|
+
* harness so the run loop can be reviewed end-to-end without a real CLI
|
|
5
|
+
* on PATH.
|
|
6
|
+
*
|
|
7
|
+
* Contract (ADR-005 §Adapter pattern):
|
|
8
|
+
* detect(): Promise<{ path, version } | null>
|
|
9
|
+
* spawn(prompt, ctx): Promise<{ text, newSessionId?, memorySummary? }>
|
|
10
|
+
*
|
|
11
|
+
* The adapter MUST be pure: input (argv + env + prompt) → output (text + optional
|
|
12
|
+
* next session-id + optional memory summary). No direct network, no direct API
|
|
13
|
+
* calls, no hidden state. The run loop handles all Commonly-facing I/O.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export default {
|
|
17
|
+
name: 'stub',
|
|
18
|
+
async detect() {
|
|
19
|
+
return { path: '(builtin)', version: '0.0.0' };
|
|
20
|
+
},
|
|
21
|
+
async spawn(prompt, _ctx) {
|
|
22
|
+
return { text: `(stub) received: ${String(prompt ?? '').slice(0, 200)}` };
|
|
23
|
+
},
|
|
24
|
+
};
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The adapter contract (ADR-005 §Adapter pattern). An adapter turns one prompt
|
|
3
|
+
* into one CLI run and one reply. It owns argv, env and output parsing for its
|
|
4
|
+
* CLI; the caller owns everything that talks to a Commonly instance.
|
|
5
|
+
*
|
|
6
|
+
* @typedef {object} Adapter
|
|
7
|
+
* @property {string} name
|
|
8
|
+
* @property {string} [runtimeType]
|
|
9
|
+
* @property {string} [providerKeyEnv] host env var the adapter's provider key arrives in
|
|
10
|
+
* @property {() => Promise<{ path: string, version: string } | null>} detect
|
|
11
|
+
* @property {(environment: object) => Promise<void>} [validateEnvironment] refuse before spawn
|
|
12
|
+
* @property {(prompt: string, ctx: object) => Promise<SpawnResult>} spawn
|
|
13
|
+
*
|
|
14
|
+
* @typedef {object} SpawnResult
|
|
15
|
+
* @property {string} text
|
|
16
|
+
* @property {string} [newSessionId]
|
|
17
|
+
* @property {number} [contextTokens]
|
|
18
|
+
* @property {string} [memorySummary]
|
|
19
|
+
*
|
|
20
|
+
* Adapters that need host policy are built by a factory that takes a SeatHost.
|
|
21
|
+
* Every field is optional; an absent field takes the neutral default named.
|
|
22
|
+
*
|
|
23
|
+
* @typedef {object} SeatHost
|
|
24
|
+
* @property {(prompt: string, longTerm: string | null | undefined, opts: { freshSession: boolean }) => string} [memoryPreamble] default: the prompt unchanged
|
|
25
|
+
* @property {(trust: string | undefined) => string | undefined} [effectiveTrust] default: the declared trust unchanged
|
|
26
|
+
* @property {() => boolean} [seatModelRequired] default: false
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* @param {Adapter[]} adapters
|
|
31
|
+
*/
|
|
32
|
+
export const createRegistry = (adapters) => {
|
|
33
|
+
const byName = {};
|
|
34
|
+
for (const adapter of adapters) {
|
|
35
|
+
if (!adapter?.name) throw new Error('every adapter needs a name');
|
|
36
|
+
if (byName[adapter.name]) throw new Error(`duplicate adapter name: ${adapter.name}`);
|
|
37
|
+
byName[adapter.name] = adapter;
|
|
38
|
+
}
|
|
39
|
+
return {
|
|
40
|
+
adapters: byName,
|
|
41
|
+
names: () => Object.keys(byName),
|
|
42
|
+
get: (name) => byName[name] || null,
|
|
43
|
+
};
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export const providerKeyEnvNames = (adapters) => [...new Set(
|
|
47
|
+
Object.values(adapters).map((adapter) => adapter.providerKeyEnv).filter(Boolean),
|
|
48
|
+
)];
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Named run failures. Every way a run can end badly has one kind, so a trace,
|
|
3
|
+
* a retry policy and a dashboard all read the same word. A kind is a contract:
|
|
4
|
+
* add one here, never mint one at a call site.
|
|
5
|
+
*/
|
|
6
|
+
import { scrubAdapterFailure } from './upstream-refusal.js';
|
|
7
|
+
|
|
8
|
+
export const FAILURE_KINDS = Object.freeze({
|
|
9
|
+
stuck: { retryable: true, meaning: 'no progress signal within the liveness window' },
|
|
10
|
+
timeout: { retryable: true, meaning: 'the run exceeded its wall-clock budget' },
|
|
11
|
+
exited: { retryable: true, meaning: 'the CLI process ended before the turn settled' },
|
|
12
|
+
protocol: { retryable: false, meaning: 'the CLI broke its wire protocol' },
|
|
13
|
+
refusal: { retryable: false, meaning: 'the model or provider refused; never retried' },
|
|
14
|
+
max_tokens: { retryable: false, meaning: 'the turn hit its token limit' },
|
|
15
|
+
max_turn_requests: { retryable: false, meaning: 'the turn hit its request limit' },
|
|
16
|
+
cancelled: { retryable: false, meaning: 'the run was cancelled' },
|
|
17
|
+
credential_denied: { retryable: false, meaning: 'the run asked for a credential its grant does not allow' },
|
|
18
|
+
unsupported: { retryable: false, meaning: 'the CLI or its version is outside what this adapter supports' },
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
export class RunFailure extends Error {
|
|
22
|
+
constructor(kind, detail, { cause } = {}) {
|
|
23
|
+
if (!FAILURE_KINDS[kind]) throw new Error(`unknown failure kind: ${kind}`);
|
|
24
|
+
super(detail ? `${kind}: ${detail}` : kind, cause ? { cause } : undefined);
|
|
25
|
+
this.name = 'RunFailure';
|
|
26
|
+
this.kind = kind;
|
|
27
|
+
this.retryable = FAILURE_KINDS[kind].retryable;
|
|
28
|
+
this.detail = detail || '';
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Build a failure whose detail has known credentials and secret shapes scrubbed. */
|
|
33
|
+
export const runFailure = (kind, text, { credentials = [], limit } = {}) => {
|
|
34
|
+
const { detail } = scrubAdapterFailure(String(text ?? ''), { credentials, limit });
|
|
35
|
+
return new RunFailure(kind, detail);
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export const failureKindOf = (error) => (error instanceof RunFailure ? error.kind : null);
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export { createRegistry, providerKeyEnvNames } from './contract.js';
|
|
2
|
+
export { requireSeatModel } from './seat-model.js';
|
|
3
|
+
export { controlsFromArguments, observeSpawn } from './runtime-observation.js';
|
|
4
|
+
export {
|
|
5
|
+
LEADS_PROCESS_GROUP, OWN_PROCESS_GROUP, spawnInOwnGroup, wantsOwnProcessGroup, leadsOwnGroup, killProcessTree,
|
|
6
|
+
trackSupervisedGroup, killSupervisedGroups, armSupervisedGroupDeadline,
|
|
7
|
+
readProcessTable, readProcess, descendantsOf, killDescendants,
|
|
8
|
+
} from './process-group.js';
|
|
9
|
+
export { adapterFailure, spawnCredentials } from './upstream-refusal.js';
|
|
10
|
+
export { default as stub } from './adapters/stub.js';
|
|
11
|
+
export { createHermesAdapter } from './adapters/hermes.js';
|
|
12
|
+
export { RunFailure, runFailure, failureKindOf, FAILURE_KINDS } from './failures.js';
|
|
13
|
+
export { EVENT_TYPES, isEventType, traceOf, NULL_TRACE } from './trace/events.js';
|
|
14
|
+
export { createTraceStore } from './trace/store.js';
|
|
15
|
+
export { validateGrant, matchToolCall, TOOL_KINDS, CREDENTIAL_SOURCES } from './valet/grant.js';
|
|
16
|
+
export { prepareRunEnv, BASE_ENV_ALLOWLIST } from './valet/env.js';
|
|
17
|
+
export { createPermissionPolicy, DEFAULT_APPROVE_TIMEOUT_MS } from './valet/permissions.js';
|
|
18
|
+
export {
|
|
19
|
+
RECORD_VERSION, RECORD_SCHEMA, KINDS, canonicalLine, appendEntry, verifyRecord, readRecord, hashLine, toolSurfaceOf, argsDigestOf,
|
|
20
|
+
} from './record/format.js';
|
|
21
|
+
export { toOtlpJson } from './record/otel.js';
|
|
22
|
+
export { superviseRun, SUPERVISOR_DEFAULTS, DEFAULT_RETRY_ON } from './supervisor.js';
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Process groups for supervised children.
|
|
3
|
+
*
|
|
4
|
+
* An agent CLI spawns its own children: MCP servers through `npx`, a native
|
|
5
|
+
* binary behind a node launcher, shells for tools. Killing only the CLI leaves
|
|
6
|
+
* those running, holding the run's work and keys on a shared host. A child the
|
|
7
|
+
* supervisor will kill is therefore started as the leader of its own process
|
|
8
|
+
* group, and killed as a group.
|
|
9
|
+
*
|
|
10
|
+
* Leading a group is never inferred. The spawner that passed `detached` marks
|
|
11
|
+
* the child, and only a marked child that is still alive is ever signalled by
|
|
12
|
+
* negative pid: signalling `-pid` for a process that does not lead its group
|
|
13
|
+
* can reach an unrelated group.
|
|
14
|
+
*
|
|
15
|
+
* A group kill misses a descendant that moved to a group of its own (codex
|
|
16
|
+
* starts every MCP server and tool command that way; claude's Bash tool does
|
|
17
|
+
* too). So before any signal, the kill also snapshots the process table and
|
|
18
|
+
* walks the child's descendants, deepest first. Each pid is re-read right
|
|
19
|
+
* before it is signalled and killed only if its parent and start time still
|
|
20
|
+
* match the snapshot: a pid that exited and was reused by another process on
|
|
21
|
+
* this host is left alone. Leaves go first so a pid's parent is still alive,
|
|
22
|
+
* and its ppid still meaningful, when it is checked.
|
|
23
|
+
*
|
|
24
|
+
* What the walk does not catch, by design, because each case would need the
|
|
25
|
+
* ppid match loosened:
|
|
26
|
+
* (a) a descendant reparented to 1 (its parent died) between the snapshot
|
|
27
|
+
* and its re-read is skipped: its ppid no longer matches;
|
|
28
|
+
* (b) a process started after the snapshot is not in it, and one that calls
|
|
29
|
+
* setsid after the snapshot leaves the group the group kill signals;
|
|
30
|
+
* either is missed.
|
|
31
|
+
*
|
|
32
|
+
* A child in its own group is outside the wrapper's group, so launchd's kill
|
|
33
|
+
* of the seat's group on exit no longer reaches it. `trackSupervisedGroup`
|
|
34
|
+
* covers that: while any tracked group is live, the wrapper kills them all
|
|
35
|
+
* when it exits, and on SIGTERM, SIGINT or SIGHUP when nothing else handles
|
|
36
|
+
* the signal. A host that handles the signal itself (`agent run` finishes the
|
|
37
|
+
* in-flight turn on SIGTERM) owns the timing: it calls
|
|
38
|
+
* `armSupervisedGroupDeadline` so the groups die before launchd's SIGKILL,
|
|
39
|
+
* which no handler can catch, and the exit hook covers the clean case.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
import { spawnSync } from 'node:child_process';
|
|
43
|
+
|
|
44
|
+
/** Set on a child by the code that spawned it with `detached: true`. */
|
|
45
|
+
export const LEADS_PROCESS_GROUP = Symbol.for('@commonlyai/seats.leadsProcessGroup');
|
|
46
|
+
|
|
47
|
+
/** Whether a child can lead its own group here (POSIX only). */
|
|
48
|
+
export const PROCESS_GROUPS_SUPPORTED = process.platform !== 'win32';
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* The ctx key the supervisor sets to tell an adapter it will kill the child's
|
|
52
|
+
* group, so the adapter may start the child in one. Unset, an adapter spawns
|
|
53
|
+
* exactly as it would unsupervised.
|
|
54
|
+
*/
|
|
55
|
+
export const OWN_PROCESS_GROUP = 'ownProcessGroup';
|
|
56
|
+
|
|
57
|
+
export const wantsOwnProcessGroup = (ctx) => ctx?.[OWN_PROCESS_GROUP] === true && PROCESS_GROUPS_SUPPORTED;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Spawn through `spawnImpl`. With `ownGroup`, the child is started detached
|
|
61
|
+
* (it calls setsid, so it leads a new group whose id is its pid) and marked.
|
|
62
|
+
* Without it, `options` reach `spawnImpl` untouched.
|
|
63
|
+
*/
|
|
64
|
+
export const spawnInOwnGroup = (spawnImpl, command, args, options, { ownGroup = false } = {}) => {
|
|
65
|
+
if (!ownGroup || !PROCESS_GROUPS_SUPPORTED) return spawnImpl(command, args, options);
|
|
66
|
+
const child = spawnImpl(command, args, { ...options, detached: true });
|
|
67
|
+
if (child && typeof child === 'object') child[LEADS_PROCESS_GROUP] = true;
|
|
68
|
+
return child;
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const isAlive = (child) => child.exitCode === null && child.signalCode === null;
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* True only for a child its spawner marked, with a real pid, that has not been
|
|
75
|
+
* reaped. Node sets exitCode/signalCode in the same tick it reaps, so while
|
|
76
|
+
* this holds the pid (and so the group id) cannot have been reused.
|
|
77
|
+
*/
|
|
78
|
+
export const leadsOwnGroup = (child) => !!child
|
|
79
|
+
&& child[LEADS_PROCESS_GROUP] === true
|
|
80
|
+
&& Number.isInteger(child.pid) && child.pid > 0
|
|
81
|
+
&& isAlive(child);
|
|
82
|
+
|
|
83
|
+
// ── The process table ──────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
const PS_ENV = { ...process.env, LC_ALL: 'C' };
|
|
86
|
+
|
|
87
|
+
const parseTable = (text) => {
|
|
88
|
+
const rows = [];
|
|
89
|
+
for (const line of String(text || '').split('\n')) {
|
|
90
|
+
const match = /^\s*(\d+)\s+(\d+)\s+(\S.*?)\s*$/.exec(line);
|
|
91
|
+
if (match) rows.push({ pid: Number(match[1]), ppid: Number(match[2]), started: match[3] });
|
|
92
|
+
}
|
|
93
|
+
return rows;
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const ps = (args) => {
|
|
97
|
+
try {
|
|
98
|
+
const out = spawnSync('ps', args, { encoding: 'utf8', env: PS_ENV, timeout: 5000 });
|
|
99
|
+
return out.error ? null : parseTable(out.stdout);
|
|
100
|
+
} catch { return null; }
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/** Every process: { pid, ppid, started }. null when ps is unavailable. */
|
|
104
|
+
export const readProcessTable = () => ps(['-A', '-o', 'pid=,ppid=,lstart=']);
|
|
105
|
+
|
|
106
|
+
/** One process as it is now, or null when it is gone. */
|
|
107
|
+
export const readProcess = (pid) => (ps(['-o', 'pid=,ppid=,lstart=', '-p', String(pid)]) || [])
|
|
108
|
+
.find((row) => row.pid === pid) || null;
|
|
109
|
+
|
|
110
|
+
/** The descendants of `root` in `table`, deepest first. The root is not included. */
|
|
111
|
+
export const descendantsOf = (table, root) => {
|
|
112
|
+
const byParent = new Map();
|
|
113
|
+
for (const row of table || []) {
|
|
114
|
+
if (row.pid === row.ppid) continue;
|
|
115
|
+
if (!byParent.has(row.ppid)) byParent.set(row.ppid, []);
|
|
116
|
+
byParent.get(row.ppid).push(row);
|
|
117
|
+
}
|
|
118
|
+
const out = [];
|
|
119
|
+
const seen = new Set([root]);
|
|
120
|
+
let level = [root];
|
|
121
|
+
for (let depth = 1; level.length; depth += 1) {
|
|
122
|
+
const next = [];
|
|
123
|
+
for (const parent of level) {
|
|
124
|
+
for (const row of byParent.get(parent) || []) {
|
|
125
|
+
if (seen.has(row.pid)) continue;
|
|
126
|
+
seen.add(row.pid);
|
|
127
|
+
out.push({ ...row, depth });
|
|
128
|
+
next.push(row.pid);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
level = next;
|
|
132
|
+
}
|
|
133
|
+
return out.sort((a, b) => b.depth - a.depth);
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const DEFAULT_IO = Object.freeze({ table: readProcessTable, read: readProcess });
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Signal each snapshotted descendant, deepest first, re-reading it just
|
|
140
|
+
* before: it is signalled only while its ppid and start time still match.
|
|
141
|
+
* Returns the pids signalled.
|
|
142
|
+
*/
|
|
143
|
+
export const killDescendants = (descendants, signal = 'SIGKILL', { read = readProcess } = {}) => {
|
|
144
|
+
const killed = [];
|
|
145
|
+
for (const row of descendants) {
|
|
146
|
+
const now = read(row.pid);
|
|
147
|
+
if (!now || now.ppid !== row.ppid || now.started !== row.started) continue;
|
|
148
|
+
try { process.kill(row.pid, signal); killed.push(row.pid); } catch { /* gone */ }
|
|
149
|
+
}
|
|
150
|
+
return killed;
|
|
151
|
+
};
|
|
152
|
+
|
|
153
|
+
const liveRoot = (child) => !!child && Number.isInteger(child.pid) && child.pid > 0 && isAlive(child);
|
|
154
|
+
|
|
155
|
+
const killOne = (child, signal, table, io) => {
|
|
156
|
+
// The root must be alive, so unreaped, so its pid still names this child.
|
|
157
|
+
if (table && liveRoot(child)) killDescendants(descendantsOf(table, child.pid), signal, io);
|
|
158
|
+
if (leadsOwnGroup(child)) {
|
|
159
|
+
try { process.kill(-child.pid, signal); return true; } catch { /* fall back to the leader */ }
|
|
160
|
+
}
|
|
161
|
+
try { return child.kill(signal) !== false; } catch { return false; }
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Kill a child, its verified descendants, and its whole group when it leads
|
|
166
|
+
* one. The table is read before any signal. Falls back to `child.kill` when
|
|
167
|
+
* the child does not lead a group, or the group signal fails. Returns whether
|
|
168
|
+
* the child was signalled. `io` is a test seam ({ table, read }).
|
|
169
|
+
*/
|
|
170
|
+
export const killProcessTree = (child, signal = 'SIGKILL', io = {}) => {
|
|
171
|
+
const seam = { ...DEFAULT_IO, ...io };
|
|
172
|
+
return killOne(child, signal, liveRoot(child) ? seam.table() : null, seam);
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
// ── Wrapper-exit cleanup ────────────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
const EXIT_SIGNALS = ['SIGTERM', 'SIGINT', 'SIGHUP'];
|
|
178
|
+
const tracked = new Set();
|
|
179
|
+
const handlers = new Map();
|
|
180
|
+
let onExit = null;
|
|
181
|
+
let deadline = null;
|
|
182
|
+
|
|
183
|
+
/** SIGKILL every tracked group still led by a live child, with its verified descendants. Synchronous. */
|
|
184
|
+
export const killSupervisedGroups = (signal = 'SIGKILL', io = {}) => {
|
|
185
|
+
const live = [...tracked].filter(leadsOwnGroup);
|
|
186
|
+
if (live.length === 0) return 0;
|
|
187
|
+
const seam = { ...DEFAULT_IO, ...io };
|
|
188
|
+
const table = seam.table();
|
|
189
|
+
let killed = 0;
|
|
190
|
+
for (const child of live) if (killOne(child, signal, table, seam)) killed += 1;
|
|
191
|
+
return killed;
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Kill the tracked groups in `ms`, for a host that keeps running after a
|
|
196
|
+
* signal (to finish a turn) but will be SIGKILLed at a known deadline. The
|
|
197
|
+
* earliest deadline armed wins. The timer does not hold the process open.
|
|
198
|
+
* Returns a function that disarms it.
|
|
199
|
+
*/
|
|
200
|
+
export const armSupervisedGroupDeadline = (ms) => {
|
|
201
|
+
const wait = Math.max(0, Number(ms) || 0);
|
|
202
|
+
const at = Date.now() + wait;
|
|
203
|
+
if (deadline && deadline.at <= at) return deadline.cancel;
|
|
204
|
+
if (deadline) deadline.cancel();
|
|
205
|
+
const timer = setTimeout(() => { deadline = null; killSupervisedGroups(); }, wait);
|
|
206
|
+
timer.unref?.();
|
|
207
|
+
const entry = { at, cancel: () => { clearTimeout(timer); if (deadline === entry) deadline = null; } };
|
|
208
|
+
deadline = entry;
|
|
209
|
+
return entry.cancel;
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
const uninstall = () => {
|
|
213
|
+
for (const [signal, handler] of handlers) process.removeListener(signal, handler);
|
|
214
|
+
handlers.clear();
|
|
215
|
+
if (onExit) process.removeListener('exit', onExit);
|
|
216
|
+
onExit = null;
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
const install = () => {
|
|
220
|
+
if (onExit) return;
|
|
221
|
+
onExit = () => { killSupervisedGroups(); };
|
|
222
|
+
process.on('exit', onExit);
|
|
223
|
+
for (const signal of EXIT_SIGNALS) {
|
|
224
|
+
const handler = () => {
|
|
225
|
+
// A host that handles this signal has taken over shutdown: it may be
|
|
226
|
+
// finishing the turn these groups belong to, so they are left alone and
|
|
227
|
+
// the host's deadline or exit kills them.
|
|
228
|
+
if (process.listenerCount(signal) !== 1) return;
|
|
229
|
+
killSupervisedGroups();
|
|
230
|
+
// Listening for a signal replaces its default action. With nothing else
|
|
231
|
+
// handling it, restore that action and re-raise, so the wrapper still
|
|
232
|
+
// terminates exactly as it would have without this module.
|
|
233
|
+
uninstall();
|
|
234
|
+
process.kill(process.pid, signal);
|
|
235
|
+
};
|
|
236
|
+
handlers.set(signal, handler);
|
|
237
|
+
process.on(signal, handler);
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
const untrack = (child) => {
|
|
242
|
+
tracked.delete(child);
|
|
243
|
+
if (tracked.size === 0) uninstall();
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* Track a group-leading child until it exits. The handlers exist only while a
|
|
248
|
+
* tracked child is live, so a wrapper with no supervised group in flight keeps
|
|
249
|
+
* its signal behaviour unchanged. A child that does not lead a group is
|
|
250
|
+
* ignored: launchd's group kill already reaches it.
|
|
251
|
+
*/
|
|
252
|
+
export const trackSupervisedGroup = (child) => {
|
|
253
|
+
if (!leadsOwnGroup(child) || tracked.has(child)) return false;
|
|
254
|
+
tracked.add(child);
|
|
255
|
+
install();
|
|
256
|
+
child.once?.('exit', () => untrack(child));
|
|
257
|
+
return true;
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
/** For tests: how many groups are tracked. */
|
|
261
|
+
export const trackedGroupCount = () => tracked.size;
|