Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
9d3e5ec
refactor(cli): move the adapter contract, stub and Hermes into packag…
lilyshen0722 Oct 25, 2026
1a047cf
feat(seats): trace events, a trace store, named failures and a fake p…
lilyshen0722 Oct 25, 2026
1c60757
feat(seats): per-run grants for credentials and tool permissions
lilyshen0722 Oct 25, 2026
099db50
feat(seats): run/grant record format v1, hash-chained and exportable …
lilyshen0722 Oct 25, 2026
b7b71f6
feat(seats): record v1 adds the ask, card rulings and an args digest
lilyshen0722 Oct 25, 2026
1515448
feat(seats): record v1 actor carries onBehalfOf, the human an agent a…
lilyshen0722 Oct 25, 2026
41c3e99
test(seats): args digest vectors for writers in other languages
lilyshen0722 Oct 25, 2026
1e82819
feat(seats): no args digest on a lone surrogate; the tool span carrie…
lilyshen0722 Oct 25, 2026
50437c9
feat(seats): record egress.allowed and egress.denied; vectors gate ot…
lilyshen0722 Oct 25, 2026
274d6d3
feat(seats): record sandbox.applied as a v1 kind
lilyshen0722 Oct 25, 2026
e3f6264
feat(seats): record session.opened, and context fill on usage
lilyshen0722 Oct 25, 2026
e1bd2cc
test(seats): plant the login home in valet-env instead of reading the…
lilyshen0722 Oct 25, 2026
79e580d
feat(seats): add pod-member to decidedByRole, valid only on decision.…
lilyshen0722 Oct 25, 2026
ccfd71c
feat(seats): a valet policy with a trace marks itself recordsPermissions
lilyshen0722 Oct 25, 2026
92d6d09
fix(seats): a decision always names who made it; an unattributed answ…
lilyshen0722 Oct 25, 2026
fb686d4
test(seats): the pod-member test names who the ask goes to, and its w…
lilyshen0722 Oct 25, 2026
710b91b
feat(seats): supervised runs forward actor and redact to the record (…
lilyshen0722 Oct 25, 2026
bbfc39a
feat(seats): record-v1 adds agent.retry, and states the hook attribut…
lilyshen0722 Oct 25, 2026
c226bca
fix(seats): a supervised kill reaches the CLI's whole process tree; a…
lilyshen0722 Oct 25, 2026
20e459e
fix(seats): superviseRun passes the run's actor, task and pod to the …
lilyshen0722 Oct 25, 2026
3c11fcb
Merge main into the record line for its landing on main
lilyshen0722 Oct 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions .github/workflows/npm-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ on:
branches: [ main ]
paths:
- 'cli/**'
- 'packages/seats/**' # bundled into the cli tarball (bundleDependencies)
- 'commonly-mcp/**'
- 'docs/agents/skills/commonly/SKILL.md' # cli prepublishOnly copies it in
workflow_dispatch:
Expand Down Expand Up @@ -112,6 +113,14 @@ jobs:
with:
node-version: '22'

# Before the compare, not just before publish: the cli bundles
# packages/seats, and `npm pack` on a tree with no node_modules exits 0
# with the bundle silently missing. The compare would then see every
# tree as different from what npm serves. cli's prepack now refuses
# that case, so a missing install fails here, by name.
- name: Install
run: npm ci --ignore-scripts

- name: Decide whether this tree holds anything unpublished
id: cmp
shell: bash
Expand Down Expand Up @@ -237,10 +246,6 @@ jobs:
done
true

- name: Install
if: steps.cmp.outputs.action == 'publish'
run: npm ci --ignore-scripts

- name: Publish
if: steps.cmp.outputs.action == 'publish'
# No NODE_AUTH_TOKEN: npm exchanges this job's OIDC id-token with the
Expand Down
5 changes: 5 additions & 0 deletions .gitleaks.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ regexes = [
# signature. generic-api-key matched it, and main's Secret Scan was red on
# every push from 2026-10-16. The exact literal only, so a real token still fails.
'''eyJhbGciOiJSUzI1NiJ9\.jwt''',
# A fake OpenRouter key in packages/seats/test/supervisor.test.js (#2701): the
# scrub test needs a value shaped like a real key so it can prove the key never
# reaches the record. generic-api-key matched it in a commit the scan reads, so
# an inline allow on today's line would not clear it. The exact literal only.
'''sk-or-v1-abcdefabcdefabcdef''',
# The namespaced cloudflared ExternalSecret has `- secretKey: credentials.json`
# (#2439) — a Secret KEY NAME (what the tunnel credentials.json is stored under), not
# a value. gitleaks' kubernetes-secret-yaml rule captures `secretKey: credentials` (it
Expand Down
67 changes: 67 additions & 0 deletions cli/__tests__/adapters.process-group.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/**
* The claude and codex adapters start their child in its own process group
* only when the run supervisor asks (ctx.ownProcessGroup), because only the
* supervisor kills the group. Live seats run unsupervised, and their spawn
* options must stay exactly what they were.
*/
import { jest } from '@jest/globals';
import { EventEmitter } from 'events';

await jest.unstable_mockModule('child_process', () => ({
spawnSync: jest.fn(() => ({ status: 1, stdout: '', stderr: '' })),
spawn: jest.fn(),
}));

const claude = (await import('../src/lib/adapters/claude.js')).default;
const codex = (await import('../src/lib/adapters/codex.js')).default;
const { LEADS_PROCESS_GROUP, OWN_PROCESS_GROUP } = await import('@commonlyai/seats/process-group');

const makeSpawnImpl = () => {
const calls = [];
const impl = (cmd, args, opts) => {
const proc = new EventEmitter();
proc.stdout = new EventEmitter();
proc.stderr = new EventEmitter();
proc.kill = jest.fn();
calls.push({ cmd, args, opts, proc });
setTimeout(() => {
proc.stdout.emit('data', Buffer.from('ok'));
proc.emit('close', 0);
}, 0);
return proc;
};
return { impl, calls };
};

const ADAPTERS = [['claude', claude], ['codex', codex]];
const POSIX = process.platform !== 'win32';

describe.each(ADAPTERS)('%s adapter — process group', (name, adapter) => {
test('unsupervised: spawns with exactly cwd, env and stdio, no detached, no marker', async () => {
const { impl, calls } = makeSpawnImpl();
await adapter.spawn('hi', { environment: { model: 'test-model' }, sessionId: null, _spawnImpl: impl });
expect(calls).toHaveLength(1);
expect(Object.keys(calls[0].opts).sort()).toEqual(['cwd', 'env', 'stdio']);
expect(calls[0].opts.stdio).toEqual(['ignore', 'pipe', 'pipe']);
expect(calls[0].proc[LEADS_PROCESS_GROUP]).toBeUndefined();
});

(POSIX ? test : test.skip)('supervised: spawns detached and marks the child as leading its group', async () => {
const { impl, calls } = makeSpawnImpl();
await adapter.spawn('hi', {
environment: { model: 'test-model' }, sessionId: null, _spawnImpl: impl, [OWN_PROCESS_GROUP]: true,
});
expect(calls).toHaveLength(1);
expect(calls[0].opts.detached).toBe(true);
expect(Object.keys(calls[0].opts).sort()).toEqual(['cwd', 'detached', 'env', 'stdio']);
expect(calls[0].proc[LEADS_PROCESS_GROUP]).toBe(true);
});

test('a truthy non-true value does not opt in', async () => {
const { impl, calls } = makeSpawnImpl();
await adapter.spawn('hi', {
environment: { model: 'test-model' }, sessionId: null, _spawnImpl: impl, [OWN_PROCESS_GROUP]: 'yes',
});
expect(calls[0].opts).not.toHaveProperty('detached');
});
});
136 changes: 136 additions & 0 deletions cli/__tests__/seat-stop.process.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
/**
* seat-stop with real processes, wired the way `agent run` wires it: SIGTERM
* calls requestStop, and the exit path calls onExit. The supervised child
* leads its own group and holds a grandchild (`sleep 300 &`), the shape of a
* CLI holding an MCP server.
*
* - A launchd seat (its plist exists): the groups die a margin before
* ExitTimeOut, while the turn is still running.
* - A daemon-spawned seat (no plist): nothing is armed, the turn runs past the
* 15 s a default deadline would have cut it at, and the groups die on exit.
*/
import { spawn } from 'child_process';
import { once } from 'events';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import { dirname, join } from 'path';
import { fileURLToPath, pathToFileURL } from 'url';

const HERE = dirname(fileURLToPath(import.meta.url));
const SEAT_STOP = pathToFileURL(join(HERE, '..', 'src', 'lib', 'seat-stop.js')).href;
const SEATS = join(HERE, '..', '..', 'packages', 'seats', 'src');
const seats = (file) => JSON.stringify(pathToFileURL(join(SEATS, file)).href);
const POSIX = process.platform !== 'win32';

const isRunning = (pid) => {
try { process.kill(pid, 0); } catch (error) { return error.code !== 'ESRCH'; }
try { return !/^\d+ \(.*\) Z/.test(readFileSync(`/proc/${pid}/stat`, 'utf8')); } catch { return true; }
};
const sleep = (ms) => new Promise((r) => { setTimeout(r, ms); });
const waitFor = async (predicate, ms = 5000) => {
const deadline = Date.now() + ms;
while (Date.now() < deadline) {
if (predicate()) return true;
await sleep(25);
}
return predicate();
};
const reap = (pid) => { try { process.kill(pid, 'SIGKILL'); } catch { /* gone */ } };

const HOST = ({ home, pidFile, goFile }) => `
import { spawn } from 'node:child_process';
import { existsSync, readFileSync } from 'node:fs';
import { createSeatStop } from ${JSON.stringify(SEAT_STOP)};
import { superviseRun } from ${seats('supervisor.js')};
import { createTraceStore } from ${seats('trace/store.js')};
import { observeSpawn } from ${seats('runtime-observation.js')};
import { spawnInOwnGroup, wantsOwnProcessGroup } from ${seats('process-group.js')};
let stopping = false;
const seatStop = createSeatStop({ name: 'probe', stop: () => { stopping = true; }, home: ${JSON.stringify(home)} });
process.on('SIGTERM', (signal) => { process.stdout.write('stopping deadline=' + seatStop.requestStop(signal) + '\\n'); });
process.once('exit', seatStop.onExit);
const script = "sleep 300 & echo $! > '${pidFile}'; while [ ! -f '${goFile}' ]; do sleep 0.05; done; echo done; wait";
const adapter = { name: 'sh', spawn: (prompt, ctx) => new Promise((resolve, reject) => {
const child = spawnInOwnGroup(spawn, 'sh', ['-c', script], { stdio: ['ignore', 'pipe', 'pipe'] }, { ownGroup: wantsOwnProcessGroup(ctx) });
observeSpawn(child, ctx.onSpawn, {});
child.stdout.on('data', (chunk) => { if (String(chunk).includes('done')) resolve({ text: 'done' }); });
child.on('close', (code, signal) => reject(new Error('sh ended ' + (code ?? signal))));
child.once('spawn', () => {
const poll = setInterval(() => {
if (!existsSync('${pidFile}') || !/^\\d+\\s*$/.test(readFileSync('${pidFile}', 'utf8'))) return;
clearInterval(poll);
process.stdout.write('ready ' + child.pid + '\\n');
}, 20);
});
}) };
const out = await superviseRun({ adapter, prompt: 'p', ctx: {}, store: createTraceStore({ root: ${JSON.stringify(join(home, 'trace'))} }),
policy: { stallMs: 0, timeoutMs: 0 } });
process.stdout.write('turn ' + out.outcome + '\\n');
if (stopping) process.exit(0);
`;

let home;
let state;
beforeEach(() => {
home = mkdtempSync(join(tmpdir(), 'seat-stop-proc-'));
state = null;
});
afterEach(() => {
if (state) {
if (state.grandchild) reap(state.grandchild);
if (state.leader) reap(state.leader);
state.host.kill('SIGKILL');
}
rmSync(home, { recursive: true, force: true });
});

const startHost = async () => {
const pidFile = join(home, 'grandchild.pid');
const goFile = join(home, 'go');
const script = join(home, 'host.mjs');
writeFileSync(script, HOST({ home, pidFile, goFile }));
const host = spawn(process.execPath, [script], { stdio: ['ignore', 'pipe', 'inherit'] });
state = { host, goFile, out: '' };
host.stdout.on('data', (chunk) => { state.out += chunk; });
state.exited = once(host, 'exit');
expect(await waitFor(() => /ready \d+/.test(state.out))).toBe(true);
state.leader = Number(state.out.match(/ready (\d+)/)[1]);
expect(await waitFor(() => existsSync(pidFile) && /^\d+\s*$/.test(readFileSync(pidFile, 'utf8')))).toBe(true);
state.grandchild = Number(readFileSync(pidFile, 'utf8'));
return state;
};

const describePosix = POSIX ? describe : describe.skip;

describePosix('seat-stop — real processes', () => {
test('a seat with a plist: the groups die a margin before ExitTimeOut, mid-turn', async () => {
// ExitTimeOut 2 s → deadline 1.5 s.
mkdirSync(join(home, 'Library', 'LaunchAgents'), { recursive: true });
writeFileSync(join(home, 'Library', 'LaunchAgents', 'me.commonly.seat-probe.plist'),
'<?xml version="1.0"?><plist version="1.0"><dict><key>ExitTimeOut</key><integer>2</integer></dict></plist>');
await startHost();
const sent = Date.now();
state.host.kill('SIGTERM');
expect(await waitFor(() => /stopping deadline=1500/.test(state.out))).toBe(true);
await sleep(750);
expect(isRunning(state.grandchild)).toBe(true);
expect(await waitFor(() => !isRunning(state.grandchild), 2000)).toBe(true);
expect(Date.now() - sent).toBeLessThan(2000);
expect(existsSync(state.goFile)).toBe(false);
}, 30000);

test('a seat with no plist: nothing is armed, the turn runs past 15 s, and the groups die on exit', async () => {
await startHost();
state.host.kill('SIGTERM');
expect(await waitFor(() => /stopping deadline=null/.test(state.out))).toBe(true);
await sleep(16000);
expect(isRunning(state.grandchild)).toBe(true);
expect(isRunning(state.leader)).toBe(true);
writeFileSync(state.goFile, '');
const [code] = await Promise.race([state.exited, sleep(5000).then(() => ['timeout'])]);
expect(state.out).toMatch(/turn ok/);
expect(code).toBe(0);
expect(await waitFor(() => !isRunning(state.grandchild))).toBe(true);
expect(await waitFor(() => !isRunning(state.leader))).toBe(true);
}, 40000);
});
128 changes: 128 additions & 0 deletions cli/__tests__/seat-stop.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
/**
* seat-stop: SIGTERM keeps the turn, and arms a supervised-group kill a margin
* before the seat plist's ExitTimeOut (launchd's SIGKILL). The plist is only
* read. The process-level behaviour is covered with real processes in
* packages/seats/test/supervisor-tree-and-deadline.test.js.
*/
import { jest } from '@jest/globals';
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs';
import { tmpdir } from 'os';
import { join } from 'path';
import {
DEFAULT_EXIT_TIMEOUT_S, createSeatStop, groupKillDeadlineMs, readExitTimeOut, readSeatPlist, seatPlistPath,
} from '../src/lib/seat-stop.js';

const plist = (body) => `<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>me.commonly.seat-anvil</string>
<key>KeepAlive</key>
<true/>
${body}
</dict>
</plist>
`;

let home;
beforeEach(() => {
home = mkdtempSync(join(tmpdir(), 'seat-stop-'));
mkdirSync(join(home, 'Library', 'LaunchAgents'), { recursive: true });
});
afterEach(() => { rmSync(home, { recursive: true, force: true }); });

const writePlist = (name, body) => {
const path = seatPlistPath(name, home);
writeFileSync(path, plist(body));
return path;
};

describe('seat-stop — ExitTimeOut', () => {
test('the plist path is the seat launch agent', () => {
expect(seatPlistPath('anvil', home)).toBe(join(home, 'Library', 'LaunchAgents', 'me.commonly.seat-anvil.plist'));
expect(seatPlistPath('../x', home)).toBeNull();
expect(seatPlistPath('a/b', home)).toBeNull();
});

test('reads ExitTimeOut from an XML plist, and only reads it', () => {
const path = writePlist('anvil', ' <key>ExitTimeOut</key>\n <integer>40</integer>');
const before = readFileSync(path, 'utf8');
expect(readExitTimeOut(path)).toBe(40);
expect(readFileSync(path, 'utf8')).toBe(before);
});

test('unset, zero, missing file and no path all read as unset', () => {
expect(readExitTimeOut(writePlist('a', ''))).toBeNull();
expect(readExitTimeOut(writePlist('b', ' <key>ExitTimeOut</key>\n <integer>0</integer>'))).toBeNull();
expect(readExitTimeOut(join(home, 'nope.plist'))).toBeNull();
expect(readExitTimeOut(null)).toBeNull();
});

test('a binary plist is read through plutil -extract', () => {
const run = jest.fn(() => ({ status: 0, stdout: '30\n' }));
const value = readExitTimeOut('/x.plist', { readFile: () => Buffer.from('bplist00...'), run });
expect(value).toBe(30);
expect(run).toHaveBeenCalledWith('plutil', ['-extract', 'ExitTimeOut', 'raw', '-o', '-', '/x.plist'], expect.any(Object));
});

test('the deadline is ExitTimeOut less min(5 s, a quarter); unset means 20 s', () => {
expect(DEFAULT_EXIT_TIMEOUT_S).toBe(20);
expect(groupKillDeadlineMs(null)).toBe(15000);
expect(groupKillDeadlineMs(40)).toBe(35000);
expect(groupKillDeadlineMs(4)).toBe(3000);
for (const s of [1, 4, 20, 40, 120]) expect(groupKillDeadlineMs(s)).toBeLessThan(s * 1000);
});
});

describe('seat-stop — requestStop', () => {
const make = (name = 'anvil') => {
const stop = jest.fn();
const arm = jest.fn();
const kill = jest.fn();
return { stop, arm, kill, seatStop: createSeatStop({ name, stop, home, deps: { arm, kill } }) };
};

test('SIGTERM stops the loop and arms the group kill before ExitTimeOut', () => {
writePlist('anvil', ' <key>ExitTimeOut</key>\n <integer>8</integer>');
const { stop, arm, kill, seatStop } = make();
expect(seatStop.requestStop('SIGTERM')).toBe(6000);
expect(stop).toHaveBeenCalledTimes(1);
expect(arm).toHaveBeenCalledWith(6000);
expect(kill).not.toHaveBeenCalled();
});

test('SIGTERM on a seat with no plist (daemon-spawned) arms nothing: the exit path alone kills', () => {
const { stop, arm, seatStop } = make('daemon-seat');
expect(seatStop.requestStop('SIGTERM')).toBeNull();
expect(stop).toHaveBeenCalledTimes(1);
expect(arm).not.toHaveBeenCalled();
});

test('SIGTERM on a seat whose plist has no ExitTimeOut uses launchd\'s 20 s default', () => {
writePlist('anvil', '');
const { arm, seatStop } = make();
expect(seatStop.requestStop('SIGTERM')).toBe(15000);
expect(arm).toHaveBeenCalledWith(15000);
});

test('a plist that exists but cannot be read still counts as present, at the default', () => {
expect(readSeatPlist('/p.plist', { exists: () => true, readFile: () => { throw new Error('EACCES'); } }))
.toEqual({ present: true, exitTimeOut: null });
expect(readSeatPlist(join(home, 'nope.plist'))).toEqual({ present: false, exitTimeOut: null });
expect(readSeatPlist(null)).toEqual({ present: false, exitTimeOut: null });
});

test('SIGINT stops the loop and arms nothing', () => {
const { stop, arm, seatStop } = make();
expect(seatStop.requestStop('SIGINT')).toBeNull();
expect(stop).toHaveBeenCalledTimes(1);
expect(arm).not.toHaveBeenCalled();
});

test('the exit path kills the supervised groups', () => {
const { kill, seatStop } = make();
seatStop.onExit();
expect(kill).toHaveBeenCalledTimes(1);
});
});
Loading
Loading