The panel's revision is the bus's sha256 of the organization, so a whole-number --revision could never match a real Console. The fake in cli-parity now answers hex revisions, and a whole number is a usage error.
288 lines
18 KiB
JavaScript
288 lines
18 KiB
JavaScript
// agent-guard (#1873, D2 security re-check P1): the bus and Console lifecycle verbs refuse inside an
|
|
// agent run with refuseInsideAgent's refusal (exit 3), before any read. The marker is found in the
|
|
// command's environment and in this process's own one, so a sanitized io.env does not hide it.
|
|
// Status verbs stay open to agents. A person keeps every verb in a human shell, and the Console's
|
|
// Restart bus and Restart Console keep working: the Console starts a bus host with its own
|
|
// environment, which carries no marker.
|
|
import { test } from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { spawn, spawnSync } from 'node:child_process';
|
|
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
|
import { request } from 'node:http';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { reserveOrg, activateOrg } from '../../organization/src/org-registry.mjs';
|
|
import { businessDoc, rolesCopy, writeJson } from '../../organization/tests/helpers.mjs';
|
|
import { humanEnv, notifyConfig } from './helpers.mjs';
|
|
import { run } from '../src/cli.mjs';
|
|
import { busStart, busStatus, busStop } from '../src/org-bus.mjs';
|
|
import { AGENT_MARKERS } from '../src/transport.mjs';
|
|
import { createOrg, templateRender } from '../../organization/src/org-service.mjs';
|
|
import { consoleStart, consoleStop } from '../src/org-console.mjs';
|
|
|
|
const REPO = new URL('../../../', import.meta.url).pathname;
|
|
const MOSAIC = join(REPO, 'scripts/mosaic');
|
|
const DEV = { provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner' };
|
|
const FORBIDDEN = [7332, 7340, 7341, 33223, 17450, 17443, 44307, 7350];
|
|
const REFUSED = marker => new RegExp(`^mosaic: refused: this runs only from a human shell, outside any agent run \\(found ${marker} in the environment\\)\\n$`);
|
|
|
|
// Every guarded verb, as a person types it.
|
|
const GUARDED = [
|
|
['bus', 'start', 'alpha', '--no-launch', '--detach'],
|
|
['bus', 'start', 'alpha', '--internal-host'],
|
|
['bus', 'stop', 'alpha'],
|
|
['bus', 'restart', 'alpha'],
|
|
['bus', 'recover', 'alpha'],
|
|
['console', 'start', '--detach', '--port', 'auto'],
|
|
['console', 'start', '--port', 'auto'],
|
|
['console', 'stop'],
|
|
['console', 'restart'],
|
|
// archive and delete stop the organization's bus (review finding 2).
|
|
['org', 'archive', 'alpha', '--if-revision', '1'],
|
|
['org', 'delete', 'alpha', '--confirm', 'alpha'],
|
|
// E1 (P1-4): restore starts the organization's bus unless --no-bus is given.
|
|
['org', 'restore', 'alpha', '--if-revision', '1'],
|
|
// E2 (#1873, P1-3): the hire form and the autonomy panel from a terminal, reads included.
|
|
['org', 'role-templates', 'alpha'],
|
|
['org', 'hire', 'alpha', '--name', 'ada', '--template', 'worker', '--harness', 'pi', '--model', 'glm', '--reason', 'r'],
|
|
['org', 'change', 'alpha', '--name', 'ada', '--template', 'worker', '--harness', 'pi', '--model', 'glm', '--reason', 'r'],
|
|
['org', 'fire', 'alpha', '--name', 'ada', '--reason', 'r'],
|
|
['org', 'autonomy', 'alpha'],
|
|
['org', 'autonomy', 'alpha', 'set', '--revision', 'a'.repeat(64), '--merge', 'near'],
|
|
['org', 'autonomy', 'alpha', 'review', 'merge'],
|
|
];
|
|
|
|
// The CLI in this process, with the given io.env; stdout and stderr are collected.
|
|
async function inProcess(argv, env) {
|
|
let stdout = '', stderr = '';
|
|
const code = await run(argv, { env, stdin: null, stdout: { write: s => { stdout += s; } }, stderr: { write: s => { stderr += s; } } });
|
|
return { code, stdout, stderr };
|
|
}
|
|
// A marker in this process's own environment for the length of fn.
|
|
async function withOwnMarker(name, fn) {
|
|
const prior = process.env[name];
|
|
process.env[name] = 'agent-guard';
|
|
try { return await fn(); }
|
|
finally { if (prior === undefined) delete process.env[name]; else process.env[name] = prior; }
|
|
}
|
|
// The CLI as a child, without blocking this process (an in-process Console must keep answering).
|
|
function mosaic(env, args, timeout = 60000) {
|
|
return new Promise((resolve, reject) => {
|
|
const child = spawn(MOSAIC, args, { env, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
let stdout = '', stderr = '';
|
|
child.stdout.setEncoding('utf8').on('data', c => { stdout += c; });
|
|
child.stderr.setEncoding('utf8').on('data', c => { stderr += c; });
|
|
const timer = setTimeout(() => child.kill('SIGKILL'), timeout);
|
|
child.on('error', reject);
|
|
child.on('close', status => { clearTimeout(timer); resolve({ status, stdout, stderr }); });
|
|
});
|
|
}
|
|
// A request the way the page sends it to its own Console.
|
|
function post(port, path) {
|
|
return new Promise((resolve, reject) => {
|
|
const host = `127.0.0.1:${port}`;
|
|
const req = request({ host: '127.0.0.1', port, path, method: 'POST', headers: { host, origin: `http://${host}`, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json', 'content-length': 2 } }, res => {
|
|
let text = ''; res.setEncoding('utf8'); res.on('data', c => { text += c; });
|
|
res.on('end', () => { let body = null; try { body = JSON.parse(text); } catch { /* not JSON */ } resolve({ status: res.statusCode, body }); });
|
|
});
|
|
req.on('error', reject); req.end('{}');
|
|
});
|
|
}
|
|
|
|
// A scratch instance made by bootstrap.sh, with org alpha active and its organization file.
|
|
async function instance(t) {
|
|
const home = mkdtempSync(join(tmpdir(), 'ag-'));
|
|
const env = humanEnv({ HOME: home, MOSAIC_CONFIG: join(home, '.config/mosaic-agtest/config.json') });
|
|
const setup = spawnSync(join(REPO, 'scripts/bootstrap.sh'), ['--instance', 'agtest'], { env, encoding: 'utf8' });
|
|
assert.equal(setup.status, 0, setup.stderr);
|
|
env.MOSAIC_ROLES_DIR = rolesCopy(home);
|
|
const ctx = { configDir: join(home, '.config/mosaic-agtest'), dataRoot: join(home, '.mosaic-agtest'), instance: 'agtest', env };
|
|
const org = await reserveOrg(ctx, 'alpha');
|
|
await activateOrg(ctx, 'alpha');
|
|
writeJson(join(ctx.configDir, 'businesses', 'alpha.json'), businessDoc(home, 'alpha'));
|
|
notifyConfig(org.root, 'alpha', null);
|
|
t.after(async () => {
|
|
try { await busStop(ctx, 'alpha'); } catch {}
|
|
try { await consoleStop(ctx); } catch {}
|
|
rmSync(home, { recursive: true, force: true });
|
|
});
|
|
return { ctx, env, root: org.root };
|
|
}
|
|
|
|
// One test per guarded verb. The config path is not an instance config, so any read would refuse
|
|
// with another message: each refusal below happened before the command read anything.
|
|
const NOWHERE = () => humanEnv({ MOSAIC_CONFIG: '/nonexistent/ag/config.json' });
|
|
for (const args of GUARDED) {
|
|
test(`${args.join(' ')} refuses with a marker in the environment it is given and with one only in its own process`, async () => {
|
|
// A marker in the command's environment (a child started from an agent run).
|
|
const child = await mosaic({ ...NOWHERE(), MOSAIC_AGENT_RUN: '1' }, args, 30000);
|
|
assert.equal(child.status, 3, child.stderr);
|
|
assert.equal(child.stdout, '');
|
|
assert.match(child.stderr, REFUSED('MOSAIC_AGENT_RUN'));
|
|
// A sanitized io.env does not hide a marker in the process that runs the verb.
|
|
const own = await withOwnMarker('MOSAIC_RUN_ID', () => inProcess(args, NOWHERE()));
|
|
assert.equal(own.code, 3, own.stderr);
|
|
assert.equal(own.stdout, '');
|
|
assert.match(own.stderr, REFUSED('MOSAIC_RUN_ID'));
|
|
});
|
|
}
|
|
|
|
// The library start a supervisor runs checks this process too, whatever environment it is given.
|
|
test('consoleStart refuses a marker in its own process whatever environment it is given', async () => {
|
|
await withOwnMarker('CODEX_THREAD_ID', async () => {
|
|
const ctx = { configDir: '/nonexistent/ag', dataRoot: '/nonexistent/ag-data', instance: 'ag', env: NOWHERE() };
|
|
for (const devLocalOwner of [false, true]) {
|
|
await assert.rejects(consoleStart(ctx, { devLocalOwner, detach: true, port: 'auto', warn: () => {} }), e => e.exitCode === 3 && /found CODEX_THREAD_ID/.test(e.message));
|
|
}
|
|
});
|
|
});
|
|
|
|
test('usage errors still come first, so an agent sees the usage line, not the refusal', async () => {
|
|
const env = { ...NOWHERE(), MOSAIC_AGENT_RUN: '1' };
|
|
for (const args of [['bus', 'stop'], ['bus', 'start'], ['bus', 'start', 'alpha', '--pm', '--no-launch'], ['console', 'stop', 'extra'], ['console', 'restart', 'extra'], ['console', 'start', '--dev-insecure-bind', '0.0.0.0'], ['console', 'start', '--dev-allow-host', 'x'], ['console', 'start', 'extra'], ['org', 'archive', 'alpha'], ['org', 'restore', 'alpha'], ['org', 'restore', 'alpha', '--if-revision', '1', '--no-bus', '--no-bus'], ['org', 'delete', 'alpha'], ['org', 'delete', 'alpha', '--confirm', 'alpha', '--if-revision', 'x'], ['org', 'hire', 'alpha', '--name', 'ada'], ['org', 'fire', 'alpha'], ['org', 'autonomy', 'alpha', 'set', '--merge', 'near'], ['org', 'autonomy', 'alpha', 'review'], ['org', 'role-templates']]) {
|
|
const r = await mosaic(env, args, 30000);
|
|
assert.equal(r.status, 4, `${args.join(' ')}: ${r.stderr}`);
|
|
}
|
|
});
|
|
|
|
test('bus status and console status stay open to an agent run', { timeout: 120000 }, async t => {
|
|
const { ctx, env } = await instance(t);
|
|
const started = await mosaic(env, ['bus', 'start', 'alpha', '--no-launch', '--detach']);
|
|
assert.equal(started.status, 0, started.stderr);
|
|
const agent = { ...env, CLAUDECODE: '1', MOSAIC_AGENT_RUN: '1' };
|
|
const bus = await mosaic(agent, ['bus', 'status', 'alpha', '--json']);
|
|
assert.equal(bus.status, 0, bus.stderr);
|
|
assert.equal(JSON.parse(bus.stdout).healthy, true);
|
|
const console = await mosaic(agent, ['console', 'status']);
|
|
assert.equal(console.status, 0, console.stderr);
|
|
assert.deepEqual(JSON.parse(console.stdout), { running: false });
|
|
await withOwnMarker('PI_AGENT_DIR', async () => {
|
|
const own = await inProcess(['bus', 'status', 'alpha'], env);
|
|
assert.equal(own.code, 0, own.stderr);
|
|
assert.equal(JSON.parse(own.stdout).healthy, true);
|
|
assert.equal((await inProcess(['console', 'status'], env)).code, 0);
|
|
});
|
|
// The refused verbs changed nothing: the bus still runs on the same host.
|
|
const pid = JSON.parse(started.stdout).pid;
|
|
for (const args of [['bus', 'stop', 'alpha'], ['bus', 'restart', 'alpha']]) {
|
|
const r = await mosaic(agent, args);
|
|
assert.equal(r.status, 3, `${args.join(' ')}: ${r.stderr}`);
|
|
}
|
|
const after = await busStatus(ctx, 'alpha');
|
|
assert.equal(after.healthy, true); assert.equal(after.pid, pid);
|
|
});
|
|
|
|
test('from a human shell every lifecycle verb works as before', { timeout: 180000 }, async t => {
|
|
const { ctx, env, root } = await instance(t);
|
|
// bus start runs its host as `bus start alpha --internal-host` with this environment.
|
|
const start = await mosaic(env, ['bus', 'start', 'alpha', '--no-launch', '--detach']);
|
|
assert.equal(start.status, 0, start.stderr);
|
|
const first = JSON.parse(start.stdout);
|
|
const restart = await mosaic(env, ['bus', 'restart', 'alpha']);
|
|
assert.equal(restart.status, 0, restart.stderr);
|
|
assert.notEqual(JSON.parse(restart.stdout).pid, first.pid);
|
|
assert.equal((await busStatus(ctx, 'alpha')).healthy, true);
|
|
const stop = await mosaic(env, ['bus', 'stop', 'alpha']);
|
|
assert.equal(stop.status, 0, stop.stderr);
|
|
assert.equal(JSON.parse(stop.stdout).stopped, true);
|
|
assert.equal((await busStatus(ctx, 'alpha')).healthy, false);
|
|
// A dead host's state, which bus recover clears with a receipt.
|
|
const dead = { pid: 2147483647, startTime: '1', business: 'alpha', startedAt: '2026-01-01T00:00:00Z' };
|
|
for (const name of ['host.json', 'host.lock']) writeFileSync(join(root, 'bus-host', name), JSON.stringify(dead), { mode: 0o600 });
|
|
const recover = await mosaic(env, ['bus', 'recover', 'alpha']);
|
|
assert.equal(recover.status, 0, recover.stderr);
|
|
assert.equal(JSON.parse(recover.stdout).recovered, true);
|
|
assert.equal(existsSync(join(root, 'bus-host/host.json')), false);
|
|
|
|
const consoleUp = await mosaic(env, ['console', 'start', '--detach', '--port', 'auto']);
|
|
assert.equal(consoleUp.status, 0, consoleUp.stderr);
|
|
assert.ok(!FORBIDDEN.includes(Number(new URL(JSON.parse(consoleUp.stdout).url).port)));
|
|
const consoleDown = await mosaic(env, ['console', 'stop']);
|
|
assert.equal(consoleDown.status, 0, consoleDown.stderr);
|
|
assert.equal(JSON.parse(consoleDown.stdout).stopped, true);
|
|
// console restart needs a supervised Console; a foreground start supervises in this process.
|
|
const supervised = await consoleStart(ctx, { devLocalOwner: true, port: 'auto', warn: () => {} });
|
|
t.after(async () => { supervised.stop(); await supervised.done; });
|
|
const consoleRestart = await mosaic(env, ['console', 'restart']);
|
|
assert.equal(consoleRestart.status, 0, consoleRestart.stderr);
|
|
assert.equal(JSON.parse(consoleRestart.stdout).outcome, 'restarted');
|
|
});
|
|
|
|
// The Console's Restart bus runs org-bus.mjs in the Console process. Its new bus host is
|
|
// `cli.mjs bus start ID --internal-host`, a guarded entry, run with the Console's own environment.
|
|
// The organization is made the way the Console makes one (the service, as the local dev owner), and
|
|
// its bus first starts with --no-launch from a human shell; a restart keeps that option.
|
|
test("the Console's Restart bus works: the new host runs with the Console's environment", { timeout: 180000 }, async t => {
|
|
const home = mkdtempSync(join(tmpdir(), 'ag-'));
|
|
const env = humanEnv({ HOME: home, MOSAIC_CONFIG: join(home, '.config/mosaic-agcon/config.json') });
|
|
const setup = spawnSync(join(REPO, 'scripts/bootstrap.sh'), ['--instance', 'agcon'], { env, encoding: 'utf8' });
|
|
assert.equal(setup.status, 0, setup.stderr);
|
|
const ctx = { configDir: join(home, '.config/mosaic-agcon'), dataRoot: join(home, '.mosaic-agcon'), instance: 'agcon', env };
|
|
t.after(async () => {
|
|
try { await busStop(ctx, 'alpha'); } catch {}
|
|
try { await consoleStop(ctx); } catch {}
|
|
rmSync(home, { recursive: true, force: true });
|
|
});
|
|
await createOrg({ ...ctx, principal: DEV }, 'alpha', await templateRender('alpha', { fixture: 'local' }));
|
|
const started = await mosaic(env, ['bus', 'start', 'alpha', '--no-launch', '--dev-local-owner', '--detach']);
|
|
assert.equal(started.status, 0, started.stderr);
|
|
const first = JSON.parse(started.stdout).pid;
|
|
const run = await consoleStart(ctx, { devLocalOwner: true, detach: true, port: 'auto', warn: () => {} });
|
|
const port = Number(new URL(run.url).port);
|
|
assert.ok(!FORBIDDEN.includes(port));
|
|
const restart = await post(port, '/api/orgs/alpha/restart/bus');
|
|
assert.equal(restart.status, 200, JSON.stringify(restart.body));
|
|
assert.equal(restart.body.step, 'bus-restart'); assert.equal(restart.body.healthy, true);
|
|
const after = await busStatus(ctx, 'alpha');
|
|
assert.equal(after.healthy, true); assert.notEqual(after.pid, first);
|
|
// The new host carries the Console's environment: no agent marker. Only the names are read.
|
|
const names = readFileSync(`/proc/${after.pid}/environ`, 'utf8').split('\0').map(l => l.split('=')[0]);
|
|
for (const marker of AGENT_MARKERS) assert.ok(!names.includes(marker), marker);
|
|
// org archive and org delete stop this bus for a person, but refuse an agent run and leave the
|
|
// bus and the organization as they were.
|
|
const show = () => mosaic(env, ['org', 'show', 'alpha', '--json']).then(r => { assert.equal(r.status, 0, r.stderr); return JSON.parse(r.stdout); });
|
|
const before = await show();
|
|
const agent = { ...env, CLAUDECODE: '1' };
|
|
for (const args of [['org', 'archive', 'alpha', '--if-revision', String(before.revision)], ['org', 'delete', 'alpha', '--confirm', 'alpha', '--if-revision', String(before.revision)]]) {
|
|
const r = await mosaic(agent, args);
|
|
assert.equal(r.status, 3, `${args.join(' ')}: ${r.stderr}`);
|
|
assert.match(r.stderr, REFUSED('CLAUDECODE'));
|
|
}
|
|
const still = await busStatus(ctx, 'alpha');
|
|
assert.equal(still.healthy, true); assert.equal(still.pid, after.pid);
|
|
assert.deepEqual(await show(), before);
|
|
// The same archive from a human shell stops the bus; restore makes alpha active again.
|
|
const archived = await mosaic(env, ['org', 'archive', 'alpha', '--if-revision', String(before.revision), '--json']);
|
|
assert.equal(archived.status, 0, archived.stderr);
|
|
// An archived organization has no bus status, so the old host's /proc entry shows it stopped.
|
|
for (let i = 0; i < 100 && existsSync(`/proc/${after.pid}`); i++) await new Promise(r => setTimeout(r, 100));
|
|
assert.ok(!existsSync(`/proc/${after.pid}`), 'archive from a human shell stops the bus host');
|
|
// E1 (P1-4): restore starts the bus, so an agent run is refused before anything changes, and
|
|
// `--no-bus` (which starts nothing) is the only restore an agent run may make.
|
|
const archivedRevision = JSON.parse(archived.stdout).revision;
|
|
for (const marker of ['CLAUDECODE', 'MOSAIC_AGENT_RUN']) {
|
|
const r = await mosaic({ ...env, [marker]: '1' }, ['org', 'restore', 'alpha', '--if-revision', String(archivedRevision), '--json']);
|
|
assert.equal(r.status, 3, r.stderr); assert.match(r.stderr, REFUSED(marker)); assert.equal(r.stdout, '');
|
|
}
|
|
const listed = await mosaic(env, ['org', 'list', '--json']); assert.equal(listed.status, 0, listed.stderr);
|
|
assert.equal(JSON.parse(listed.stdout).find(o => o.id === 'alpha').state, 'archived', 'the refused restore changed nothing');
|
|
const quiet = await mosaic(agent, ['org', 'restore', 'alpha', '--if-revision', String(archivedRevision), '--no-bus', '--json']);
|
|
assert.equal(quiet.status, 0, quiet.stderr); assert.equal(JSON.parse(quiet.stdout).bus, null);
|
|
assert.equal((await busStatus(ctx, 'alpha')).healthy, false, 'restore --no-bus starts nothing');
|
|
// A person's restore starts the bus as the dev owner, with no agent marker in its host.
|
|
const again = await mosaic(env, ['org', 'archive', 'alpha', '--if-revision', String((await show()).revision), '--json']);
|
|
assert.equal(again.status, 0, again.stderr);
|
|
const restored = await mosaic(env, ['org', 'restore', 'alpha', '--if-revision', String(JSON.parse(again.stdout).revision), '--json']);
|
|
assert.equal(restored.status, 0, restored.stderr);
|
|
const restoredBus = JSON.parse(restored.stdout).bus;
|
|
assert.equal(restoredBus.outcome, 'started');
|
|
const up = await busStatus(ctx, 'alpha'); assert.equal(up.healthy, true); assert.equal(up.pid, restoredBus.pid);
|
|
const hostNames = readFileSync(`/proc/${up.pid}/environ`, 'utf8').split('\0').map(l => l.split('=')[0]);
|
|
for (const marker of AGENT_MARKERS) assert.ok(!hostNames.includes(marker), marker);
|
|
await busStop({ ...ctx, env }, 'alpha');
|
|
// A marker in the environment the host is started with stops it at the guarded entry.
|
|
await assert.rejects(busStart({ ...ctx, env: { ...env, MOSAIC_AGENT_RUN: '1' } }, 'alpha', { detach: true, noLaunch: true }), e => e.exitCode === 3 && /bus failed to start/.test(e.message));
|
|
assert.match(readFileSync(join(after.root ?? join(ctx.dataRoot, 'org-runtime/alpha'), 'bus-host/host.log'), 'utf8'), /outside any agent run \(found MOSAIC_AGENT_RUN/);
|
|
assert.equal((await busStatus(ctx, 'alpha')).healthy, false);
|
|
});
|