diff --git a/packages/cli/src/org.mjs b/packages/cli/src/org.mjs index e9c0648d0..27d3b0c92 100644 --- a/packages/cli/src/org.mjs +++ b/packages/cli/src/org.mjs @@ -33,7 +33,7 @@ export async function orgCommand(args, io, deps = {}) { 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']); lifecycle = { confirm: need('--confirm'), ifRevision: revision(p.values['--if-revision'], false) }; 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'] ?? [] }; } diff --git a/packages/cli/tests/agent-guard.test.mjs b/packages/cli/tests/agent-guard.test.mjs index 2bc50b174..dc7ecdbef 100644 --- a/packages/cli/tests/agent-guard.test.mjs +++ b/packages/cli/tests/agent-guard.test.mjs @@ -154,7 +154,7 @@ test('bus status and console status stay open to an agent run', { timeout: 12000 }); // 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'], ['org', 'archive', 'alpha', '--if-revision', '1'], ['org', 'delete', 'alpha', '--confirm', 'alpha']]) { + 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}`); } @@ -228,8 +228,28 @@ test("the Console's Restart bus works: the new host runs with the Console's envi // 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 busStop(ctx, 'alpha'); 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);