org delete checks --if-revision before --confirm again, so a bad revision without --confirm says "invalid revision" as it did on BASE. The archive and delete "bus untouched" check moves from the status test, whose fixture has no owner file, to the Restart bus test, which has a real organization and a live bus. An agent run's archive and delete exit 3 and leave the bus pid and the organization unchanged; the same archive from a human shell stops the host, and restore makes the organization active again.
257 lines
16 KiB
JavaScript
257 lines
16 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'],
|
|
];
|
|
|
|
// 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', 'delete', 'alpha'], ['org', 'delete', 'alpha', '--confirm', 'alpha', '--if-revision', 'x']]) {
|
|
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');
|
|
const restored = await mosaic(env, ['org', 'restore', 'alpha', '--if-revision', String(JSON.parse(archived.stdout).revision), '--json']);
|
|
assert.equal(restored.status, 0, restored.stderr);
|
|
// 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);
|
|
});
|