fix(cli): bus and Console lifecycle verbs refuse inside an agent run (#1873) #1942

Merged
jason.woltje merged 3 commits from agent-guard into refactor 2026-11-11 22:32:24 +00:00
7 changed files with 314 additions and 14 deletions

No files matched your search

+4 -2
View File
@@ -309,7 +309,7 @@ scripts/mosaic inbox | tasks | agents [--business <id>] [--json]
scripts/mosaic inbox --read <id>... | --read-all [--business <id>]
scripts/mosaic decide <decision> <option> [--note <text>] [--yes]
scripts/mosaic trail <task|decision> [--json]
scripts/mosaic bus start <business> [--pm | --no-launch] [--dev-local-owner] | stop | status [--json]
scripts/mosaic bus start <business> [--pm | --no-launch] [--dev-local-owner] [--detach] | stop | restart | recover | status [--json]
scripts/mosaic console start [--dev-local-owner [--dev-insecure-bind ADDR --dev-allow-host NAME[,NAME...]]] [--detach] [--port auto|N]
scripts/mosaic console stop | status | restart
scripts/mosaic org reload <org>
@@ -333,7 +333,9 @@ For local development on a named instance only, `mosaic bus start BUSINESS --dev
`mosaic console start --dev-local-owner --dev-insecure-bind ADDR --dev-allow-host NAME[,NAME...]` opens that dev owner Console to other machines, for UI testing from another device on a trusted LAN. It has no sign-in: anyone who can reach ADDR:PORT acts as the owner. Start, and `mosaic console status` while that Console runs, print `WARNING: dev owner Console listens on ADDR:PORT with no sign-in; anyone who can reach it acts as the owner.` on stderr. ADDR is `0.0.0.0` or an IPv4 address, and a specific address must also be one of the names. NAME is a host name or IPv4 address without a port; give several as one comma-separated value. The Console answers only `NAME:<bound port>` and the loopback names, and takes a write only when Origin is `http://` plus that same Host. A browser on such an http origin sends no Fetch Metadata, so for an allowed NAME a write with that exact Origin and no `Sec-Fetch-Site` counts as same-origin; a `Sec-Fetch-Site` the browser did send is still checked. Both flags are refused without `--dev-local-owner`, and `--dev-insecure-bind` is refused without a `--dev-allow-host`. SSO, preview and tester Consoles stay on loopback. The Console state file records `bind` and `allowHosts`. `mosaic console status` prints the state as JSON (`bind` is `127.0.0.1` for a loopback Console) and never moves a stale state file.
`mosaic console restart` and `mosaic org reload ORG` are the Console's Settings controls (F3) from a terminal (U1, #1873). Each sends one request to the running Console named in the instance's console state, as the page sends it (the Console's own Host and Origin, `Sec-Fetch-Site: same-origin`, a JSON `{}` body), so the Console applies the same owner, `--send` and origin checks and writes the same action trail, `<dataRoot>/console/actions.jsonl`. Both refuse inside an agent run (exit 3) before reading anything, and refuse with exit 3 when no Console runs. `console restart` works only for a Console started in the foreground by `mosaic console start`, or by `mosaic console start --dev-local-owner --detach`, which leaves a detached supervisor (`packages/cli/src/console-supervisor.mjs`, its own session) running the Console as the foreground start does; its receipt adds the supervisor's `supervisor` pid, and its later warnings go to `console/supervisor-*.log` under the data root. `mosaic console stop` stops that Console, and its supervisor then exits. `mosaic console start --dev-local-owner` refuses inside an agent run (exit 3), with or without `--detach`, checking the starting process's own environment as well as the one given for the Console; a start without `--dev-local-owner` is unchanged. A detached start whose supervisor does not reply within 60 seconds signals the supervisor's process group, then kills it, and clears `console/start.lock` only when the lock names that supervisor (pid and start time) and it is gone. Any other Console answers `not-supervised`. `console restart` waits up to 30 seconds for the relaunched Console to answer and prints `{"outcome":"restarted","from":OLD,"boot":NEW}`. `org reload ORG` restarts a running bus or starts a stopped one and prints the Console's done receipt. A request that left but got no answer is reported as outcome unknown (exit 1) and is never retried; check `mosaic console status`, `mosaic bus status ORG` and the trail. `mosaic bus restart ORG` remains the trail-less host verb.
`mosaic console restart` and `mosaic org reload ORG` are the Console's Settings controls (F3) from a terminal (U1, #1873). Each sends one request to the running Console named in the instance's console state, as the page sends it (the Console's own Host and Origin, `Sec-Fetch-Site: same-origin`, a JSON `{}` body), so the Console applies the same owner, `--send` and origin checks and writes the same action trail, `<dataRoot>/console/actions.jsonl`. Both refuse inside an agent run (exit 3) before reading anything, and refuse with exit 3 when no Console runs. `console restart` works only for a Console started in the foreground by `mosaic console start`, or by `mosaic console start --dev-local-owner --detach`, which leaves a detached supervisor (`packages/cli/src/console-supervisor.mjs`, its own session) running the Console as the foreground start does; its receipt adds the supervisor's `supervisor` pid, and its later warnings go to `console/supervisor-*.log` under the data root. `mosaic console stop` stops that Console, and its supervisor then exits. `mosaic console start` refuses inside an agent run (exit 3), with or without `--dev-local-owner` or `--detach`, checking the starting process's own environment as well as the one given for the Console (agent-guard, #1873). A detached start whose supervisor does not reply within 60 seconds signals the supervisor's process group, then kills it, and clears `console/start.lock` only when the lock names that supervisor (pid and start time) and it is gone. Any other Console answers `not-supervised`. `console restart` waits up to 30 seconds for the relaunched Console to answer and prints `{"outcome":"restarted","from":OLD,"boot":NEW}`. `org reload ORG` restarts a running bus or starts a stopped one and prints the Console's done receipt. A request that left but got no answer is reported as outcome unknown (exit 1) and is never retried; check `mosaic console status`, `mosaic bus status ORG` and the trail. `mosaic bus restart ORG` remains the trail-less host verb.
The bus and Console lifecycle verbs refuse inside an agent run (agent-guard, #1873): `mosaic bus start`, `bus stop`, `bus restart` and `bus recover`, and `mosaic console start`, `console stop` and `console restart`, and `mosaic org archive` and `org delete`, which stop the organization's bus. Each exits 3 with `refused: this runs only from a human shell, outside any agent run (found MARKER in the environment)` before it reads anything. It checks the environment it is given and its own process environment, so a sanitized environment does not hide a marker. A usage error (a missing or extra argument, `--pm` with `--no-launch`, or a bad `--dev-insecure-bind`/`--dev-allow-host`) still comes first (exit 4). `mosaic bus status` and `mosaic console status` stay open to agents. The Console's Restart bus and Restart Console controls keep working: the Console starts its bus host with its own environment, which carries no marker, and Restart Console relaunches the Console under its supervisor without going through the CLI. The markers are the ones `packages/cli/src/transport.mjs` lists. The check is cooperative: a process that drops the markers from its environment is not stopped by it.
The instance processing switch is separate from per-business launches off/on.
`mosaic halt` durably stops managed processing across every business in the
+11
View File
@@ -298,9 +298,16 @@ mosaic bus start <business> [--pm]
the host; run by the unit, not by hand.
--pm also launches the business's PM
mosaic bus stop SIGTERM to the recorded host, then wait
mosaic bus restart stop, then start with the recorded options
mosaic bus recover clear a dead host's locks, with a receipt
mosaic bus status [--json] host state file, socket, writer.lock
```
`bus start`, `bus stop`, `bus restart` and `bus recover`, and `console start`,
`console stop` and `console restart`, refuse inside an agent run (exit 3,
agent-guard #1873). They check the environment they are given and their own
process environment. `bus status` and `console status` stay open to agents.
`bus start` does the following:
1. It reads the system config through `scripts/mosaic-config.mjs validate`,
@@ -1290,6 +1297,10 @@ mean a bus start is still in progress; if a process crashed and left its lock,
inspect it before removing it and resuming. Moves use rename and therefore
require the source and deleted folder to be on the same filesystem.
`mosaic org archive` and `mosaic org delete` stop the organization bus, so like
`bus stop` they refuse inside an agent run (exit 3, agent-guard #1873), after
their usage checks and before any config is read. `org restore` is unchanged.
System types are installed plug-ins. `mosaic org system-types installed` prints
module metadata; `mosaic org system-types enabled ID` prints an organization's enabled
ids. The owner changes the enabled list through the existing organization file
+21 -4
View File
@@ -569,10 +569,21 @@ function organizationContext(io, deps) {
return { configDir: dir, dataRoot: system.dataRoot, instance, env: io.env, system };
}
// agent-guard (#1873): the bus and Console lifecycle verbs (start, stop, restart, recover) refuse
// inside an agent run, before any read, with refuseInsideAgent's refusal (exit 3). This process's
// own environment is checked as well as the one the command was given, so a sanitized io.env does
// not hide a marker (D1 security re-check P1). Status verbs stay open to agents. A person keeps
// these verbs in a human shell, and the Console's Restart bus and Restart Console controls.
function refuseAgentRun(io) {
refuseInsideAgent(process.env);
refuseInsideAgent(io.env);
}
async function consoleCommand(rest, io, deps) {
const [sub, ...args] = rest;
if (sub === "stop") {
if (args.length) throw usage("console stop takes no arguments");
refuseAgentRun(io);
io.stdout.write(`${JSON.stringify(await consoleStop(organizationContext(io, deps), { warn: s => io.stderr.write(s) }))}\n`);
return;
}
@@ -585,7 +596,7 @@ async function consoleCommand(rest, io, deps) {
// and trail checks (org-console.mjs consoleRestart); an agent run is refused before any read.
if (sub === "restart") {
if (args.length) throw usage("console restart takes no arguments");
refuseInsideAgent(io.env);
refuseAgentRun(io);
io.stdout.write(`${JSON.stringify(await consoleRestart(organizationContext(io, deps), deps.consoleRequest ?? {}))}\n`);
return;
}
@@ -595,11 +606,12 @@ async function consoleCommand(rest, io, deps) {
// org-lan-bind: refused here, before any config is read, unless both flags come with
// --dev-local-owner. ADDR is 0.0.0.0 or an IPv4 address; NAME[,NAME...] has no ports.
const devOwner = parsed.flags.has("--dev-local-owner"), bind = parsed.values["--dev-insecure-bind"] ?? null;
// D1: an agent run never starts a dev-local-owner Console (org-console.mjs consoleStart checks again).
if (devOwner) { refuseInsideAgent(process.env); refuseInsideAgent(io.env); }
const names = Object.hasOwn(parsed.values, "--dev-allow-host") ? [parsed.values["--dev-allow-host"]] : [];
let lan;
try { lan = devLanOptions({ devOwner, bind, names }); } catch (err) { throw usage(err.message); }
// D1, agent-guard: an agent run never starts a Console, dev-local-owner or not (org-console.mjs
// consoleStart checks again). Usage errors above come first; devLanOptions reads nothing.
refuseAgentRun(io);
const receipt = await consoleStart(organizationContext(io, deps), { devLocalOwner: devOwner, detach: parsed.flags.has("--detach"), port: parsed.values["--port"] ?? "auto", ...(lan ? { bind: lan.bind, allowHosts: lan.allowHosts } : {}), warn: s => io.stderr.write(s) });
io.stdout.write(`${JSON.stringify(receipt)}\n`);
if (!parsed.flags.has("--detach")) {
@@ -620,6 +632,9 @@ async function bus(rest, io, deps) {
const parsed = parse(args, { flags: ["--pm", "--no-launch", "--dev-local-owner", "--detach", "--internal-host"] });
if (parsed.positional.length !== 1) throw usage("bus start takes one organization");
if (parsed.flags.has("--no-launch") && parsed.flags.has("--pm")) throw usage("bus start takes --pm or --no-launch, not both");
// agent-guard: --internal-host too. The host a Console or a person starts runs it with that
// process's environment (org-bus.mjs busStart), which carries no agent marker.
refuseAgentRun(io);
assertBuildStart({ devOwner: parsed.flags.has("--dev-local-owner") });
if (parsed.flags.has("--dev-local-owner")) devOwnerInstance(io.env);
const ctx = organizationContext(io, deps);
@@ -706,7 +721,9 @@ async function bus(rest, io, deps) {
const parsed = parse(args, { flags: sub === "status" ? ["--json"] : [] });
if (parsed.positional.length !== 1) throw usage(`bus ${sub} takes one organization`);
// D2 security review: recovery clears host state, so an agent run is refused before any read.
if (sub === "recover") { refuseInsideAgent(io.env); devOwnerInstance(io.env); }
// agent-guard: stop and restart too; status stays open to agents.
if (sub !== "status") refuseAgentRun(io);
if (sub === "recover") devOwnerInstance(io.env);
const ctx = organizationContext(io, deps), id = parsed.positional[0];
const result = sub === "stop" ? await busStop(ctx, id) : sub === "restart" ? await busRestart(ctx, id) : sub === "recover" ? await busRecover(ctx, id) : await busStatus(ctx, id);
io.stdout.write(`${JSON.stringify(result, null, parsed.flags.has("--json") ? 2 : 0)}\n`);
+4 -3
View File
@@ -237,9 +237,10 @@ export async function consoleStart(ctx, { devLocalOwner = false, detach = false,
let lan;
try { lan = devLanOptions({ devOwner: devLocalOwner, bind, names: allowHosts }); } catch (e) { throw new CliError(e.message, 4); }
// D1 fix round (security review P1): a dev-local-owner Console acts as the owner with no sign-in,
// so an agent run never starts one, detached or not. Other starts are unchanged. This process's own
// environment is checked as well as the one given for the Console (security re-check P1).
if (devLocalOwner) { refuseInsideAgent(process.env); if (ctx.env) refuseInsideAgent(ctx.env); }
// so an agent run never starts one, detached or not. agent-guard (#1873): nor any other Console.
// This process's own environment is checked as well as the one given for the Console (security
// re-check P1). A Restart Console relaunch goes through relaunch(), not here.
refuseInsideAgent(process.env); if (ctx.env) refuseInsideAgent(ctx.env);
if (detach && devLocalOwner) return detachedSupervisor(ctx, { port, bind, allowHosts, warn });
const release = takeLock(ctx);
let run, args;
+10 -2
View File
@@ -15,6 +15,10 @@ function parse(args) { const pos = [], values = {}, flags = new Set(); for (let
// A refusal names the document by label and the error by its code, never by host path or bytes (PR #1916).
const errorCode = e => typeof e?.code === 'string' && /^E[A-Z0-9]+$/.test(e.code) ? e.code : 'unreadable';
async function document(file) { try { return JSON.parse(await readFile(file, 'utf8')); } catch (e) { throw new CliError(`cannot read organization document: ${e instanceof SyntaxError ? 'not valid JSON' : errorCode(e)}`, 2); } }
// agent-guard (#1873): archive and delete stop the organization's bus, so an agent run is refused
// as it is for `mosaic bus stop`: after the usage checks, before any config is read, and checking
// this process's own environment as well as io.env. Restore starts nothing and stays as it was.
function refuseAgentRun(io) { refuseInsideAgent(process.env); refuseInsideAgent(io.env); }
export async function orgCommand(args, io, deps = {}) {
try {
// #1873 T1: the definitions verbs take their own options.
@@ -26,14 +30,18 @@ export async function orgCommand(args, io, deps = {}) {
if (words[0] === 'validate' && words.length === 2) { allowed([], []); result = await validateOrg(await document(words[1])); if (!result.ok) throw new CliError(result.errors.map(e => `${e.path}: ${e.message}`).join('\n'), 2); io.stdout.write('valid\n'); return; }
// U1 (#1873): `org reload` is a human action; an agent run is refused before any config is read.
if (words[0] === 'reload') refuseInsideAgent(io.env);
const revision = (r, required) => { if (r === undefined && !required) return undefined; if (!/^[1-9][0-9]*$/.test(r ?? '')) throw new CliError(r === undefined ? '--if-revision is required' : 'invalid revision', 4); return Number(r); };
let lifecycle = null;
if (['archive', 'restore'].includes(words[0]) && words.length === 2) { allowed(['--json'], ['--if-revision']); lifecycle = { ifRevision: revision(p.values['--if-revision'], true) }; if (words[0] === 'archive') refuseAgentRun(io); }
else if (words[0] === 'delete' && words.length === 2) { allowed(['--json'], ['--confirm', '--if-revision']); const ifRevision = revision(p.values['--if-revision'], false); lifecycle = { confirm: need('--confirm'), ifRevision }; refuseAgentRun(io); }
if (words[0] === 'system-types' && words[1] === 'installed' && words.length === 2) { allowed(['--json'], []); io.stdout.write(JSON.stringify(await installedSystemTypes(), null, 2) + '\n'); return; }
const instance = devOwnerInstance(io.env); const system = deps.system ?? loadSystem({ env: io.env, instance }); const ctx = { configDir: configDir(io.env), dataRoot: system.dataRoot, instance, principal: DEV_OWNER };
if (words[0] === 'system-types' && words[1] === 'enabled' && words.length === 3) { allowed(['--json'], []); const org = await showOrg(ctx, words[2]); result = { organization: words[2], enabled: org.doc.plugins?.['system-type'] ?? [] }; }
else if (words[0] === 'lock' && ['show', 'clear'].includes(words[1]) && words.length === 3) { allowed([], []); result = await orgLock(ctx, words[2], { clear: words[1] === 'clear' }); }
else if (words[0] === 'create' && words.length === 2) { allowed([], ['--from']); result = await createOrg(ctx, words[1], await document(need('--from'))); }
else if (words[0] === 'update' && words.length === 2) { allowed([], ['--from', '--if-revision']); const r = need('--if-revision'); if (!/^[1-9][0-9]*$/.test(r)) throw new CliError('invalid revision', 4); result = await updateOrg(ctx, words[1], await document(need('--from')), { ifRevision: Number(r) }); }
else if (['archive', 'restore'].includes(words[0]) && words.length === 2) { allowed(['--json'], ['--if-revision']); const r = need('--if-revision'); if (!/^[1-9][0-9]*$/.test(r)) throw new CliError('invalid revision', 4); const opts = { ifRevision: Number(r) }; result = words[0] === 'archive' ? await archiveOrg(ctx, words[1], { ...opts, busStop: busStopForDelete }) : await restoreOrg(ctx, words[1], opts); }
else if (words[0] === 'delete' && words.length === 2) { allowed(['--json'], ['--confirm', '--if-revision']); const r = p.values['--if-revision']; if (r !== undefined && !/^[1-9][0-9]*$/.test(r)) throw new CliError('invalid revision', 4); result = await deleteOrg(ctx, words[1], { busStop: busStopForDelete, confirm: need('--confirm'), ifRevision: r === undefined ? undefined : Number(r) }); }
else if (['archive', 'restore'].includes(words[0]) && words.length === 2) result = words[0] === 'archive' ? await archiveOrg(ctx, words[1], { ...lifecycle, busStop: busStopForDelete }) : await restoreOrg(ctx, words[1], lifecycle);
else if (words[0] === 'delete' && words.length === 2) result = await deleteOrg(ctx, words[1], { busStop: busStopForDelete, ...lifecycle });
// U1 (#1873): the F3 bus control from a terminal, through the running Console's own route, which
// checks the owner and writes the action trail (org-console.mjs consoleBusRestart).
else if (words[0] === 'reload' && words.length === 2) { allowed([], []); result = await consoleBusRestart(ctx, words[1], deps.consoleRequest ?? {}); }
+256
View File
@@ -0,0 +1,256 @@
// 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);
});
+8 -3
View File
@@ -338,7 +338,8 @@ test('a supervisor signalled during its start stops the console it started and r
});
// D1 fix round (security review P1): an agent run never starts a dev-local-owner Console.
test('an agent run cannot start a dev-local-owner console; a start without it is unchanged', { timeout: 60000 }, async t => {
// agent-guard (#1873): nor a Console without it; the human start is unchanged.
test('an agent run cannot start a console, dev-local-owner or not; a human start is unchanged', { timeout: 60000 }, async t => {
const { ctx, mosaic } = await instance(t);
const agent = { ...ctx, env: { ...ctx.env, MOSAIC_RUN_ID: 'run-1' } };
for (const detach of [true, false]) {
@@ -358,8 +359,12 @@ test('an agent run cannot start a dev-local-owner console; a start without it is
assert.deepEqual(existsSync(consoleDir) ? readdirSync(consoleDir) : [], []);
assert.deepEqual(consoleStatus(ctx), { running: false });
const plain = agentCli(['console', 'start', '--detach', '--port', 'auto']);
assert.equal(plain.status, 0, plain.stderr);
assert.deepEqual(Object.keys(JSON.parse(plain.stdout)), ['url', 'pid']);
assert.equal(plain.status, 3); assert.equal(plain.stdout, ''); assert.match(plain.stderr, /only from a human shell/);
await assert.rejects(consoleStart(agent, { detach: true, port: 'auto', warn: () => {} }), e => e.exitCode === 3 && /found MOSAIC_RUN_ID/.test(e.message));
assert.deepEqual(existsSync(consoleDir) ? readdirSync(consoleDir) : [], []);
const human = mosaic(['console', 'start', '--detach', '--port', 'auto']);
assert.equal(human.status, 0, human.stderr);
assert.deepEqual(Object.keys(JSON.parse(human.stdout)), ['url', 'pid']);
assert.equal((await consoleStop(ctx)).stopped, true);
});