@commonlyai/cli 0.1.172 → 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/README.md +14 -4
- 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 +19 -8
- 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
- package/src/lib/handoff-memory.js +0 -23
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { codexRuntimeInventory } from '../native-runtime-inventory.js';
|
|
2
2
|
import { observeSpawn, controlsFromArguments } from '../runtime-observation.js';
|
|
3
|
+
import { spawnInOwnGroup, wantsOwnProcessGroup } from '@commonlyai/seats/process-group';
|
|
3
4
|
/**
|
|
4
5
|
* codex adapter — wraps the local `codex` CLI as a Commonly agent.
|
|
5
6
|
*
|
|
@@ -572,14 +573,15 @@ const CODEX_PROVIDER_KEY_ENVS = [
|
|
|
572
573
|
'CODEX_API_KEY',
|
|
573
574
|
];
|
|
574
575
|
|
|
575
|
-
|
|
576
|
+
// `ownGroup`: see runClaude in claude.js.
|
|
577
|
+
const runCodex = ({ args, cwd, env, timeoutMs, credentials = [], spawnImpl = childSpawn, onSpawn, controls = {}, ownGroup = false }) => new Promise((resolve, reject) => { // stdio: ['ignore', 'pipe', 'pipe'] — without this, child_process.spawn
|
|
576
578
|
// defaults stdin to a fresh pipe. Codex 0.125.0's `exec` then blocks on
|
|
577
579
|
// `Reading additional input from stdin...` because it sees an open pipe
|
|
578
580
|
// and waits for input that never arrives. Interactive runs are fine because
|
|
579
581
|
// codex detects a TTY and uses the argv prompt directly. Setting stdin to
|
|
580
582
|
// `'ignore'` gives codex /dev/null → immediate EOF → it falls back to the
|
|
581
583
|
// argv prompt as intended. Surfaced live during ADR-005 Phase 2 smoke.
|
|
582
|
-
const proc = spawnImpl
|
|
584
|
+
const proc = spawnInOwnGroup(spawnImpl, 'codex', args, { cwd, env, stdio: ['ignore', 'pipe', 'pipe'] }, { ownGroup });
|
|
583
585
|
observeSpawn(proc, onSpawn, controls);
|
|
584
586
|
let stderr = '';
|
|
585
587
|
let timedOut = false;
|
|
@@ -745,6 +747,7 @@ export default {
|
|
|
745
747
|
// nothing left to compare a tail against.
|
|
746
748
|
credentials: spawnCredentials(ctx, CODEX_PROVIDER_KEY_ENVS),
|
|
747
749
|
spawnImpl: ctx._spawnImpl, onSpawn: ctx.onSpawn, controls: controlsFromArguments(args.slice(args.indexOf('exec') + (args[args.indexOf('exec') + 1] === 'resume' ? 3 : 1), -1), { modelFlag: '--model', valueFlags: ['-c', '-o', '--permission-profile'], codex: true }), // test seam only — do not use in production
|
|
750
|
+
ownGroup: wantsOwnProcessGroup(ctx),
|
|
748
751
|
});
|
|
749
752
|
|
|
750
753
|
let text = '';
|
|
@@ -1,199 +1,18 @@
|
|
|
1
|
-
import { observeSpawn, controlsFromArguments } from '../runtime-observation.js';
|
|
2
|
-
/** Hermes 0.22.2 local CLI wrapper. Hermes owns provider I/O; this file only spawns it. */
|
|
3
|
-
import { requireSeatModel, seatModelRequired } from './seat-model.js';
|
|
4
|
-
import { spawn as childSpawn, spawnSync } from 'child_process';
|
|
5
|
-
import { createHash } from 'crypto';
|
|
6
|
-
import { mkdir } from 'fs/promises';
|
|
7
1
|
import { homedir } from 'os';
|
|
8
2
|
import { join } from 'path';
|
|
9
|
-
import {
|
|
3
|
+
import { createHermesAdapter } from '@commonlyai/seats/adapters/hermes';
|
|
10
4
|
import { buildMemoryPreamble } from '../memory-bridge.js';
|
|
11
5
|
import { effectiveSandboxTrust } from '../environment.js';
|
|
12
|
-
import {
|
|
13
|
-
|
|
14
|
-
export
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
encoding: 'utf8', env, timeout: VERSION_TIMEOUT_MS,
|
|
25
|
-
});
|
|
26
|
-
if (probe.error?.code === 'ENOENT') return null;
|
|
27
|
-
if (probe.error || probe.status !== 0) continue;
|
|
28
|
-
const version = String(probe.stdout || '').match(/Hermes Agent v([^\s]+)/i)?.[1];
|
|
29
|
-
if (version !== TESTED_HERMES_VERSION) {
|
|
30
|
-
throw new Error(`Hermes ${version || 'unknown'} is unsupported; this adapter requires ${TESTED_HERMES_VERSION}`);
|
|
31
|
-
}
|
|
32
|
-
return version;
|
|
33
|
-
}
|
|
34
|
-
const status = probe.status ?? 'none';
|
|
35
|
-
const signal = probe.signal ?? 'none';
|
|
36
|
-
const code = probe.error?.code ?? 'none';
|
|
37
|
-
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`);
|
|
38
|
-
};
|
|
39
|
-
|
|
40
|
-
export const readHermesJsonl = (line, state) => {
|
|
41
|
-
let event;
|
|
42
|
-
try { event = JSON.parse(line); } catch { throw new Error('Hermes emitted invalid JSONL'); }
|
|
43
|
-
if (state.result) throw new Error('Hermes emitted data after its terminal result');
|
|
44
|
-
if (event?.type === 'system' && event.subtype === 'init') {
|
|
45
|
-
state.started = true;
|
|
46
|
-
} else if (event?.type === 'result') {
|
|
47
|
-
state.result = event;
|
|
48
|
-
} else if (!state.started) {
|
|
49
|
-
throw new Error('Hermes emitted an event before init');
|
|
50
|
-
}
|
|
51
|
-
};
|
|
52
|
-
|
|
53
|
-
const runHermes = ({ args, prompt, cwd, env, timeoutMs, spawnImpl, credentials, onSpawn, controls = {} }) => new Promise((resolve, reject) => {
|
|
54
|
-
const child = (spawnImpl || childSpawn)('hermes', args, {
|
|
55
|
-
cwd, env, stdio: ['pipe', 'pipe', 'pipe'],
|
|
56
|
-
});
|
|
57
|
-
observeSpawn(child, onSpawn, controls);
|
|
58
|
-
const state = { started: false, result: null };
|
|
59
|
-
const decoder = new StringDecoder('utf8');
|
|
60
|
-
let buffer = '';
|
|
61
|
-
let stderr = '';
|
|
62
|
-
let timedOut = false;
|
|
63
|
-
let settled = false;
|
|
64
|
-
let closed = false;
|
|
65
|
-
let stopping = false;
|
|
66
|
-
let killTimer;
|
|
67
|
-
const finish = (error, result) => {
|
|
68
|
-
if (settled) return;
|
|
69
|
-
settled = true;
|
|
70
|
-
clearTimeout(timer);
|
|
71
|
-
if (error) reject(error); else resolve(result);
|
|
72
|
-
};
|
|
73
|
-
const stop = () => {
|
|
74
|
-
if (stopping || closed) return;
|
|
75
|
-
stopping = true;
|
|
76
|
-
child.kill('SIGTERM');
|
|
77
|
-
killTimer = setTimeout(() => {
|
|
78
|
-
if (!closed) child.kill('SIGKILL');
|
|
79
|
-
}, 5000);
|
|
80
|
-
};
|
|
81
|
-
const failAndStop = (error) => {
|
|
82
|
-
stop();
|
|
83
|
-
finish(error);
|
|
84
|
-
};
|
|
85
|
-
const timer = setTimeout(() => {
|
|
86
|
-
timedOut = true;
|
|
87
|
-
stop();
|
|
88
|
-
}, timeoutMs);
|
|
89
|
-
child.on('error', (error) => failAndStop(adapterFailure('Hermes', error?.message || 'spawn failed', { credentials })));
|
|
90
|
-
child.stdout.on('data', (chunk) => {
|
|
91
|
-
if (settled) return;
|
|
92
|
-
buffer += decoder.write(chunk);
|
|
93
|
-
let newline = buffer.indexOf('\n');
|
|
94
|
-
while (newline !== -1) {
|
|
95
|
-
if (newline > MAX_LINE_CHARS) {
|
|
96
|
-
failAndStop(new Error('Hermes JSONL line exceeded 1 MiB'));
|
|
97
|
-
return;
|
|
98
|
-
}
|
|
99
|
-
const line = buffer.slice(0, newline).trim();
|
|
100
|
-
buffer = buffer.slice(newline + 1);
|
|
101
|
-
if (line) {
|
|
102
|
-
try { readHermesJsonl(line, state); } catch (error) {
|
|
103
|
-
failAndStop(error);
|
|
104
|
-
return;
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
newline = buffer.indexOf('\n');
|
|
108
|
-
}
|
|
109
|
-
if (buffer.length > MAX_LINE_CHARS) {
|
|
110
|
-
failAndStop(new Error('Hermes JSONL line exceeded 1 MiB'));
|
|
111
|
-
}
|
|
112
|
-
});
|
|
113
|
-
child.stdout.on('error', (error) => failAndStop(adapterFailure('Hermes stdout', error?.message || 'read failed', { credentials })));
|
|
114
|
-
child.stderr.on('data', (chunk) => {
|
|
115
|
-
stderr = `${stderr}${chunk.toString()}`.slice(-MAX_STDERR_CHARS);
|
|
116
|
-
});
|
|
117
|
-
child.on('close', (code) => {
|
|
118
|
-
closed = true;
|
|
119
|
-
clearTimeout(killTimer);
|
|
120
|
-
if (settled) return;
|
|
121
|
-
if (timedOut) return finish(new Error(`Hermes timed out after ${timeoutMs}ms`));
|
|
122
|
-
buffer += decoder.end();
|
|
123
|
-
if (buffer.trim()) {
|
|
124
|
-
try { readHermesJsonl(buffer.trim(), state); } catch (error) { return finish(error); }
|
|
125
|
-
}
|
|
126
|
-
const result = state.result;
|
|
127
|
-
if (!result || !state.started) return finish(new Error('Hermes exited without a complete JSONL result'));
|
|
128
|
-
if (code !== 0 || result.exit_code !== 0) {
|
|
129
|
-
return finish(adapterFailure('Hermes', result.error || stderr || `exit ${code}`, {
|
|
130
|
-
credentials, exitCode: code,
|
|
131
|
-
}));
|
|
132
|
-
}
|
|
133
|
-
if (typeof result.text !== 'string' || !result.session_id) {
|
|
134
|
-
return finish(new Error('Hermes result omitted text or session_id'));
|
|
135
|
-
}
|
|
136
|
-
return finish(null, { text: result.text, newSessionId: result.session_id });
|
|
137
|
-
});
|
|
138
|
-
child.stdin.on('error', (error) => failAndStop(adapterFailure('Hermes stdin', error?.message || 'write failed', { credentials })));
|
|
139
|
-
child.stdin.end(prompt);
|
|
6
|
+
import { seatModelRequired } from './seat-model.js';
|
|
7
|
+
|
|
8
|
+
export { TESTED_HERMES_VERSION, readHermesJsonl } from '@commonlyai/seats/adapters/hermes';
|
|
9
|
+
|
|
10
|
+
// Commonly's policy for the generic Hermes adapter: its memory preamble, its
|
|
11
|
+
// legacy sandbox-trust names, the fleet seat-model switch, and the profile
|
|
12
|
+
// directory seats have always used.
|
|
13
|
+
export default createHermesAdapter({
|
|
14
|
+
memoryPreamble: buildMemoryPreamble,
|
|
15
|
+
effectiveTrust: effectiveSandboxTrust,
|
|
16
|
+
seatModelRequired,
|
|
17
|
+
profileRoot: join(homedir(), '.commonly', 'hermes', 'profiles'),
|
|
140
18
|
});
|
|
141
|
-
|
|
142
|
-
export default {
|
|
143
|
-
get requiresModel() { return seatModelRequired(); },
|
|
144
|
-
name: 'hermes',
|
|
145
|
-
runtimeType: 'hermes',
|
|
146
|
-
supportsProvider: false,
|
|
147
|
-
providerKeyEnv: 'OPENROUTER_API_KEY',
|
|
148
|
-
|
|
149
|
-
async validateEnvironment(environment) {
|
|
150
|
-
if (environment?.provider !== undefined) {
|
|
151
|
-
throw new Error('Hermes adapter does not consume environment.provider; supply OPENROUTER_API_KEY in the host environment');
|
|
152
|
-
}
|
|
153
|
-
if (environment?.mcp?.length) {
|
|
154
|
-
throw new Error('Hermes adapter does not yet consume environment.mcp declarations');
|
|
155
|
-
}
|
|
156
|
-
if (effectiveSandboxTrust(environment?.sandbox?.trust) === 'public'
|
|
157
|
-
|| (environment?.sandbox?.mode && environment.sandbox.mode !== 'none')) {
|
|
158
|
-
throw new Error('Hermes public or sandbox-declared seats are unsupported: no enforced confinement adapter exists');
|
|
159
|
-
}
|
|
160
|
-
},
|
|
161
|
-
|
|
162
|
-
async detect() {
|
|
163
|
-
const version = readVersion(process.env);
|
|
164
|
-
return version ? { path: 'hermes', version } : null;
|
|
165
|
-
},
|
|
166
|
-
|
|
167
|
-
async spawn(prompt, ctx = {}) {
|
|
168
|
-
const seatModel = requireSeatModel(ctx.environment, 'hermes');
|
|
169
|
-
await this.validateEnvironment(ctx.environment);
|
|
170
|
-
// Keep Hermes away from the operator's default profile, including any
|
|
171
|
-
// Nous Portal/Cloud credentials there. The wrapper provides the sole key.
|
|
172
|
-
const seat = `${ctx.agentName || ''}\0${ctx.cwd || ''}`;
|
|
173
|
-
const home = ctx._hermesHome || join(homedir(), '.commonly', 'hermes', 'profiles',
|
|
174
|
-
createHash('sha256').update(seat).digest('hex').slice(0, 24));
|
|
175
|
-
await mkdir(home, { recursive: true, mode: 0o700 });
|
|
176
|
-
const env = { ...(ctx.env || process.env), HERMES_HOME: home };
|
|
177
|
-
if (!readVersion(env, ctx._spawnSyncImpl)) {
|
|
178
|
-
throw new Error('Hermes Agent 0.22.2 is not on PATH');
|
|
179
|
-
}
|
|
180
|
-
if (!env.OPENROUTER_API_KEY) {
|
|
181
|
-
throw new Error('Supply the operator OPENROUTER_API_KEY through Secret Manager before running Hermes');
|
|
182
|
-
}
|
|
183
|
-
const args = ['chat', '--oneshot', '--query-file', '-', '--format', 'stream-json',
|
|
184
|
-
'--provider', 'openrouter', '--source', 'tool', '--no-restore-cwd'];
|
|
185
|
-
if (seatModel) args.push('--model', seatModel);
|
|
186
|
-
if (ctx.sessionId) args.push('--resume', ctx.sessionId);
|
|
187
|
-
const fullPrompt = buildMemoryPreamble(prompt, ctx.memoryLongTerm, {
|
|
188
|
-
freshSession: !ctx.sessionId,
|
|
189
|
-
});
|
|
190
|
-
const requestedTimeout = Number(ctx.timeoutMs);
|
|
191
|
-
const timeoutMs = Number.isFinite(requestedTimeout) && requestedTimeout > 0
|
|
192
|
-
? Math.min(requestedTimeout, DEFAULT_TIMEOUT_MS) : DEFAULT_TIMEOUT_MS;
|
|
193
|
-
return runHermes({
|
|
194
|
-
args, prompt: fullPrompt, cwd: ctx.cwd, env, timeoutMs,
|
|
195
|
-
spawnImpl: ctx._spawnImpl, onSpawn: ctx.onSpawn, controls: controlsFromArguments(args, { modelFlag: '--model', valueFlags: ['--query-file', '--format', '--provider', '--source', '--resume'] }),
|
|
196
|
-
credentials: spawnCredentials(ctx, ['OPENROUTER_API_KEY']),
|
|
197
|
-
});
|
|
198
|
-
},
|
|
199
|
-
};
|
|
@@ -1,15 +1,8 @@
|
|
|
1
|
+
import { requireSeatModel as requireDeclaredModel } from '@commonlyai/seats/seat-model';
|
|
2
|
+
|
|
1
3
|
/** Fleet runners opt in; BYO users retain their own CLI's model default. */
|
|
2
4
|
export const seatModelRequired = () => process.env.COMMONLY_REQUIRE_SEAT_MODEL === '1';
|
|
3
5
|
|
|
4
|
-
export const requireSeatModel = (environment, adapterName, required = seatModelRequired()) =>
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
if (!required) return undefined;
|
|
8
|
-
const error = new Error(
|
|
9
|
-
`${adapterName} requires environment.model; set the seat model with commonly agent config <name> --model <model> before running`,
|
|
10
|
-
);
|
|
11
|
-
error.code = 'seat_model_missing';
|
|
12
|
-
throw error;
|
|
13
|
-
}
|
|
14
|
-
return model.trim();
|
|
15
|
-
};
|
|
6
|
+
export const requireSeatModel = (environment, adapterName, required = seatModelRequired()) => (
|
|
7
|
+
requireDeclaredModel(environment, adapterName, required)
|
|
8
|
+
);
|
package/src/lib/adapters/stub.js
CHANGED
|
@@ -1,24 +1 @@
|
|
|
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
|
-
};
|
|
1
|
+
export { default } from '@commonlyai/seats/adapters/stub';
|
|
@@ -1,30 +1,6 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
3
|
-
|
|
4
|
-
export const controlsFromArguments = (args, { modelFlag, effortFlag, valueFlags = [], codex = false }) => {
|
|
5
|
-
const controls = { model: null, effort: null };
|
|
6
|
-
const flags = new Set([modelFlag, effortFlag, ...valueFlags].filter(Boolean));
|
|
7
|
-
for (let index = 0; index < args.length; index += 1) {
|
|
8
|
-
const flag = args[index];
|
|
9
|
-
if (!flags.has(flag)) continue;
|
|
10
|
-
const value = args[++index];
|
|
11
|
-
if (flag === modelFlag) controls.model = value || null;
|
|
12
|
-
if (flag === effortFlag) controls.effort = value || null;
|
|
13
|
-
if (codex && flag === '-c' && value?.startsWith('model_reasoning_effort=')) {
|
|
14
|
-
try { controls.effort = JSON.parse(value.slice('model_reasoning_effort='.length)); } catch { /* unknown encoding */ }
|
|
15
|
-
}
|
|
16
|
-
}
|
|
17
|
-
return controls;
|
|
18
|
-
};
|
|
19
|
-
|
|
20
|
-
// Called at the child's successful OS spawn event, not when a requested
|
|
21
|
-
// configuration is saved or when a turn is merely queued. Never send argv.
|
|
22
|
-
export const observeSpawn = (child, onSpawn, controls) => {
|
|
23
|
-
if (typeof onSpawn !== 'function') return;
|
|
24
|
-
child.once('spawn', () => {
|
|
25
|
-
try { onSpawn({ model: controls.model || null, effort: controls.effort || null, at: new Date().toISOString() }); } catch { /* diagnostic only */ }
|
|
26
|
-
});
|
|
27
|
-
};
|
|
1
|
+
// controlsFromArguments and observeSpawn live in the seats runtime package;
|
|
2
|
+
// the reporter below posts to a Commonly instance, so it stays in the CLI.
|
|
3
|
+
export { controlsFromArguments, observeSpawn } from '@commonlyai/seats/runtime-observation';
|
|
28
4
|
|
|
29
5
|
export const createRuntimeReporter = ({ client, harness, detect }) => {
|
|
30
6
|
let queue = Promise.resolve();
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How `agent run` stops on a signal while supervised process groups may be
|
|
3
|
+
* live (see @commonlyai/seats/process-group).
|
|
4
|
+
*
|
|
5
|
+
* SIGTERM keeps the in-flight turn: the run loop stops polling and lets the
|
|
6
|
+
* turn finish. A launchd seat is SIGKILLed ExitTimeOut seconds after SIGTERM,
|
|
7
|
+
* and that SIGKILL cannot be caught, so a supervised group (outside the seat's
|
|
8
|
+
* own group, which launchd kills) would outlive the seat. So on a launchd
|
|
9
|
+
* seat, SIGTERM also arms a deadline a margin before ExitTimeOut, and the exit
|
|
10
|
+
* path kills whatever groups are left when the turn ends first.
|
|
11
|
+
*
|
|
12
|
+
* The deadline is armed only when the seat's plist exists. A seat without one
|
|
13
|
+
* (the daemon spawns it) is stopped with SIGTERM and never SIGKILLed, so it
|
|
14
|
+
* relies on the exit path alone, and a reassignment or config change does not
|
|
15
|
+
* cut the in-flight turn.
|
|
16
|
+
*
|
|
17
|
+
* ExitTimeOut is read from the seat's plist and never written. In a plist that
|
|
18
|
+
* exists, an unset, zero or unreadable value is launchd's default of 20 s,
|
|
19
|
+
* because launchd will SIGKILL that seat.
|
|
20
|
+
*/
|
|
21
|
+
import { spawnSync } from 'child_process';
|
|
22
|
+
import { existsSync, readFileSync } from 'fs';
|
|
23
|
+
import { homedir } from 'os';
|
|
24
|
+
import { join } from 'path';
|
|
25
|
+
import { armSupervisedGroupDeadline, killSupervisedGroups } from '@commonlyai/seats/process-group';
|
|
26
|
+
|
|
27
|
+
export const DEFAULT_EXIT_TIMEOUT_S = 20;
|
|
28
|
+
const MAX_MARGIN_MS = 5000;
|
|
29
|
+
const SEAT_NAME = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
30
|
+
|
|
31
|
+
/** The seat's launchd plist path, or null for a name that cannot be a file name. */
|
|
32
|
+
export const seatPlistPath = (name, home = homedir()) => (typeof name === 'string' && SEAT_NAME.test(name)
|
|
33
|
+
? join(home, 'Library', 'LaunchAgents', `me.commonly.seat-${name}.plist`)
|
|
34
|
+
: null);
|
|
35
|
+
|
|
36
|
+
const positive = (value) => (Number.isFinite(value) && value > 0 ? value : null);
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* ExitTimeOut in seconds, or null when the plist is missing, has none, or
|
|
40
|
+
* holds a value launchd would not use as a timeout. An XML plist is read
|
|
41
|
+
* directly; a binary one through `plutil -extract` (read-only).
|
|
42
|
+
*/
|
|
43
|
+
export const readExitTimeOut = (path, { readFile = readFileSync, run = spawnSync } = {}) => {
|
|
44
|
+
if (!path) return null;
|
|
45
|
+
let raw;
|
|
46
|
+
try { raw = readFile(path); } catch { return null; }
|
|
47
|
+
const text = Buffer.isBuffer(raw) ? raw.toString('utf8') : String(raw);
|
|
48
|
+
if (text.startsWith('bplist')) {
|
|
49
|
+
try {
|
|
50
|
+
const out = run('plutil', ['-extract', 'ExitTimeOut', 'raw', '-o', '-', path], { encoding: 'utf8', timeout: 5000 });
|
|
51
|
+
return out.status === 0 ? positive(Number(String(out.stdout).trim())) : null;
|
|
52
|
+
} catch { return null; }
|
|
53
|
+
}
|
|
54
|
+
const match = /<key>\s*ExitTimeOut\s*<\/key>\s*<(integer|real)>\s*([0-9.]+)\s*<\/\1>/.exec(text);
|
|
55
|
+
return match ? positive(Number(match[2])) : null;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Whether the seat's plist exists, and its ExitTimeOut (null when unset or
|
|
60
|
+
* unreadable). Only an existing plist means launchd will SIGKILL this seat.
|
|
61
|
+
*/
|
|
62
|
+
export const readSeatPlist = (path, { exists = existsSync, ...deps } = {}) => {
|
|
63
|
+
if (!path || !exists(path)) return { present: false, exitTimeOut: null };
|
|
64
|
+
return { present: true, exitTimeOut: readExitTimeOut(path, deps) };
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/** Milliseconds after SIGTERM to kill the groups: ExitTimeOut less a margin of 5 s or a quarter, whichever is less. */
|
|
68
|
+
export const groupKillDeadlineMs = (exitTimeOutS) => {
|
|
69
|
+
const totalMs = (positive(exitTimeOutS) ?? DEFAULT_EXIT_TIMEOUT_S) * 1000;
|
|
70
|
+
return Math.floor(totalMs - Math.min(MAX_MARGIN_MS, totalMs / 4));
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* `requestStop(signal)` stops the run loop and, on SIGTERM to a seat with a
|
|
75
|
+
* plist, arms the group deadline; `onExit` kills the groups left at exit.
|
|
76
|
+
* `deps` is a test seam.
|
|
77
|
+
*/
|
|
78
|
+
export const createSeatStop = ({ name, stop, home, deps = {} } = {}) => {
|
|
79
|
+
const {
|
|
80
|
+
arm = armSupervisedGroupDeadline,
|
|
81
|
+
kill = killSupervisedGroups,
|
|
82
|
+
readPlist = readSeatPlist,
|
|
83
|
+
} = deps;
|
|
84
|
+
return {
|
|
85
|
+
requestStop(signal) {
|
|
86
|
+
stop();
|
|
87
|
+
if (signal !== 'SIGTERM') return null;
|
|
88
|
+
const plist = readPlist(seatPlistPath(name, home));
|
|
89
|
+
if (!plist.present) return null;
|
|
90
|
+
const deadlineMs = groupKillDeadlineMs(plist.exitTimeOut);
|
|
91
|
+
arm(deadlineMs);
|
|
92
|
+
return deadlineMs;
|
|
93
|
+
},
|
|
94
|
+
onExit() { kill(); },
|
|
95
|
+
};
|
|
96
|
+
};
|