Files
stack/packages/cli/tests/proxy-setup-cli.test.mjs
T
jason.woltje f0e11cb342 feat(proxy): Console proxy setup page and CLI parity (#1873)
One Proxy setup panel on the Model proxy settings page takes an owner from
nothing to a running proxy: status with one next-step button per state, the
pinned fetch with hash checks, progress and a receipt, init with a
bus-generated key (a pasted key only over loopback or HTTPS, with a
confirmation), unit render/install/start/stop/restart with health, a fence
clear that the bus re-proves against a confirmed restart, and a stale init
lock clear that names its blockers. Own proxy per organization shows as not
built.

New Console routes under /api/proxy/admin/ use the existing owner proxy
checks; agents, testers and grant holders get human-required. New bus verbs
proxy.init {generate:true} and proxy.fence.clear. Receipts go to
<dataRoot>/state/proxy-admin.jsonl with labels and key ids only. Every new
refusal code has a refusal-table entry.

CLI: mosaic proxy status, unit render|status|install|start|stop|restart,
init --generate-key, fence clear --restart, init-lock status|clear; proxy
fetch keeps its stdout and adds progress and the receipt.

Page fix found in the end-to-end run: a stopped proxy hides the account
panels until the service answers. (The catalogue switch label fix found in
the same run landed upstream as D3 P0-4.)
2026-11-11 16:32:31 -06:00

127 lines
9.2 KiB
JavaScript

// #1873 CLI parity for the Console's Proxy page: status, unit, init-lock, init --generate-key and
// fence clear --restart. Every verb runs only from a human shell; nothing prints a key or a path.
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { main } from '../src/cli.mjs';
import { proxyCommand } from '../src/model-proxy.mjs';
import { io } from './helpers.mjs';
const code = c => e => e.code === c;
const fakeSystem = () => ({ dataRoot: join(mkdtempSync(join(tmpdir(), 'ps-cli-')), 'data') });
const STATUS = {
release: { fetched: true, version: '8.0.42', panelVersion: '1.26.7', verified: true },
init: { initialized: true, partial: false, settings: { port: 18432 }, files: [] },
unit: { unit: 'mosaic-proxy.service', installed: true, active: 'active', pid: 77, port: 18432, listening: true },
fence: { set: false }, initLock: { present: false, clearable: false, blockers: [] },
ownProxy: { available: false, state: 'not built' }, next: ['accounts'],
};
function fakeAdmin({ restart = { confirmed: true } } = {}) {
const calls = [];
return {
calls,
status: async () => { calls.push(['status']); return STATUS; },
render: async () => { calls.push(['render']); return { text: '[Unit]\nDescription=fixture\n' }; },
unitAction: async op => { calls.push(['unit', op]); return op === 'restart' ? { receipt: { op: 'unit.restart' }, evidence: restart } : { op, ok: true }; },
initLockClear: async () => { calls.push(['init-lock.clear']); return { cleared: true, receipt: { op: 'init-lock.clear' } }; },
fenceClear: async call => { calls.push(['fence.clear']); return call('proxy.fence.clear', { evidence: restart }); },
};
}
const human = () => io();
test('every new setup verb refuses inside an agent shell and with --tester, before the controller runs', async () => {
const admin = fakeAdmin();
for (const args of [['proxy', 'status'], ['proxy', 'status', '--json'], ['proxy', 'unit', 'render'], ['proxy', 'unit', 'start'], ['proxy', 'unit', 'restart'],
['proxy', 'init-lock', 'status'], ['proxy', 'init-lock', 'clear'], ['proxy', 'fence', 'clear', '--restart', '--business', 'demo'], ['proxy', 'init', '--generate-key', '--business', 'demo']]) {
await assert.rejects(main(args, io({ CODEX_THREAD_ID: 'fixture' }), { system: fakeSystem(), proxyAdmin: admin, transport: () => () => { throw Error('must not call'); } }), code('human-required'), args.join(' '));
await assert.rejects(main([...args, '--tester'], human(), { system: fakeSystem(), proxyAdmin: admin }), code('human-required'), `${args.join(' ')} --tester`);
}
assert.deepEqual(admin.calls, []);
});
test('proxy status prints the same facts the page shows, and --json the status object', async () => {
const admin = fakeAdmin();
const x = human();
await main(['proxy', 'status'], x, { system: fakeSystem(), proxyAdmin: admin });
assert.match(x.out.text, /^release: 8\.0\.42 \(panel 1\.26\.7\) hashes verified$/m);
assert.match(x.out.text, /^unit: mosaic-proxy\.service installed, active pid 77, port 18432 answering$/m);
assert.match(x.out.text, /^own proxy: not built$/m);
assert.match(x.out.text, /^next: accounts$/m);
const y = human();
await main(['proxy', 'status', '--json'], y, { system: fakeSystem(), proxyAdmin: admin });
assert.deepEqual(JSON.parse(y.out.text), STATUS);
await assert.rejects(main(['proxy', 'status', '--verbose'], human(), { system: fakeSystem(), proxyAdmin: admin }), code('proxy-usage'));
});
test('proxy unit and init-lock verbs map one to one onto the controller; bad usage refuses', async () => {
const admin = fakeAdmin();
const x = human();
await main(['proxy', 'unit', 'render'], x, { system: fakeSystem(), proxyAdmin: admin });
assert.equal(x.out.text, '[Unit]\nDescription=fixture\n');
for (const op of ['install', 'start', 'stop']) await main(['proxy', 'unit', op], human(), { system: fakeSystem(), proxyAdmin: admin });
await main(['proxy', 'unit', 'status'], human(), { system: fakeSystem(), proxyAdmin: admin });
await main(['proxy', 'init-lock', 'status'], human(), { system: fakeSystem(), proxyAdmin: admin });
const y = human();
await main(['proxy', 'init-lock', 'clear'], y, { system: fakeSystem(), proxyAdmin: admin });
assert.equal(JSON.parse(y.out.text).cleared, true);
assert.deepEqual(admin.calls, [['render'], ['unit', 'install'], ['unit', 'start'], ['unit', 'stop'], ['status'], ['status'], ['init-lock.clear']]);
for (const args of [['proxy', 'unit'], ['proxy', 'unit', 'reload'], ['proxy', 'unit', 'start', 'now'], ['proxy', 'init-lock'], ['proxy', 'init-lock', 'clear', '--force']])
await assert.rejects(main(args, human(), { system: fakeSystem(), proxyAdmin: admin }), code('proxy-usage'), args.join(' '));
});
test('a controller refusal reaches the shell as its proxy code only, with no path or detail', async () => {
const admin = fakeAdmin();
admin.unitAction = async () => { throw Object.assign(Error('/home/owner/.config/mosaic-dev/proxy.json pa-cli-sentinel'), { code: 'proxy-unit-missing' }); };
admin.status = async () => { throw Error('pa-cli-sentinel'); };
await assert.rejects(main(['proxy', 'unit', 'start'], human(), { system: fakeSystem(), proxyAdmin: admin }), e => e.code === 'proxy-unit-missing' && !e.message.includes('pa-cli-sentinel') && !e.message.includes('/home/'));
await assert.rejects(main(['proxy', 'status'], human(), { system: fakeSystem(), proxyAdmin: admin }), e => e.code === 'proxy-unit-failed' && !e.message.includes('pa-cli-sentinel'));
});
test('proxy init --generate-key needs no terminal, never prompts, and sends generate to the bus', async () => {
const x = human();
assert.notEqual(x.stdin.isTTY, true);
const calls = [];
await main(['proxy', 'init', '--generate-key', '--business', 'demo', '--port', '18440', '--rotate-grace-minutes', '5'], x, {
system: fakeSystem(), promptProxyKey: () => { throw Error('must not prompt'); },
transport: b => async (verb, args) => { calls.push({ b, verb, args }); return { port: 18440, rotateGraceMinutes: 5, binary: '/fixture' }; } });
assert.deepEqual(calls, [{ b: 'demo', verb: 'proxy.init', args: { generate: true, port: 18440, rotateGraceMinutes: 5 } }]);
assert.deepEqual(JSON.parse(x.out.text), { port: 18440, rotateGraceMinutes: 5, firstOutputSeconds: null, generated: true });
await assert.rejects(main(['proxy', 'init', '--generate-key', '--generate-key', '--business', 'demo'], human(), { system: fakeSystem() }), code('proxy-usage'));
await assert.rejects(main(['proxy', 'init', '--generate-key', '--port', '8317', '--business', 'demo'], human(), { system: fakeSystem() }), code('proxy-config-invalid'));
});
test('proxy fence clear --restart restarts first and clears only on a confirmed restart', async () => {
const admin = fakeAdmin();
const calls = [];
const x = human();
await main(['proxy', 'fence', 'clear', '--restart', '--business', 'demo'], x, { system: fakeSystem(), proxyAdmin: admin,
transport: b => async (verb, args) => { calls.push({ b, verb, args }); return { cleared: true, evidence: { after: { pid: 2 } } }; } });
assert.deepEqual(admin.calls, [['unit', 'restart'], ['fence.clear']]);
assert.deepEqual(calls, [{ b: 'demo', verb: 'proxy.fence.clear', args: { evidence: { confirmed: true } } }]);
assert.deepEqual(JSON.parse(x.out.text), { restart: { op: 'unit.restart' }, cleared: true, evidence: { after: { pid: 2 } } });
const unconfirmed = fakeAdmin({ restart: { confirmed: false } });
await assert.rejects(main(['proxy', 'fence', 'clear', '--restart', '--business', 'demo'], human(), { system: fakeSystem(), proxyAdmin: unconfirmed,
transport: () => () => { throw Error('must not call'); } }), code('proxy-restart-unconfirmed'));
assert.deepEqual(unconfirmed.calls, [['unit', 'restart']]);
await assert.rejects(main(['proxy', 'fence', 'clear', '--restart', '--restart'], human(), { system: fakeSystem(), proxyAdmin: admin }), code('proxy-usage'));
});
test('proxy fetch shows the page\'s progress steps and writes the same fetch receipt; stdout keeps its line', async () => {
const system = fakeSystem();
const x = human();
await proxyCommand(['fetch'], x, { system, fetch: async ({ progress }) => { for (const step of ['download-archive', 'verify-archive', 'install']) progress(step); return { binary: 'fixture-binary', panel: 'fixture-panel' }; } });
assert.deepEqual(JSON.parse(x.out.text), { binary: 'fixture-binary', panel: 'fixture-panel' });
assert.match(x.err.text, /^fetch: download-archive\nfetch: verify-archive\nfetch: install\nreceipt: /);
const receipts = readFileSync(join(system.dataRoot, 'state', 'proxy-admin.jsonl'), 'utf8').trim().split('\n').map(JSON.parse);
assert.equal(receipts.length, 1);
assert.equal(receipts[0].op, 'fetch');
assert.equal(receipts[0].verified, true);
assert.match(receipts[0].archiveSha256, /^[0-9a-f]{64}$/);
// A failed hash check writes no receipt and keeps its code.
const failed = fakeSystem();
await assert.rejects(proxyCommand(['fetch'], human(), { system: failed, fetch: async () => { throw Object.assign(Error('mismatch'), { code: 'proxy-fetch-hash' }); } }), code('proxy-fetch-hash'));
assert.throws(() => readFileSync(join(failed.dataRoot, 'state', 'proxy-admin.jsonl')), { code: 'ENOENT' });
});