Compare commits
9
Commits
dev-console
...
proxy-ui
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
75f6d5533a | ||
|
|
f0e11cb342 | ||
|
|
35c125fc1f | ||
|
|
294149c20e | ||
|
|
6f18f3d015 | ||
|
|
a5f227e9fc | ||
|
|
ce066123b0 | ||
|
|
5dbd7e72d3 | ||
|
|
d23673cd3e |
No files matched your search
+21
-2
@@ -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
|
||||
@@ -1345,6 +1347,23 @@ input; error on stderr, never a clean verdict).
|
||||
problem. Scripts print diagnostics on stderr; model responses (and only
|
||||
model responses) on stdout.
|
||||
|
||||
## Model proxy setup (`scripts/mosaic proxy`, #1873)
|
||||
|
||||
Human-shell verbs that match the Console's Proxy setup panel; each refuses
|
||||
`human-required` in an agent shell or with `--tester`, and prints no key or
|
||||
absolute path. `proxy fetch` (existing output unchanged) now prints the
|
||||
page's progress steps on stderr and writes the same fetch receipt to
|
||||
`<dataRoot>/state/proxy-admin.jsonl`. `proxy status [--json]` shows release, init, unit, fence, init
|
||||
lock, own proxy and the next step. `proxy init --generate-key [--port N]
|
||||
[--rotate-grace-minutes N] [--first-output-seconds N] --business ID` has the
|
||||
bus generate the management key (no prompt, no TTY). `proxy unit
|
||||
render|status|install|start|stop|restart` manages the proxy user unit.
|
||||
`proxy fence clear --restart --business ID` restarts the unit, requires a
|
||||
confirmed new process listening on the port, then clears the fence with that
|
||||
evidence (`proxy-restart-unconfirmed` otherwise). `proxy init-lock
|
||||
status|clear` clears a stale init lock only when every bus is stopped and no
|
||||
proxy state exists. Details: `packages/model-proxy/README.md`.
|
||||
|
||||
## Agent limits (`scripts/mosaic limits`)
|
||||
|
||||
View: `scripts/mosaic limits --business ID --json`. Set: `scripts/mosaic limits
|
||||
|
||||
+36
-10
@@ -606,6 +606,7 @@ export class Broker {
|
||||
#pluginService;
|
||||
#credentialVerbs;
|
||||
#proxyInit;
|
||||
#proxyFenceClear;
|
||||
#proxyPage;
|
||||
#project;
|
||||
#library;
|
||||
@@ -666,7 +667,7 @@ export class Broker {
|
||||
#processing;
|
||||
#taskTracker;
|
||||
// credentialStatus is the runtime's metadata-only Credentials.status(); the broker never gets use().
|
||||
constructor({ store, businesses, pluginService = null, secretCheck = () => {}, credentialStatus = () => [], organization = null, hires = null, autonomy = null, project = null, library = null, usage = null, limits = null, harnessOn = null, credentialVerbs = null, proxyInit = null, proxyPage = null, hostGrant = null, hostKeys = null, processing = null, testerOn = null, grantScope = null, roleFile = null, giteaGrant = null, giteaKeys = null, access = null, accessStamp = null, compactions = null, requestLifecycle = null, requirementsHost = null, workspaceRead = null, taskTracker = null }) {
|
||||
constructor({ store, businesses, pluginService = null, secretCheck = () => {}, credentialStatus = () => [], organization = null, hires = null, autonomy = null, project = null, library = null, usage = null, limits = null, harnessOn = null, credentialVerbs = null, proxyInit = null, proxyFenceClear = null, proxyPage = null, hostGrant = null, hostKeys = null, processing = null, testerOn = null, grantScope = null, roleFile = null, giteaGrant = null, giteaKeys = null, access = null, accessStamp = null, compactions = null, requestLifecycle = null, requirementsHost = null, workspaceRead = null, taskTracker = null }) {
|
||||
if (!object(businesses) || !Object.keys(businesses).length) fail('invalid-business');
|
||||
// Row 194 b15 (sage-2501): the function-argument checks come before anything reads the store.
|
||||
if (harnessOn !== null && typeof harnessOn !== 'function') throw new TypeError('harnessOn must be a function');
|
||||
@@ -695,6 +696,7 @@ export class Broker {
|
||||
// Row 199: the runtime's credential lifecycle (credential-verbs.mjs); it holds the store, not the broker.
|
||||
this.#credentialVerbs = credentialVerbs;
|
||||
this.#proxyInit = proxyInit;
|
||||
this.#proxyFenceClear = proxyFenceClear;
|
||||
this.#proxyPage = proxyPage;
|
||||
this.#hostGrant = hostGrant;
|
||||
this.#hostKeys = hostKeys;
|
||||
@@ -5971,6 +5973,19 @@ export class Broker {
|
||||
return Promise.resolve().then(() => this.#proxyPage(request.verb, args, { business: s.business, human: s.human }))
|
||||
.catch(error => { throw this.#refused(s, new BusError(PROXY_PAGE_CODES.includes(error.code) ? error.code : 'proxy-down'), { verb: request.verb }); });
|
||||
}
|
||||
// #1873: the fence clear takes restart evidence, which the runtime re-proves on the host
|
||||
// before the lock clears. Owner only, before every other gate, like the proxy page verbs.
|
||||
if (request.verb === 'proxy.fence.clear') {
|
||||
this.#human(s);
|
||||
if (!this.#proxyFenceClear) fail('not-configured');
|
||||
const args = request.args ?? {};
|
||||
keys(args, ['evidence'], ['evidence']);
|
||||
return Promise.resolve().then(() => this.#proxyFenceClear(args)).then(result => {
|
||||
if (!object(result) || typeof result.cleared !== 'boolean') fail('proxy-down');
|
||||
this.#store.transaction(() => this.#event(s, 'action.allowed', { call: 'proxy.fence.clear', cleared: result.cleared, pid: result.evidence?.after?.pid ?? null }, null));
|
||||
return result;
|
||||
}).catch(error => { throw this.#refused(s, new BusError(/^proxy-[a-z-]+$/.test(error.code ?? '') ? error.code : 'proxy-down'), { verb: request.verb }); });
|
||||
}
|
||||
// FED R3 (design 3.5, 3.7 trigger 1): a user reaches the collaborator list only, and each
|
||||
// request reads the access file again, so a removed or expired grant refuses the next one.
|
||||
let grants = null;
|
||||
@@ -5991,19 +6006,24 @@ export class Broker {
|
||||
if (request.verb === 'proxy.init') {
|
||||
this.#human(s);
|
||||
if (!this.#proxyInit) fail('not-configured');
|
||||
keys(args, ['key', 'port', 'rotateGraceMinutes', 'firstOutputSeconds'], ['key']);
|
||||
if (!validManagementKey(args.key)) fail('proxy-key-invalid');
|
||||
keys(args, ['key', 'generate', 'port', 'rotateGraceMinutes', 'firstOutputSeconds']);
|
||||
// #1873: exactly one of a key or generate:true. A generated key is made here, stored by
|
||||
// init where a typed key goes, and never returned, logged or put in an event.
|
||||
if (Object.hasOwn(args, 'generate') ? args.generate !== true || Object.hasOwn(args, 'key') : !Object.hasOwn(args, 'key')) fail('invalid-request');
|
||||
if (!Object.hasOwn(args, 'generate') && !validManagementKey(args.key)) fail('proxy-key-invalid');
|
||||
if (Object.hasOwn(args, 'port') && (!Number.isInteger(args.port) || args.port < 1024 || args.port > 65535 || args.port === 8317)) fail('proxy-config-invalid');
|
||||
try { proxyTiming(args); } catch (error) { fail(error.code); }
|
||||
// Async initialization is outside the store transaction. Refusals retain codes only.
|
||||
return Promise.resolve().then(() => this.#proxyInit(args)).then(result => {
|
||||
const { generate, ...given } = args;
|
||||
const initArgs = generate ? { ...given, key: randomBytes(48).toString('base64url') } : given;
|
||||
return Promise.resolve().then(() => this.#proxyInit(initArgs)).then(result => {
|
||||
if (!object(result) || Object.keys(result).some(k => !['binary', 'port', 'rotateGraceMinutes', 'firstOutputSeconds'].includes(k))
|
||||
|| !Number.isInteger(result.port) || result.port < 1024 || result.port > 65535 || result.port === 8317
|
||||
|| typeof result.binary !== 'string' || !result.binary.startsWith('/')) fail('proxy-init-failed');
|
||||
try { proxyTiming(result); } catch (error) { fail(error.code); }
|
||||
this.#store.transaction(() => this.#event(s, 'action.allowed', { call: 'proxy.init', port: result.port }, null));
|
||||
this.#store.transaction(() => this.#event(s, 'action.allowed', { call: 'proxy.init', port: result.port, ...(generate ? { generated: true } : {}) }, null));
|
||||
return result;
|
||||
}).catch(error => { throw this.#refused(s, new BusError(/^proxy-[a-z-]+$/.test(error.code ?? '') ? error.code : 'proxy-init-failed'), request); });
|
||||
}).catch(error => { throw this.#refused(s, new BusError(/^proxy-[a-z-]+$/.test(error.code ?? '') ? error.code : 'proxy-init-failed'), { verb: request.verb }); });
|
||||
}
|
||||
this.#secretCheck(args);
|
||||
if (request.verb === 'plugin.tool') {
|
||||
@@ -6488,7 +6508,9 @@ export class Broker {
|
||||
if(!['hire','change','fire'].includes(input?.op))fail('invalid-request');
|
||||
if(this.#classification(s,input.op==='fire'?'role.fire':'role.hire')!=='within-role')fail('not-authorized');
|
||||
};
|
||||
if(immediate) { const c=this.#hireCaller(s); if(!c.rights.includes('org.hire'))fail('right-required'); }
|
||||
// #1873 CB1: a copy credential binding is owner-only. A person raising one in one step must be
|
||||
// the organization's owner; a member with org.hire is not.
|
||||
if(immediate) { const c=this.#hireCaller(s); if(!c.rights.includes('org.hire'))fail('right-required'); if(input?.binding!==undefined && c.owner!==true)fail('human-required'); }
|
||||
else chiefAllowed();
|
||||
if(!this.#hires)fail('not-configured');
|
||||
const did=randomUUID(), at=this.#now(), expiresAt=new Date(Date.parse(at)+7*86400000).toISOString();
|
||||
@@ -6497,12 +6519,14 @@ export class Broker {
|
||||
if(!immediate)chiefAllowed();
|
||||
else {
|
||||
const caller=this.#hireCaller(s);
|
||||
if(((p.flagged.length || p.credentialLines.length) && !caller.owner) || (p.level==='root' && !caller.instanceOwner))fail('hire-owner-required');
|
||||
if(p.binding && caller.owner!==true)fail('human-required');
|
||||
if(!p.binding && (((p.flagged.length || p.credentialLines.length) && !caller.owner) || (p.level==='root' && !caller.instanceOwner)))fail('hire-owner-required');
|
||||
}
|
||||
const action=`role.${p.op}`;
|
||||
const level=decisionAutonomy('authority',b.autonomy);
|
||||
let route='human';
|
||||
if(!immediate && level!=='in the loop' && p.level!=='root' && !p.flagged.length && !p.credentialLines.length) {
|
||||
// A binding always goes to the owner; no autonomy level moves it (design section 4).
|
||||
if(!immediate && !p.binding && level!=='in the loop' && p.level!=='root' && !p.flagged.length && !p.credentialLines.length) {
|
||||
if(level==='out of the loop')fail('authority-limits-unavailable');
|
||||
if(!this.#budgetCovered(this.#business(s.business)))fail('autonomy-precondition');
|
||||
route=b.autonomy?.authorityResolver;
|
||||
@@ -6512,7 +6536,7 @@ export class Broker {
|
||||
this.#store.transaction(()=>{
|
||||
const raiser=immediate?'human':s.role;
|
||||
this.#store.run('INSERT INTO decisions(id,at,business,project,raised_by_role,raised_by_run,class,action,route_to,question,options,recommendation,blocking) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)',
|
||||
did,at,s.business,p.project??null,raiser,immediate?'human-console':s.run,'gated',action,route,`${p.op} ${p.name} at ${p.level} level?`,JSON.stringify([{key:'yes',text:'Approve'},{key:'no',text:'Deny'}]),'no',0);
|
||||
did,at,s.business,p.project??null,raiser,immediate?'human-console':s.run,'gated',action,route,p.binding?`${p.binding.after===null?'unbind':'bind'} credentials for copy ${p.binding.instance}?`:`${p.op} ${p.name} at ${p.level} level?`,JSON.stringify([{key:'yes',text:'Approve'},{key:'no',text:'Deny'}]),'no',0);
|
||||
this.#event(s,'action.allowed',{operation:'decision.raise',decision:did,action,approvalChoice:'yes',target:p.name,hire:p,autonomyLevel:level},did);
|
||||
});
|
||||
if(immediate) return this.#hireConsole(s,'hire.resolve.console',{id:did,choice:'yes',digest:p.digest});
|
||||
@@ -6531,6 +6555,7 @@ export class Broker {
|
||||
const validate=()=>{
|
||||
this.#agent(s);
|
||||
if(d.route_to!==s.role || d.raised_by_role===s.role || resolverSubject(this.#business(s.business),s.role,p?.name))fail('not-resolver');
|
||||
if(p?.binding)fail('human-required');
|
||||
if(!p || p.level==='root' || p.flagged.length || p.credentialLines.length)fail('hire-owner-required');
|
||||
if(this.#closed(d.id))fail('decision-closed');
|
||||
if(this.#expireHire(s,d,p))fail('decision-expired');
|
||||
@@ -6581,6 +6606,7 @@ export class Broker {
|
||||
if(closed)fail(closed.by==='expiry'?'decision-expired':'decision-closed');
|
||||
if(this.#expireHire(s,d,p))fail('decision-expired');
|
||||
if(!caller.rights.includes('org.hire'))fail('right-required');
|
||||
if(p.binding && caller.owner!==true)fail('human-required');
|
||||
if(a.adjust!==undefined) {
|
||||
if(!this.#hires?.preview)fail('not-configured');
|
||||
p=await this.#hires.preview(s.business,d.id,{...caller,adjust:a.adjust});
|
||||
|
||||
@@ -98,16 +98,19 @@ export function createProxyLock({ stateDir, identity, waitMs = 5000 }) {
|
||||
fs.renameSync(temp, generationFile); syncDir(stateDir);
|
||||
fs.unlinkSync(fenceFile); syncDir(stateDir);
|
||||
}
|
||||
async function clear({ restarted, read }) {
|
||||
// `verify(previous)` (optional) re-proves a restart against the fence it would clear, inside
|
||||
// the lock, and returns the evidence the receipt records.
|
||||
async function clear({ restarted, read, verify }) {
|
||||
if (restarted !== true) fail('proxy-restart-required');
|
||||
return run(async () => {
|
||||
const previous = status();
|
||||
const evidence = verify ? await verify(previous) : undefined;
|
||||
await read();
|
||||
const fd = fs.openSync(receiptFile, 'a', 0o600);
|
||||
try { fs.writeFileSync(fd, JSON.stringify({ op: 'fence.clear', time: new Date().toISOString(), previous }) + '\n'); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
|
||||
try { fs.writeFileSync(fd, JSON.stringify({ op: 'fence.clear', time: new Date().toISOString(), previous, ...(evidence === undefined ? {} : { evidence }) }) + '\n'); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
|
||||
syncDir(stateDir);
|
||||
if (previous) confirm();
|
||||
return { cleared: Boolean(previous) };
|
||||
return { cleared: Boolean(previous), ...(evidence === undefined ? {} : { evidence }) };
|
||||
}, { allowUncertain: true });
|
||||
}
|
||||
async function run(operation, { allowUncertain = false } = {}) {
|
||||
|
||||
@@ -16,6 +16,13 @@ group("confirmation-required", "R5/R12: person confirms the arbiter resolution",
|
||||
group("decision-reversed reverse-window-closed", "R2/R12: one-use reverse within the owner's window", "Inspect the reversal trail. After the window, raise an ordinary new decision through the normal approval path; never replay the old approval.");
|
||||
group("definition-shared hire-resolution-required hire-owner-required decision-expired right-required", "R5/R8/R9: digest-bound hire approval by a permitted person", "Use the hire Console path with org.hire. Flagged, credential and root decisions need their owner; expired decisions need a fresh proposal.");
|
||||
group("root-fire-shared", "R3/R8: root fire must revoke every copy's grants", "Other organizations may retain running copies and grants. Ask the owner to coordinate revocation across those organizations before removing the root agent.");
|
||||
// #1873 CB1: copy credential bindings (design sections 4 and 6). Approval is owner-only; nothing here
|
||||
// names a token, a slot path or a file path.
|
||||
group("organization-bindings-protected", "R1/R8: copy bindings change only through an owner-approved decision", "Ask the chief to raise a binding decision for the copy; only the organization owner approves it. No other write path changes copyBindings.");
|
||||
group("credential-binding-changed credential-role-changed credential-definition-unpinned", "R1/R8: binding approval pinned to the current copy, definition and generation", "Read the copy's current binding and definition, then ask the chief to raise a fresh binding decision for the owner; an old approval never carries over.");
|
||||
group("credential-scope-exceeded", "R1/R8: effective access is the role intersected with the approval", "Narrow the requested scopes and projects to what the copy's role and admitted projects allow, or change the role through its own decision first.");
|
||||
group("credential-unbound", "R1/R8: launch needs an approved, verified binding", "Ask the chief to raise a binding decision for the missing service; the owner approves it. A pending binding is not usable until verification exists.");
|
||||
group("copy-unavailable copy-refused", "R3/R8: one active, unambiguous copy identity", "Read the organization's copies. Ask the owner to resolve the definition collision or restore the copy's source before raising a binding decision.");
|
||||
group("copy-stale", "R3: root policy changed since this bus booted", "Ask the owner to restart the organization bus to load the approved root policy, then launch a fresh copy.");
|
||||
group("root-policy-unreadable", "R3: root policy evidence must be readable", "Ask the owner to inspect root agent receipt metadata and restart the bus after repairing the read failure.");
|
||||
group("plugin-disabled", "R3/R6/R10: installed, pinned and enabled plugin capabilities", "Ask the instance owner to verify the installed plugin and the organization owner to enable it. A replacement of an already loaded package needs a host restart; then relaunch the agent.");
|
||||
@@ -50,6 +57,18 @@ group(
|
||||
"R1/R3: owner-managed model and provider configuration",
|
||||
"Ask the owner to inspect the provider configuration through its supported controls; never put keys or callback contents in messages."
|
||||
);
|
||||
// #1873: proxy setup from the Console page and `mosaic proxy`. Each code has one owner next step.
|
||||
group("proxy-install-missing proxy-fetch-url proxy-fetch-failed proxy-fetch-redirect", "R1: pinned model proxy release", "Fetch the pinned release from the Proxy page or with mosaic proxy fetch. A download or redirect failure is retried as it stands; the release URL is fixed by the pin.");
|
||||
group("proxy-install-invalid proxy-fetch-hash proxy-fetch-archive proxy-fetch-existing", "R1: pinned release hashes must match", "A file does not match the pinned hash. Do not run it. Ask the owner to remove the pinned release directory named in the README and fetch again.");
|
||||
group("proxy-already-initialized proxy-port-in-use proxy-management-key-invalid", "R1/R4: proxy init is exclusive creation", "Init never rewrites proxy.json. Read the Proxy page status; pick a free port, or keep the existing setup and continue from the status's next step.");
|
||||
group("proxy-init-busy proxy-init-lock-held proxy-init-lock-absent", "R4: init lock clears only when provably stale", "Wait for a running init to finish. A stale lock clears from the Proxy page or with mosaic proxy init-lock clear only when the bus is stopped and no proxy.json, key file or proxy dir exists; the refusal lists what is still there.");
|
||||
group("proxy-key-transport", "R1/R6: a pasted key travels only over loopback or HTTPS", "Use the generated key, which never leaves the host, or open the Console over HTTPS or on this machine to paste one.");
|
||||
group("proxy-restart-required proxy-restart-unconfirmed proxy-lock-uncertain", "R4/R6: fence clears only after a confirmed proxy restart", "Restart the proxy from the Proxy page or with mosaic proxy fence clear --restart, so the new process is confirmed on the proxy port, then clear the fence. A fence file with no readable time cannot be proven against: restart the proxy service yourself and run mosaic proxy fence clear --proxy-restarted.");
|
||||
group("proxy-admin-busy", "R4: one proxy setup action at a time", "Wait for the running fetch, init or unit action to finish (the Proxy page status shows it), then retry.");
|
||||
group("proxy-key-mismatch", "R1: a pasted management key is confirmed before init", "Type the same key in both fields, or leave them empty to have the bus generate the key.");
|
||||
group("proxy-lock-busy proxy-lock-invalid", "R4: one proxy key write at a time", "Wait for the running proxy write to finish and retry. An invalid lock file needs the owner to inspect the proxy state directory.");
|
||||
group("proxy-not-configured proxy-unit-usage proxy-unit-exists proxy-unit-dropin proxy-unit-copy proxy-unit-path proxy-unit-missing proxy-unit-unhealthy proxy-unit-failed", "R1/R4: proxy user unit is explicit creation", "Initialize the proxy first, then install the unit from the Proxy page or with mosaic proxy unit install. An existing unit or drop-in is never replaced; remove it by hand. An unhealthy unit: read its journal by unit name and restart it.");
|
||||
group("proxy-client-invalid proxy-client-refused proxy-client-response", "R1: proxy management answers", "Check the proxy unit is running and its port answers on the Proxy page, then retry. Upstream bodies are never shown.");
|
||||
group(
|
||||
"block-recipient block-reply-required block-sender message-recipient-refused message-sender-refused tester-reply-required tester-thread-human user-reply-required user-thread-human",
|
||||
"Role messaging: permitted sender, recipient and reply context",
|
||||
|
||||
@@ -35,6 +35,7 @@ import { createModelCatalogue } from './model-catalogue.mjs';
|
||||
import { sha256 } from '../../model-proxy/src/common.mjs';
|
||||
import { initializeProxy } from '../../model-proxy/src/settings.mjs';
|
||||
import { createProxyLock } from './proxy-lock.mjs';
|
||||
import { verifyRestart } from '../../model-proxy/src/admin.mjs';
|
||||
import { ModelKeys, modelOrganization, stopModelRuns, MODEL_STOP_TIMEOUT_MS } from './model-keys.mjs';
|
||||
import { instanceSettings } from '../../organization/src/instance-settings.mjs';
|
||||
import { organizationWriter, OrganizationError, setReplyLimits } from '../../organization/src/organization.mjs';
|
||||
@@ -334,7 +335,7 @@ export async function startBroker({
|
||||
|| (Object.hasOwn(librarySync, 'intervalMs') && !(Number.isSafeInteger(librarySync.intervalMs) && librarySync.intervalMs > 0))
|
||||
|| (Object.hasOwn(librarySync, 'configDir') && typeof librarySync.configDir !== 'string')))
|
||||
throw new BusError('invalid-host-request');
|
||||
let modelProxyClient = null, proxyPage = null, proxyUsage = null, usageTimer = null, proxyLock = null;
|
||||
let modelProxyClient = null, proxyPage = null, proxyUsage = null, usageTimer = null, proxyLock = null, proxyPort = null;
|
||||
let modelKeys = null, modelTimer = null, modelSweep = null, modelCatalogues = null;
|
||||
const modelRuns = new Map();
|
||||
let store, credentials, server, adapter, processing, syncTimer = null, syncPass = null, accessTimer = null, requestTimer = null;
|
||||
@@ -383,6 +384,7 @@ export async function startBroker({
|
||||
proxyLock = createProxyLock({ stateDir: join(instanceDataRoot, 'proxy'), identity: `${instanceConfigDir}:${proxy.port}` });
|
||||
}
|
||||
modelProxyClient = await loadManagementClient({ configDir: org ? instanceConfigDir : organization.dir, proxy, receipt: proxyReceipt });
|
||||
proxyPort = proxy.port;
|
||||
}
|
||||
|
||||
// Row 234 (#1740, r6 X4 third bullet): each host grant record gives its agent a store reference to
|
||||
@@ -912,7 +914,9 @@ export async function startBroker({
|
||||
autonomy: organization ? autonomyHost({writer:organizationWriter({dir:organization.dir,rolesDir:organization.rolesDir,dataRoot:instanceRoot??dataRoot}),dataRoot,business:id=>businesses[id],countsAccount:levels.countsAccount,hires:{reverse:(...args)=>broker.hireReverse(...args)},reload,processing}) : null,
|
||||
hires: organization ? (()=>{
|
||||
const service=hireDecisions({writer:organizationWriter({dir:organization.dir,rolesDir:organization.rolesDir,dataRoot:instanceRoot??dataRoot}),rolesDir:organization.rolesDir,configDir:organization.dir,dataRoot,
|
||||
system:organization.system,levels,catalogueFor:async(id,doc)=>doc.plugins?.['model-provider']?.length?catalogueFor(id,{fresh:true}):null,assertClean:value=>credentials.assertClean(value),scanDefinition:(name,entry)=>scanDefinitions({[name]:entry},value=>credentials.assertClean(value)),registryFor:doc=>organizationPlugins(instanceRoot??dataRoot,doc.plugins).registry});
|
||||
system:organization.system,levels,catalogueFor:async(id,doc)=>doc.plugins?.['model-provider']?.length?catalogueFor(id,{fresh:true}):null,assertClean:value=>credentials.assertClean(value),scanDefinition:(name,entry)=>scanDefinitions({[name]:entry},value=>credentials.assertClean(value)),registryFor:doc=>organizationPlugins(instanceRoot??dataRoot,doc.plugins).registry,
|
||||
// #1873 CB1: a copy binding names a copy this bus booted with; no snapshot, no binding.
|
||||
copies:id=>copies?.org===id?copies:null});
|
||||
return authorityHost(service,{modelKeys,reload,proxyOrganization});
|
||||
})() : null,
|
||||
roleFile: organization ? (name) => loadRole(organization.rolesDir, name) : null,
|
||||
@@ -927,9 +931,18 @@ export async function startBroker({
|
||||
proxyInit: organization ? async args => {
|
||||
const settings = await initializeProxy({ configDir: organization.dir, dataRoot: instanceRoot ?? dataRoot, ...args });
|
||||
modelProxyClient = await loadManagementClient({ configDir: organization.dir, proxy: settings, receipt: proxyReceipt });
|
||||
proxyPort = settings.port;
|
||||
// #1873: an org bus that initializes the shared proxy also holds its write lock from now on.
|
||||
if (org && !proxyLock) proxyLock = createProxyLock({ stateDir: join(instanceDataRoot, 'proxy'), identity: `${instanceConfigDir}:${settings.port}` });
|
||||
configureProxyPage();
|
||||
return settings;
|
||||
} : null,
|
||||
// #1873: clear the uncertain-write fence only on restart evidence this host re-proves: the
|
||||
// process on the proxy port is new, alive and started after the fence was set.
|
||||
proxyFenceClear: organization ? async ({ evidence }) => {
|
||||
if (!proxyLock || !modelProxyClient) throw new BusError('not-configured');
|
||||
return proxyLock.clear({ restarted: true, read: () => modelProxyClient.getKeys(), verify: previous => verifyRestart(evidence, { fence: previous, port: proxyPort }) });
|
||||
} : null,
|
||||
credentialVerbs: organization
|
||||
? credentialVerbs({ credentials, writer: organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir, dataRoot:instanceRoot ?? dataRoot }),
|
||||
organizations: () => Object.keys(businesses), boot: copies?.definitions ? { org: copies.org, definitions: copies.definitions } : null })
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
// #1873 CB1: a copy credential binding through the broker. Only the chief raises one; only the
|
||||
// organization owner resolves it; no autonomy level, member right, resolver agent, tester or reader
|
||||
// moves it. The written record stays pending, so nothing launches on it in CB1.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Broker } from '../src/broker.mjs';
|
||||
import { Store } from '../src/store.mjs';
|
||||
import { REFUSALS } from '../src/refusals.mjs';
|
||||
import { hireDecisions } from '../../organization/src/hire-decisions.mjs';
|
||||
import { organizationWriter } from '../../organization/src/organization.mjs';
|
||||
import { loadBusiness } from '../../organization/src/organization-config.mjs';
|
||||
import { checkRootAgents } from '../../organization/src/agent-levels.mjs';
|
||||
import { materializeCopies } from '../../organization/src/agent-copies.mjs';
|
||||
import { activateOrg, reserveOrg } from '../../organization/src/org-registry.mjs';
|
||||
import { businessDoc, rolesCopy, writeJson } from '../../organization/tests/helpers.mjs';
|
||||
|
||||
const SUBJECT = { provider: 'test', iss: 'https://issuer.test', sub: 'owner' };
|
||||
const GITEA = { gitea: { scopes: ['read:repository'], projects: ['stack'] } };
|
||||
const SECRET = 'sk-test-NOT-A-SECRET-1234';
|
||||
|
||||
async function fixture(t, edit = d => d) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'cb1-bus-')), rolesDir = rolesCopy(root);
|
||||
const base = { ...businessDoc(root, 'demo'), plugins: { harness: ['pi'] }, vars: { 'bus.tester': true } };
|
||||
base.projects.stack.agents = { checker: { definition: 'reviewer' } };
|
||||
const doc = edit(base);
|
||||
const file = writeJson(join(root, 'businesses/demo.json'), doc);
|
||||
const storeRoot = join(root, 'data'); mkdirSync(storeRoot, { mode: 0o700 });
|
||||
const ctx = { configDir: root, dataRoot: storeRoot, instance: 'test' };
|
||||
await reserveOrg(ctx, 'demo'); await activateOrg(ctx, 'demo');
|
||||
const snapshot = materializeCopies({ orgId: 'demo', doc, root: { state: 'ok', ...checkRootAgents({ agentsVersion: 1, agents: { rev: { definition: 'reviewer' } } }, { rolesDir }) }, rolesDir });
|
||||
const writer = organizationWriter({ dir: root, rolesDir }), store = new Store(storeRoot);
|
||||
const service = hireDecisions({ writer, rolesDir, dataRoot: storeRoot, configDir: root, assertClean: v => { if (JSON.stringify(v).includes(SECRET)) throw Object.assign(new Error('x'), { code: 'definition-holds-credential' }); }, copies: id => snapshot.org === id ? snapshot : null });
|
||||
const loaded = () => { const d = loadBusiness('demo', { dir: root, rolesDir }); return { ...d, roles: Object.fromEntries(Object.entries(d.roles).map(([n, e]) => [n, { ...d.definitions[e.definition], ...e }])) }; };
|
||||
let writes = 0;
|
||||
const b = new Broker({ store, businesses: { demo: loaded() }, hires: { ...service, apply: async (...args) => { writes++; const r = await service.apply(...args); b.reloadBusiness('demo', loaded()); return r; } } });
|
||||
const owner = b.bindConsole({ business: 'demo', human: 'jason', subject: SUBJECT });
|
||||
const member = (rights, extra = {}) => b.bindHireConsole({ business: 'demo', actor: 'member', subject: { ...SUBJECT, sub: 'member' }, rights, ...extra });
|
||||
const caps = { chief: b.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' }), coder: b.bindLaunch({ business: 'demo', role: 'coder', run: 'coder-run', harness: 'pi' }) };
|
||||
b.request(caps.chief, { verb: 'role.claim' }); b.request(caps.coder, { verb: 'role.claim' });
|
||||
const call = (cap, verb, args = {}) => Promise.resolve().then(() => b.request(cap, { verb, args }));
|
||||
const bind = (extra = {}) => ({ op: 'change', level: 'root', name: 'rev', reason: 'Bind the reviewer copy', binding: { services: GITEA }, ...extra });
|
||||
const raise = extra => call(caps.chief, 'hire.raise', bind(extra));
|
||||
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const bindings = () => JSON.parse(readFileSync(file, 'utf8')).copyBindings ?? {};
|
||||
return { root, rolesDir, file, b, store, writer, service, owner, member, caps, call, bind, raise, bindings, writes: () => writes, loaded };
|
||||
}
|
||||
|
||||
test('CB1 bus: only the chief raises a binding; the decision names the copy and holds no token or path', async t => {
|
||||
const f = await fixture(t);
|
||||
await assert.rejects(f.call(f.caps.coder, 'hire.raise', f.bind()), { code: 'role-not-chief' });
|
||||
assert.equal(f.store.get('SELECT count(*) n FROM decisions').n, 0);
|
||||
const d = await f.raise();
|
||||
assert.equal(d.route_to, 'human');
|
||||
assert.equal(d.question, 'bind credentials for copy rev?');
|
||||
assert.equal(d.hire.binding.instance, 'rev');
|
||||
const text = JSON.stringify(d);
|
||||
for (const bad of [SECRET, f.root, 'token']) assert.ok(!text.includes(bad), bad);
|
||||
assert.equal(f.writes(), 0);
|
||||
assert.deepEqual(f.bindings(), {});
|
||||
});
|
||||
|
||||
test('CB1 bus: no autonomy level routes a binding away from the owner', async t => {
|
||||
for (const authority of ['near the loop', 'out of the loop']) {
|
||||
const f = await fixture(t, doc => {
|
||||
doc.definitions = { resolver: { rev: 1, role: { tools: ['read'], network: 'none', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.authority'], crossRole: [] }, credentials: [] }, instructions: 'Resolve independent decisions.' } };
|
||||
doc.roles.cto = { definition: 'org:resolver' };
|
||||
doc.plugins['limit-check'] = ['token-limit']; doc.launch.limits = { tokens: { providers: { zai: { limit: 100000, since: '2026-01-01T00:00:00.000Z' } } } };
|
||||
doc.autonomy = { authority, authorityResolver: 'cto', notices: true };
|
||||
return doc;
|
||||
});
|
||||
const live = f.loaded(); live.roles.cto.authority = { withinRole: ['decision.resolve.authority'], crossRole: [] }; f.b.reloadBusiness('demo', live);
|
||||
const resolver = f.b.bindLaunch({ business: 'demo', role: 'cto', run: 'cto-run', harness: 'pi' }); f.b.request(resolver, { verb: 'role.claim' });
|
||||
const d = await f.raise();
|
||||
assert.equal(d.route_to, 'human', authority);
|
||||
// The authority resolver cannot take it, nor can the chief through the agent verb.
|
||||
await assert.rejects(f.call(resolver, 'decision.resolve', { id: d.id, choice: 'yes' }), { code: 'hire-resolution-required' }, authority);
|
||||
await assert.rejects(f.call(f.caps.chief, 'decision.resolve', { id: d.id, choice: 'yes' }), { code: 'hire-resolution-required' }, authority);
|
||||
assert.equal(f.writes(), 0, authority);
|
||||
assert.deepEqual(f.bindings(), {}, authority);
|
||||
// A project-level unbind is not root; it lists the lines it removes and still goes to the owner.
|
||||
if (authority === 'near the loop') {
|
||||
await f.call(f.owner, 'hire.start.console', { op: 'change', level: 'project', project: 'stack', name: 'checker', reason: 'Bind', binding: { services: GITEA } });
|
||||
const u = await f.raise({ level: 'project', project: 'stack', name: 'checker', binding: { services: null } });
|
||||
assert.equal(u.hire.level, 'project');
|
||||
assert.equal(u.route_to, 'human');
|
||||
await assert.rejects(f.call(resolver, 'decision.resolve', { id: u.id, choice: 'yes' }), { code: 'hire-resolution-required' });
|
||||
assert.equal(f.bindings()['checker.stack'].state, 'pending');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('CB1 bus: members with org.hire or decide, the tester and a reader cannot resolve; the owner can, once', async t => {
|
||||
const f = await fixture(t);
|
||||
const d = await f.raise();
|
||||
const digest = d.hire.digest;
|
||||
await assert.rejects(f.call(f.member(['org.hire']), 'hire.resolve.console', { id: d.id, choice: 'yes', digest }), { code: 'human-required' });
|
||||
await assert.rejects(f.call(f.member(['org.hire', 'decide'], { instanceOwner: true }), 'hire.resolve.console', { id: d.id, choice: 'yes', digest }), { code: 'human-required' });
|
||||
await assert.rejects(f.call(f.member(['decide']), 'hire.resolve.console', { id: d.id, choice: 'yes', digest }), { code: 'right-required' });
|
||||
await assert.rejects(f.call(f.b.bindTester({ business: 'demo' }), 'hire.resolve.console', { id: d.id, choice: 'yes', digest }), { code: 'human-required' });
|
||||
await assert.rejects(f.call(f.b.bindReader({ business: 'demo' }), 'hire.resolve.console', { id: d.id, choice: 'yes', digest }), { code: 'human-required' });
|
||||
await assert.rejects(f.call(f.owner, 'decision.resolve', { id: d.id, choice: 'yes' }), { code: 'human-required' });
|
||||
assert.equal(f.writes(), 0);
|
||||
assert.deepEqual(f.bindings(), {});
|
||||
await assert.rejects(f.call(f.owner, 'hire.resolve.console', { id: d.id, choice: 'yes', digest: '0'.repeat(64) }), { code: 'decision-stale' });
|
||||
await f.call(f.owner, 'hire.resolve.console', { id: d.id, choice: 'yes', digest });
|
||||
assert.equal(f.writes(), 1);
|
||||
const rec = f.bindings().rev;
|
||||
assert.equal(rec.state, 'pending');
|
||||
assert.equal(rec.decision, d.id);
|
||||
await assert.rejects(f.call(f.owner, 'hire.resolve.console', { id: d.id, choice: 'yes', digest }), { code: 'decision-closed' });
|
||||
assert.equal(f.writes(), 1);
|
||||
});
|
||||
|
||||
test('CB1 bus: a one-step Console binding is owner-only; a member is refused before any decision is saved', async t => {
|
||||
const f = await fixture(t);
|
||||
await assert.rejects(f.call(f.member(['org.hire'], { instanceOwner: true }), 'hire.start.console', f.bind()), { code: 'human-required' });
|
||||
assert.equal(f.store.get('SELECT count(*) n FROM decisions').n, 0);
|
||||
await f.call(f.owner, 'hire.start.console', f.bind());
|
||||
assert.equal(f.bindings().rev.state, 'pending');
|
||||
// Unbind is owner-only too.
|
||||
await assert.rejects(f.call(f.member(['org.hire']), 'hire.start.console', f.bind({ binding: { services: null } })), { code: 'human-required' });
|
||||
await f.call(f.owner, 'hire.start.console', f.bind({ binding: { services: null } }));
|
||||
assert.equal(f.bindings().rev.state, 'unbound');
|
||||
});
|
||||
|
||||
test('CB1 bus: every new refusal has a table entry with a next step', () => {
|
||||
for (const code of ['organization-bindings-protected', 'credential-binding-changed', 'credential-role-changed', 'credential-definition-unpinned', 'credential-scope-exceeded', 'credential-unbound', 'copy-unavailable', 'copy-refused']) {
|
||||
assert.ok(Object.hasOwn(REFUSALS, code), code);
|
||||
assert.ok(REFUSALS[code].nextStep?.length > 20, code);
|
||||
assert.ok(!/(^|[\s("'`])(\/|~\/)/.test(REFUSALS[code].nextStep), code);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
// #1873 bus side of the Console proxy setup: proxy.init with a bus-generated key, the owner-only
|
||||
// proxy.fence.clear verb, and a lock clear that re-proves restart evidence before it clears.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Broker } from '../src/broker.mjs';
|
||||
import { Store } from '../src/store.mjs';
|
||||
import { createProxyLock } from '../src/proxy-lock.mjs';
|
||||
import { REFUSALS } from '../src/refusals.mjs';
|
||||
import { validManagementKey } from '../../model-proxy/src/common.mjs';
|
||||
|
||||
const role = { authority: { withinRole: ['message.send'], crossRole: [] } };
|
||||
const code = name => error => error.code === name;
|
||||
function fixture(t, { proxyInit = null, proxyFenceClear = null } = {}) {
|
||||
const store = new Store(fs.mkdtempSync(join(tmpdir(), 'ps-bus-')));
|
||||
t.after(() => store.close());
|
||||
const b = new Broker({ store, proxyInit, proxyFenceClear, businesses: { demo: { id: 'demo', human: 'owner', arbiters: { technical: 'chief', delivery: 'chief' }, vars: { 'bus.tester': true }, roles: { chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: role } } } });
|
||||
return {
|
||||
store, b,
|
||||
human: b.bindHuman({ business: 'demo', human: 'owner', via: 'cli', outsideAgent: true }),
|
||||
tester: b.bindTester({ business: 'demo' }), reader: b.bindReader({ business: 'demo' }),
|
||||
agent: b.bindLaunch({ business: 'demo', role: 'coder', run: 'ps-run', harness: 'pi' }),
|
||||
};
|
||||
}
|
||||
const events = store => JSON.stringify(store.all('SELECT body FROM events'));
|
||||
const bodies = store => store.all('SELECT body FROM events').map(r => JSON.parse(r.body));
|
||||
|
||||
test('proxy.init generate:true makes a valid key on the bus, hands it only to init, and never returns or records it', async t => {
|
||||
const seen = [];
|
||||
const f = fixture(t, { proxyInit: async args => { seen.push(args); return { port: args.port ?? 18432, binary: '/fixture' }; } });
|
||||
const result = await f.b.request(f.human, { verb: 'proxy.init', args: { generate: true, port: 18440, rotateGraceMinutes: 5 } });
|
||||
assert.deepEqual(result, { port: 18440, binary: '/fixture' });
|
||||
assert.equal(seen.length, 1);
|
||||
assert.deepEqual(Object.keys(seen[0]).sort(), ['key', 'port', 'rotateGraceMinutes']);
|
||||
assert.equal(validManagementKey(seen[0].key), true);
|
||||
assert.match(seen[0].key, /^[A-Za-z0-9_-]{64}$/, '48 CSPRNG bytes as base64url');
|
||||
assert.equal(JSON.stringify(result).includes(seen[0].key), false);
|
||||
assert.equal(events(f.store).includes(seen[0].key), false);
|
||||
assert.deepEqual(bodies(f.store).filter(b => b.call === 'proxy.init'), [{ call: 'proxy.init', port: 18440, generated: true }]);
|
||||
await f.b.request(f.human, { verb: 'proxy.init', args: { generate: true } });
|
||||
assert.notEqual(seen[1].key, seen[0].key, 'each init generates a fresh key');
|
||||
});
|
||||
|
||||
test('proxy.init generate refuses agents and testers, and refuses a key with generate or generate other than true', async t => {
|
||||
let calls = 0;
|
||||
const f = fixture(t, { proxyInit: async () => { calls++; return { port: 18432, binary: '/fixture' }; } });
|
||||
for (const cap of [f.agent, f.tester]) assert.throws(() => f.b.request(cap, { verb: 'proxy.init', args: { generate: true } }), code('human-required'));
|
||||
assert.throws(() => f.b.request(f.reader, { verb: 'proxy.init', args: { generate: true } }), code('read-only'));
|
||||
for (const args of [{ generate: true, key: 'ps-sentinel-key-0001' }, { generate: false }, { generate: 'yes' }, {}])
|
||||
assert.throws(() => f.b.request(f.human, { verb: 'proxy.init', args }), code('invalid-request'), JSON.stringify(args));
|
||||
assert.equal(calls, 0);
|
||||
assert.equal(events(f.store).includes('ps-sentinel-key-0001'), false);
|
||||
// The same verb the refusals guard does run for the owner.
|
||||
await f.b.request(f.human, { verb: 'proxy.init', args: { generate: true } });
|
||||
assert.equal(calls, 1);
|
||||
});
|
||||
|
||||
test('proxy.fence.clear is owner-only, passes the evidence through, and records only the pid', async t => {
|
||||
const seen = [];
|
||||
const evidence = { unit: 'mosaic-proxy.service', confirmed: true, before: { pid: 1 }, after: { pid: 2 }, observedAt: new Date(0).toISOString() };
|
||||
const f = fixture(t, { proxyFenceClear: async args => { seen.push(args); return { cleared: true, evidence: { after: { pid: 2 } } }; } });
|
||||
for (const cap of [f.agent, f.tester]) assert.throws(() => f.b.request(cap, { verb: 'proxy.fence.clear', args: { evidence } }), code('human-required'));
|
||||
assert.throws(() => f.b.request(f.reader, { verb: 'proxy.fence.clear', args: { evidence } }), code('read-only'));
|
||||
assert.throws(() => f.b.request(f.human, { verb: 'proxy.fence.clear', args: {} }), code('invalid-request'));
|
||||
assert.throws(() => f.b.request(f.human, { verb: 'proxy.fence.clear', args: { evidence, restarted: true } }), code('invalid-request'));
|
||||
assert.equal(seen.length, 0);
|
||||
assert.deepEqual(await f.b.request(f.human, { verb: 'proxy.fence.clear', args: { evidence } }), { cleared: true, evidence: { after: { pid: 2 } } });
|
||||
assert.deepEqual(seen, [{ evidence }]);
|
||||
assert.deepEqual(bodies(f.store).filter(b => b.call === 'proxy.fence.clear'), [{ call: 'proxy.fence.clear', cleared: true, pid: 2 }]);
|
||||
});
|
||||
|
||||
test('proxy.fence.clear maps a refusal to its proxy code and a missing runtime to not-configured', async t => {
|
||||
const f = fixture(t, { proxyFenceClear: async () => { throw Object.assign(Error('/home/x detail'), { code: 'proxy-restart-unconfirmed' }); } });
|
||||
await assert.rejects(f.b.request(f.human, { verb: 'proxy.fence.clear', args: { evidence: {} } }), e => e.code === 'proxy-restart-unconfirmed' && !e.message.includes('/home/x'));
|
||||
const g = fixture(t);
|
||||
assert.throws(() => g.b.request(g.human, { verb: 'proxy.fence.clear', args: { evidence: {} } }), code('not-configured'));
|
||||
});
|
||||
|
||||
test('a lock clear with verify: the fence an uncertain write set clears only on proof, and the receipt holds the evidence', async t => {
|
||||
const dir = fs.mkdtempSync(join(tmpdir(), 'ps-lock-'));
|
||||
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
|
||||
const lock = createProxyLock({ stateDir: dir, identity: 'instance-one' });
|
||||
await assert.rejects(lock.run(async () => { lock.fence({ org: 'acme', op: 'apikey.add' }); throw Error('outcome unknown'); }));
|
||||
assert.throws(() => lock.assertCertain(), code('proxy-uncertain'));
|
||||
let read = 0;
|
||||
await assert.rejects(lock.clear({ restarted: true, read: async () => { read++; }, verify: () => { throw Object.assign(Error('no'), { code: 'proxy-restart-unconfirmed' }); } }), code('proxy-restart-unconfirmed'));
|
||||
assert.equal(read, 0, 'an unproved restart never reads or clears');
|
||||
assert.throws(() => lock.assertCertain(), code('proxy-uncertain'));
|
||||
const seenFence = [];
|
||||
const result = await lock.clear({ restarted: true, read: async () => { read++; }, verify: previous => { seenFence.push(previous); return { after: { pid: 42 } }; } });
|
||||
assert.deepEqual(result, { cleared: true, evidence: { after: { pid: 42 } } });
|
||||
assert.equal(seenFence[0].org, 'acme'); assert.equal(seenFence[0].op, 'apikey.add');
|
||||
lock.assertCertain();
|
||||
const receipt = fs.readFileSync(join(dir, 'mosaic-keys.receipts.jsonl'), 'utf8').trim().split('\n').map(JSON.parse).at(-1);
|
||||
assert.deepEqual(receipt.evidence, { after: { pid: 42 } });
|
||||
assert.equal(receipt.previous.org, 'acme');
|
||||
});
|
||||
|
||||
test('every #1873 refusal code has a refusal-table entry with a next step', () => {
|
||||
for (const c of ['proxy-admin-busy', 'proxy-key-transport', 'proxy-key-mismatch', 'proxy-restart-required', 'proxy-restart-unconfirmed', 'proxy-init-lock-held', 'proxy-init-lock-absent', 'proxy-unit-missing', 'proxy-unit-unhealthy', 'proxy-unit-failed', 'proxy-unit-exists', 'proxy-unit-path', 'proxy-unit-usage', 'proxy-install-missing', 'proxy-install-invalid', 'proxy-fetch-hash']) {
|
||||
const entry = REFUSALS[c];
|
||||
assert.ok(entry, c);
|
||||
assert.equal(typeof entry.nextStep, 'string', c);
|
||||
assert.ok(entry.nextStep.length > 20, c);
|
||||
assert.equal(/\/(home|mnt|srv|tmp)\//.test(entry.nextStep), false, `${c} names no absolute path`);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
// Old path of packages/organization/src/copy-bindings.mjs (rename design r2, stage B).
|
||||
export * from "../../organization/src/copy-bindings.mjs";
|
||||
@@ -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
|
||||
|
||||
@@ -63,7 +63,8 @@ export const USAGE = `usage:
|
||||
${LIMITS_USAGE}
|
||||
${PLUGINS_USAGE}
|
||||
${CREDENTIALS_USAGE}
|
||||
mosaic proxy fetch | fence status [--json] | fence clear --proxy-restarted | init [--port N] [--business ID] [--rotate-grace-minutes N] [--first-output-seconds N]
|
||||
mosaic proxy fetch | status [--json] | fence status [--json] | fence clear --proxy-restarted | fence clear --restart [--business ID] | init [--generate-key] [--port N] [--business ID] [--rotate-grace-minutes N] [--first-output-seconds N]
|
||||
mosaic proxy unit render|install|start|stop|restart|status [--json] | init-lock status|clear
|
||||
${HOSTS_USAGE}
|
||||
${GRANTS_USAGE}
|
||||
${ORG_INSTANCES_USAGE}
|
||||
@@ -569,10 +570,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 +597,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 +607,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 +633,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 +722,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`);
|
||||
@@ -778,7 +796,7 @@ export async function main(argv, io = { env: process.env, stdin: process.stdin,
|
||||
}
|
||||
if (verb === "plugins") return pluginsCommand(rest, io, (p) => callFor(p, io, deps));
|
||||
// Row 199 (#1697): issue and rotate read the token on stdin.
|
||||
if (verb === "proxy") return proxyCommand(rest, io, { system: () => deps.system ?? loadSystem({ env: io.env }), callFor: p => callFor(p, io, deps), prompt: deps.promptProxyKey });
|
||||
if (verb === "proxy") return proxyCommand(rest, io, { system: () => deps.system ?? loadSystem({ env: io.env }), callFor: p => callFor(p, io, deps), prompt: deps.promptProxyKey, ...(deps.proxyAdmin ? { admin: deps.proxyAdmin } : {}) });
|
||||
if (verb === "credentials") return credentialsCommand(rest, io, (p) => callFor(p, io, deps));
|
||||
// Row 234 (#1740, r6 E4): the host records.
|
||||
if (verb === "hosts") return hostsCommand(rest, io, (p) => callFor(p, io, deps));
|
||||
|
||||
@@ -7,6 +7,9 @@ import { orgSelector } from './org-selector.mjs';
|
||||
import { refuseInsideAgent } from './transport.mjs';
|
||||
import { fetchPinned } from '../../model-proxy/src/fetch.mjs';
|
||||
import { validManagementKey, proxyTiming } from '../../model-proxy/src/common.mjs';
|
||||
import { createProxyAdmin } from '../../model-proxy/src/admin-control.mjs';
|
||||
import { listOrgs } from '../../organization/src/org-registry.mjs';
|
||||
import { busesStopped, instanceFromConfig } from './proxy-admin-host.mjs';
|
||||
const failure = (code, exitCode = 2) => Object.assign(new CliError(code, exitCode), { code });
|
||||
// A raw terminal suppresses echo; input is never argv, env, readline history or stdout.
|
||||
export function promptProxyKey(io, label) {
|
||||
@@ -34,12 +37,90 @@ export function promptProxyKey(io, label) {
|
||||
io.stdin.setRawMode(true); io.stdin.on('data', data); io.stdin.once('end', end); io.stdin.once('error', failed); io.stdin.resume();
|
||||
});
|
||||
}
|
||||
export async function proxyCommand(args, io, { system, callFor, prompt = promptProxyKey, fetch = fetchPinned }) {
|
||||
// #1873: the setup controller the Console's Proxy page uses, for this instance. `admin` is a
|
||||
// test seam; the default runs proxy-service.sh, systemctl and the pinned fetch for real.
|
||||
function adminFor(io, system, deps) {
|
||||
if (deps.admin) return deps.admin;
|
||||
const config = typeof system === 'function' ? system() : system;
|
||||
const dir = configDir(io.env);
|
||||
return createProxyAdmin({
|
||||
dataRoot: config.dataRoot, configDir: dir, instance: instanceFromConfig(io.env), env: io.env, fetch: deps.fetch ?? fetchPinned,
|
||||
unitDir: deps.unitDir ?? null,
|
||||
buses: () => busesStopped({ dataRoot: config.dataRoot, configDir: dir }),
|
||||
orgs: async () => (await listOrgs({ configDir: dir, dataRoot: config.dataRoot })).filter(org => org.state === 'active').map(org => ({ id: org.id, proxy: 'shared', catalogue: null })),
|
||||
});
|
||||
}
|
||||
const print = (io, value) => io.stdout.write(JSON.stringify(value) + '\n');
|
||||
const hostCode = (error, fallback) => (/^proxy-[a-z-]+$/.test(error?.code ?? '') ? error.code : fallback);
|
||||
|
||||
// `mosaic proxy status | unit ... | init-lock ...`: host facts and actions, labels only.
|
||||
async function setupCommand(verb, rest, io, { system, deps }) {
|
||||
const admin = adminFor(io, system, deps);
|
||||
try {
|
||||
if (verb === 'status') {
|
||||
if (!(rest.length === 0 || (rest.length === 1 && rest[0] === '--json'))) throw failure('proxy-usage', 4);
|
||||
const status = await admin.status();
|
||||
if (rest[0] === '--json') return print(io, status);
|
||||
const { release, init, unit, fence, initLock } = status;
|
||||
io.stdout.write([
|
||||
`release: ${release.fetched ? `${release.version} (panel ${release.panelVersion}) ${release.verified ? 'hashes verified' : `not verified: ${release.problem}`}` : `not fetched (pinned ${release.version})`}`,
|
||||
`initialized: ${init.initialized ? `yes, port ${init.settings?.port}` : init.partial ? `partly (${init.files.filter(f => f.present).map(f => f.label).join(', ')})` : 'no'}`,
|
||||
`unit: ${unit.unit} ${unit.installed ? 'installed' : 'not installed'}, ${unit.active}${unit.pid ? ` pid ${unit.pid}` : ''}${unit.port ? `, port ${unit.port} ${unit.listening ? 'answering' : 'not answering'}` : ''}`,
|
||||
`fence: ${fence.set ? `set${fence.op ? ` after ${fence.op}` : ''}${fence.time ? ` at ${fence.time}` : ''}` : 'clear'}`,
|
||||
`init lock: ${initLock.present ? (initLock.clearable ? 'stale, clearable' : `held: ${initLock.blockers.join('; ')}`) : 'none'}`,
|
||||
`own proxy: ${status.ownProxy.state}`,
|
||||
`next: ${status.next.join(', ')}`,
|
||||
].join('\n') + '\n');
|
||||
return;
|
||||
}
|
||||
if (verb === 'unit') {
|
||||
const [op, ...flags] = rest;
|
||||
if (op === 'render' && flags.length === 0) { io.stdout.write((await admin.render()).text); return; }
|
||||
if (op === 'status' && (flags.length === 0 || (flags.length === 1 && flags[0] === '--json'))) { print(io, (await admin.status()).unit); return; }
|
||||
if (['install', 'start', 'stop', 'restart'].includes(op) && flags.length === 0) { print(io, await admin.unitAction(op)); return; }
|
||||
throw failure('proxy-usage', 4);
|
||||
}
|
||||
if (verb === 'init-lock') {
|
||||
const [op, ...flags] = rest;
|
||||
if (flags.length !== 0) throw failure('proxy-usage', 4);
|
||||
if (op === 'status') { print(io, (await admin.status()).initLock); return; }
|
||||
if (op === 'clear') { print(io, await admin.initLockClear()); return; }
|
||||
throw failure('proxy-usage', 4);
|
||||
}
|
||||
} catch (error) { if (error instanceof CliError) throw error; throw failure(hostCode(error, 'proxy-unit-failed')); }
|
||||
throw failure('proxy-usage', 4);
|
||||
}
|
||||
|
||||
export async function proxyCommand(args, io, { system, callFor, prompt = promptProxyKey, fetch = fetchPinned, ...deps }) {
|
||||
const [verb, ...rest] = args;
|
||||
if (args.includes('--tester')) throw failure('human-required', 3);
|
||||
if (verb === 'init' || verb === 'fetch' || verb === 'fence') {
|
||||
// #1873: every proxy verb, read or write, runs only from a human shell.
|
||||
if (['init', 'fetch', 'fence', 'status', 'unit', 'init-lock'].includes(verb)) {
|
||||
try { refuseInsideAgent(io.env); } catch { throw failure('human-required', 3); }
|
||||
}
|
||||
if (['status', 'unit', 'init-lock'].includes(verb)) return setupCommand(verb, rest, io, { system, deps: { ...deps, fetch } });
|
||||
if (verb === 'fence' && rest[0] === 'clear' && rest.includes('--restart')) {
|
||||
// #1873: restart the proxy unit, then send the restart evidence to the bus, which re-proves it
|
||||
// on the host before it clears the fence. --business/--org names the bus.
|
||||
const parsed = { flags: new Set(), values: {} };
|
||||
const selector = orgSelector(() => failure('proxy-usage', 4));
|
||||
const flags = rest.slice(1);
|
||||
let restart = false;
|
||||
for (let i = 0; i < flags.length; i++) {
|
||||
if (flags[i] === '--restart' && !restart) { restart = true; continue; }
|
||||
if (selector.owns(flags[i])) { selector.take(flags[i], flags[i + 1]); i++; continue; }
|
||||
throw failure('proxy-usage', 4);
|
||||
}
|
||||
if (selector.given) parsed.values['--business'] = selector.value;
|
||||
const call = callFor(parsed);
|
||||
const admin = adminFor(io, system, { ...deps, fetch });
|
||||
try {
|
||||
const restarted = await admin.unitAction('restart');
|
||||
if (restarted.evidence?.confirmed !== true) throw failure('proxy-restart-unconfirmed');
|
||||
print(io, { restart: restarted.receipt, ...(await admin.fenceClear(call)) });
|
||||
return;
|
||||
} catch (error) { if (error instanceof CliError) throw error; throw failure(hostCode(error, 'proxy-down')); }
|
||||
}
|
||||
if (verb === 'fence') {
|
||||
const [action, ...flags] = rest;
|
||||
if (action === 'clear' && flags.length === 0) throw failure('proxy-restart-required');
|
||||
@@ -66,6 +147,7 @@ export async function proxyCommand(args, io, { system, callFor, prompt = promptP
|
||||
for (let i = 0; i < rest.length; i++) {
|
||||
const flag = rest[i];
|
||||
if (verb === 'init' && selector.owns(flag)) { selector.take(flag, rest[i + 1]); i++; continue; }
|
||||
if (verb === 'init' && flag === '--generate-key' && !parsed.flags.has(flag)) { parsed.flags.add(flag); continue; }
|
||||
if (verb !== 'init' || !['--port', '--rotate-grace-minutes', '--first-output-seconds'].includes(flag) || parsed.values[flag] !== undefined || !rest[i + 1]) throw failure('proxy-usage', 4);
|
||||
parsed.values[flag] = rest[++i];
|
||||
}
|
||||
@@ -81,7 +163,23 @@ export async function proxyCommand(args, io, { system, callFor, prompt = promptP
|
||||
try { proxyTiming(timing); } catch (error) { throw failure(error.code); }
|
||||
try {
|
||||
const config = typeof system === 'function' ? system() : system;
|
||||
if (verb === 'fetch') { const result = await fetch({ dataRoot: config.dataRoot }); io.stdout.write(JSON.stringify({ binary: result.binary, panel: result.panel }) + '\n'); return; }
|
||||
if (verb === 'fetch') {
|
||||
// #1873: the page's fetch, with its progress on stderr and its receipt in proxy-admin.jsonl;
|
||||
// stdout keeps its existing line.
|
||||
const { result, receipt } = await adminFor(io, system, { ...deps, fetch }).fetchRun({ progress: step => io.stderr.write(`fetch: ${step}\n`) });
|
||||
io.stderr.write(`receipt: ${JSON.stringify(receipt)}\n`);
|
||||
io.stdout.write(JSON.stringify({ binary: result.binary, panel: result.panel }) + '\n'); return;
|
||||
}
|
||||
// #1873: the bus generates the key and stores it where init stores a typed key; no prompt,
|
||||
// no terminal needed, and the key is never printed or returned.
|
||||
if (parsed.flags.has('--generate-key')) {
|
||||
// Through the page's controller, so the same init receipt lands in proxy-admin.jsonl.
|
||||
const call = callFor(parsed);
|
||||
const { settings, receipt } = await adminFor(io, system, deps).init(call, { generate: true, ...(port === undefined ? {} : { port }), ...timing });
|
||||
io.stderr.write(`receipt: ${JSON.stringify(receipt)}\n`);
|
||||
print(io, { ...settings, generated: true });
|
||||
return;
|
||||
}
|
||||
if (!io.stdin.isTTY || !io.stdout.isTTY) throw failure('proxy-terminal-required', 3);
|
||||
// Establish that the CLI is eligible before reading sensitive input. The bus verifies the proof.
|
||||
const call = callFor(parsed);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 ?? {}); }
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
// #1873 proxy setup: host facts that `mosaic proxy` and the Console page share. Never reads a
|
||||
// key, proxy setting or bus record; labels only, never an absolute path.
|
||||
import net from 'node:net';
|
||||
import { hostStatus } from './host.mjs';
|
||||
import { socketPath, orgPaths } from './config.mjs';
|
||||
import { devOwnerInstance } from './dev-local-owner.mjs';
|
||||
import { listOrgs } from '../../organization/src/org-registry.mjs';
|
||||
|
||||
// A socket that accepts a connection, or fails other than "absent"/"refused", counts as running.
|
||||
function socketLive(file, timeout = 1000) {
|
||||
return new Promise(resolve => {
|
||||
const socket = net.connect(file);
|
||||
const done = value => { socket.destroy(); resolve(value); };
|
||||
socket.setTimeout(timeout, () => done(true));
|
||||
socket.once('connect', () => done(true));
|
||||
socket.once('error', error => done(!['ENOENT', 'ECONNREFUSED'].includes(error.code)));
|
||||
});
|
||||
}
|
||||
|
||||
async function rootRunning(root, socket) {
|
||||
try {
|
||||
const status = hostStatus(root);
|
||||
if (status.host?.live || status.writerLock?.live) return true;
|
||||
} catch { return true; }
|
||||
return socketLive(socket);
|
||||
}
|
||||
|
||||
// The init-lock README condition "the bus is stopped", proved for the instance's own bus and
|
||||
// every registered organization's bus. Anything that cannot be examined counts as running.
|
||||
// `listOrgsFn` and `running` are for tests.
|
||||
export async function busesStopped({ dataRoot, configDir, listOrgsFn = listOrgs, running = rootRunning }) {
|
||||
const out = [];
|
||||
if (await running(dataRoot, socketPath(dataRoot))) out.push('the instance bus');
|
||||
let orgs;
|
||||
try { orgs = await listOrgsFn({ configDir }); }
|
||||
catch { return { stopped: false, running: [...out, 'organization registry unreadable'] }; }
|
||||
for (const org of orgs) {
|
||||
const label = `organization ${typeof org?.id === 'string' ? org.id : '?'}`;
|
||||
let paths;
|
||||
try { paths = orgPaths(org.root); } catch { out.push(label); continue; }
|
||||
if (await running(org.root, paths.socket)) out.push(label);
|
||||
}
|
||||
return { stopped: out.length === 0, running: out };
|
||||
}
|
||||
|
||||
// The named instance MOSAIC_CONFIG selects, or null for the default (dev) instance.
|
||||
export function instanceFromConfig(env = process.env) {
|
||||
try { return devOwnerInstance(env); } catch { return null; }
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
// #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 = [], system = fakeSystem();
|
||||
await main(['proxy', 'init', '--generate-key', '--business', 'demo', '--port', '18440', '--rotate-grace-minutes', '5'], x, {
|
||||
system, 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 });
|
||||
// The same init receipt the page writes, settings only.
|
||||
const [receipt] = readFileSync(join(system.dataRoot, 'state', 'proxy-admin.jsonl'), 'utf8').trim().split('\n').map(JSON.parse);
|
||||
assert.deepEqual({ ...receipt, time: 'T' }, { time: 'T', op: 'init', generated: true, port: 18440, rotateGraceMinutes: 5, firstOutputSeconds: null });
|
||||
assert.match(x.err.text, /^receipt: \{"time":/m);
|
||||
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' });
|
||||
});
|
||||
@@ -125,3 +125,54 @@ buses, the proxy's own panel, other holders of the management key, and the
|
||||
human `proxy fetch` and `proxy init` tools do not participate. Such writers can
|
||||
still race a whole-list write or alter proxy state. A server-side fence is a
|
||||
follow-up; do not run those writers concurrently with organization buses.
|
||||
|
||||
### Setup from the Console (#1873)
|
||||
|
||||
Settings > Model proxy has a Proxy setup panel for the instance owner: the
|
||||
`--dev-local-owner` owner on a dev Console, or the root owner under `--sso`.
|
||||
Agents, testers and grant holders get `human-required` on every setup route,
|
||||
read or write. The panel shows the release (pinned version, hashes verified),
|
||||
initialization, the unit (installed, active, pid, port), the fence, the init
|
||||
lock, each organization's proxy (shared) and catalogue, and own proxy. Each
|
||||
state offers its next step as one button. CLI parity:
|
||||
|
||||
mosaic proxy status [--json]
|
||||
mosaic proxy fetch
|
||||
mosaic proxy init --generate-key [--port N] [--rotate-grace-minutes N] [--first-output-seconds N] --business ID
|
||||
mosaic proxy unit render|status|install|start|stop|restart
|
||||
mosaic proxy fence clear --restart --business ID
|
||||
mosaic proxy init-lock status|clear
|
||||
|
||||
- Fetch runs the same PIN and hash checks as `mosaic proxy fetch`; the page
|
||||
shows each step as it runs.
|
||||
- Init asks the bus to generate the management key (`proxy.init` with
|
||||
`generate: true`, 48 CSPRNG bytes). The key goes to the key file only; no
|
||||
response, event or receipt carries it. A pasted key with its confirmation is
|
||||
accepted only when the page is on HTTPS or on loopback from a loopback
|
||||
peer; a plain-HTTP LAN page gets the generated key only
|
||||
(`proxy-key-transport`). Init keeps its exclusive-create rules and never
|
||||
rewrites `config.json`. Restart the other buses afterwards.
|
||||
- The unit verbs install the same user unit as `scripts/proxy-service.sh`.
|
||||
Health is the unit's main process tree listening on the configured port.
|
||||
- `fence clear --restart` (and the page's fence step) restarts the unit,
|
||||
confirms a new process that started after the fence and listens on the port,
|
||||
then clears the fence with that evidence under the lock. The receipt holds the
|
||||
before and after pids. Without a confirmed restart it refuses
|
||||
(`proxy-restart-unconfirmed`). A fence file with no readable time also refuses,
|
||||
because a restart cannot be shown to come after it; restart the service
|
||||
yourself and use `fence clear --proxy-restarted`, which is unchanged.
|
||||
- `proxy-admin-busy` (one setup action at a time) is per process: the Console
|
||||
and a `mosaic proxy` command each hold their own. The writes that matter are
|
||||
still serialized across processes: init by the bus's init lock and
|
||||
exclusive create, key writes by the instance-wide proxy lock, and unit
|
||||
actions by systemd.
|
||||
- The page and `status --json` carry the last ten receipts from
|
||||
`proxy-admin.jsonl`, so they survive a reload. CLI `init --generate-key`
|
||||
writes the same init receipt as the page.
|
||||
- The init lock clears only when every bus on the instance is stopped and no
|
||||
`proxy.json`, key file or proxy state directory exists. Otherwise the panel
|
||||
and `init-lock status` list why not.
|
||||
- Own proxy per organization is not built: the configuration has one proxy
|
||||
per instance (ruling 8, deferred).
|
||||
|
||||
Receipts: `<dataRoot>/state/proxy-admin.jsonl` (labels, pids and port only).
|
||||
@@ -0,0 +1,140 @@
|
||||
// #1873 proxy setup controller shared by the Console page (webui proxy-admin.mjs) and
|
||||
// `mosaic proxy status|unit|init-lock|fence clear --restart`. Host actions run here; init and
|
||||
// the fence clear go to the bus, which decides them. Results and receipts carry labels, key IDs
|
||||
// and process ids only: never a key, token, absolute path or upstream text.
|
||||
import { homedir } from 'node:os';
|
||||
import { fetchPinned } from './fetch.mjs';
|
||||
import { PIN } from './common.mjs';
|
||||
import { releaseState, initState, fenceState, initLockStatus, clearInitLock, appendReceipt, recentReceipts, createUnitControl, FILE_LABELS } from './admin.mjs';
|
||||
|
||||
export const proxyCode = (error, fallback) => (/^proxy-[a-z-]+$/.test(error?.code ?? '') ? error.code : fallback);
|
||||
const failure = code => Object.assign(new Error(code), { code });
|
||||
|
||||
// `buses()` proves the init-lock condition "bus stopped" ({stopped, running:[label]}).
|
||||
// `orgs()` lists the organizations this proxy serves: [{id, catalogue}]. `unit` and `fetch` are
|
||||
// (and `pin`) seams for tests; the defaults run the real proxy-service.sh, systemctl and pinned fetch.
|
||||
export function createProxyAdmin({ dataRoot, configDir, instance = null, unitDir = null, unit = null, fetch = fetchPinned, buses, orgs = async () => [], now = () => new Date(), env = process.env, pin = PIN }) {
|
||||
const port = () => { try { return initState(configDir, dataRoot).settings?.port ?? null; } catch { return null; } };
|
||||
const control = unit ?? createUnitControl({ instance, unitDir, port, env });
|
||||
let job = { state: 'idle', step: null, receipt: null, error: null };
|
||||
// The last confirmed restart this page made. One fence clear may use it.
|
||||
let restarted = null;
|
||||
let busy = null;
|
||||
const receipt = record => appendReceipt(dataRoot, { time: now().toISOString(), ...record });
|
||||
const fetchRecord = () => ({ op: 'fetch', version: pin.version, panelVersion: pin.panel.version, archiveSha256: pin.archive.sha256, panelSha256: pin.panel.sha256, binarySha256: pin.binary.sha256, verified: true });
|
||||
async function exclusive(name, action) {
|
||||
if (busy) throw failure('proxy-admin-busy');
|
||||
busy = name;
|
||||
try { return await action(); } finally { busy = null; }
|
||||
}
|
||||
async function unitStatus() {
|
||||
try { return await control.status(); } catch (error) { return { unit: control.unit, installed: false, active: 'unknown', healthy: false, error: proxyCode(error, 'proxy-unit-failed') }; }
|
||||
}
|
||||
function nextSteps({ release, init, unitState, fence, lock }) {
|
||||
if (!release.fetched || release.problem) return ['fetch'];
|
||||
if (lock.present) return lock.clearable ? ['init-lock-clear'] : ['init-lock-wait'];
|
||||
if (init.partial || init.problem) return ['init-repair'];
|
||||
if (!init.initialized) return ['init'];
|
||||
if (!unitState.installed) return ['unit-install'];
|
||||
if (unitState.active !== 'active') return ['unit-start'];
|
||||
if (!unitState.healthy) return ['unit-restart'];
|
||||
if (fence) return restarted ? ['fence-clear'] : ['unit-restart'];
|
||||
return ['accounts'];
|
||||
}
|
||||
return {
|
||||
unit: control.unit,
|
||||
async status() {
|
||||
// The job is read before the release: a fetch that ends during the awaits below then reads
|
||||
// "running" (and the page polls again), never "done" next to a release read before it ended.
|
||||
const fetchJob = { ...job };
|
||||
const release = releaseState(dataRoot, pin);
|
||||
const init = initState(configDir, dataRoot);
|
||||
const unitState = await unitStatus();
|
||||
const fence = fenceState(dataRoot);
|
||||
const lock = init.lock ? initLockStatus({ configDir, dataRoot, buses: await buses() }) : { present: false, clearable: false, blockers: [] };
|
||||
let organizations = null;
|
||||
try { organizations = await orgs(); } catch { organizations = null; }
|
||||
return {
|
||||
release: { version: release.version, panelVersion: release.panelVersion, fetched: release.fetched, verified: release.verified, problem: release.problem },
|
||||
init: {
|
||||
initialized: init.initialized, partial: init.partial, problem: init.problem, settings: init.settings,
|
||||
files: Object.entries(init.files).map(([name, found]) => ({ label: FILE_LABELS[name], present: found })),
|
||||
},
|
||||
unit: unitState,
|
||||
fence: fence ? { set: true, org: fence.org ?? null, op: fence.op ?? null, time: fence.time ?? null, invalid: fence.invalid === true } : { set: false },
|
||||
initLock: lock,
|
||||
fetch: fetchJob,
|
||||
restart: restarted ? { confirmed: true, observedAt: restarted.observedAt, pid: restarted.after.pid } : null,
|
||||
busy,
|
||||
receipts: recentReceipts(dataRoot),
|
||||
organizations,
|
||||
// Ruling 8: the system config holds one proxy per instance; an organization's own proxy
|
||||
// needs a config format that does not exist yet.
|
||||
ownProxy: { available: false, state: 'not built', reason: 'This instance has one shared proxy. A separate proxy per organization is not built yet.' },
|
||||
next: nextSteps({ release, init, unitState, fence, lock }),
|
||||
};
|
||||
},
|
||||
// Runs in the background; the page polls status for its step and receipt.
|
||||
fetchStart() {
|
||||
if (job.state === 'running' || busy) throw failure('proxy-admin-busy');
|
||||
job = { state: 'running', step: 'start', receipt: null, error: null };
|
||||
busy = 'fetch';
|
||||
Promise.resolve()
|
||||
.then(() => fetch({ dataRoot, pin, progress: step => { job.step = step; } }))
|
||||
.then(() => {
|
||||
job = { state: 'done', step: 'done', error: null, receipt: receipt(fetchRecord()) };
|
||||
})
|
||||
.catch(error => { job = { state: 'failed', step: job.step, receipt: null, error: proxyCode(error, 'proxy-fetch-failed') }; })
|
||||
.finally(() => { busy = null; });
|
||||
return { state: 'running' };
|
||||
},
|
||||
// The CLI's fetch: the same pinned fetch and receipt, in the foreground. Returns the fetch
|
||||
// result (for the CLI's existing output) and the receipt.
|
||||
async fetchRun({ progress = () => {} } = {}) {
|
||||
if (job.state === 'running') throw failure('proxy-admin-busy');
|
||||
return exclusive('fetch', async () => {
|
||||
const result = await fetch({ dataRoot, pin, progress });
|
||||
return { result, receipt: receipt(fetchRecord()) };
|
||||
});
|
||||
},
|
||||
async init(call, args) {
|
||||
return exclusive('init', async () => {
|
||||
const result = await call('proxy.init', args);
|
||||
const settings = { port: result.port, rotateGraceMinutes: result.rotateGraceMinutes ?? null, firstOutputSeconds: result.firstOutputSeconds ?? null };
|
||||
return { settings, receipt: receipt({ op: 'init', generated: args.generate === true, ...settings }) };
|
||||
});
|
||||
},
|
||||
async render() {
|
||||
const text = await control.render();
|
||||
const home = homedir();
|
||||
return { unit: control.unit, text: home && home !== '/' ? text.split(home).join('~') : text };
|
||||
},
|
||||
async unitAction(op) {
|
||||
return exclusive(`unit-${op}`, async () => {
|
||||
if (op === 'restart') {
|
||||
const { state, evidence } = await control.restart();
|
||||
restarted = evidence.confirmed ? evidence : null;
|
||||
return { state, evidence, receipt: receipt({ op: 'unit.restart', unit: control.unit, confirmed: evidence.confirmed, before: { pid: evidence.before.pid }, after: { pid: evidence.after.pid, listening: evidence.after.listening }, observedAt: evidence.observedAt }) };
|
||||
}
|
||||
const state = await control[op]();
|
||||
// Any other unit action ends the restart this page confirmed.
|
||||
restarted = null;
|
||||
return { state, receipt: receipt({ op: `unit.${op}`, unit: control.unit, active: state.active, pid: state.pid ?? null, healthy: state.healthy === true }) };
|
||||
});
|
||||
},
|
||||
// Only after this page restarted the proxy and saw the new process. The bus re-proves it.
|
||||
async fenceClear(call) {
|
||||
return exclusive('fence-clear', async () => {
|
||||
if (!restarted) throw failure('proxy-restart-required');
|
||||
const evidence = restarted;
|
||||
// Kept until the bus answers: a bus that is down or busy leaves the confirmed restart for a retry.
|
||||
const result = await call('proxy.fence.clear', { evidence });
|
||||
if (restarted === evidence) restarted = null;
|
||||
return { cleared: result.cleared === true, receipt: receipt({ op: 'fence.clear', cleared: result.cleared === true, evidence: result.evidence ?? null }) };
|
||||
});
|
||||
},
|
||||
async initLockClear() {
|
||||
return exclusive('init-lock-clear', async () => ({ receipt: clearInitLock({ configDir, dataRoot, buses: await buses(), now }) }));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
// #1873 proxy setup state and host actions shared by `mosaic proxy` and the Console page.
|
||||
// Never imports the management client and never reads the management key file: state is
|
||||
// file presence, the pinned install hashes, the fence file and the proxy unit's systemd state.
|
||||
// Results and receipts name files by label, never by absolute path, and carry no key.
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import net from 'node:net';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { PIN, refuse } from './common.mjs';
|
||||
import { installPaths, loadProxy, verifyInstall } from './settings.mjs';
|
||||
|
||||
const REPO = fileURLToPath(new URL('../../../', import.meta.url)).replace(/\/$/, '');
|
||||
export const INSTANCE_NAME = /^[a-z][a-z0-9]{1,15}$/;
|
||||
|
||||
|
||||
export const FILE_LABELS = Object.freeze({ settings: 'proxy.json', key: 'the management key file', proxyDir: 'the proxy state directory', lock: 'the init lock' });
|
||||
export function initFiles(configDir, dataRoot) {
|
||||
return {
|
||||
settings: path.join(configDir, 'proxy.json'),
|
||||
key: path.join(configDir, 'secrets', 'proxy-management.key'),
|
||||
proxyDir: path.join(dataRoot, 'proxy'),
|
||||
lock: path.join(configDir, '.proxy-init.lock'),
|
||||
};
|
||||
}
|
||||
// Presence only. A path that cannot be examined counts as present, so nothing clears on doubt.
|
||||
function present(file) { try { fs.lstatSync(file); return true; } catch (error) { return error.code !== 'ENOENT'; } }
|
||||
|
||||
const verified = new Map();
|
||||
// The pinned release: hashes are checked once per file identity (dev, inode, size, mtime, ctime, mode),
|
||||
// in nanoseconds. mtime can be set back by the file's owner; ctime cannot, so an in-place rewrite re-hashes.
|
||||
export function releaseState(dataRoot, pin = PIN) {
|
||||
const paths = installPaths(dataRoot, pin);
|
||||
const out = { version: pin.version, panelVersion: pin.panel.version, fetched: false, verified: false, problem: null };
|
||||
let stats;
|
||||
try { stats = [paths.binary, paths.panel].map(file => fs.lstatSync(file, { bigint: true })); }
|
||||
catch (error) { out.problem = error.code === 'ENOENT' ? 'proxy-install-missing' : 'proxy-install-invalid'; return out; }
|
||||
out.fetched = true;
|
||||
const identity = stats.map(s => `${s.dev}:${s.ino}:${s.size}:${s.mtimeNs}:${s.ctimeNs}:${s.mode}`).join('|');
|
||||
if (verified.get(paths.dir) === identity) { out.verified = true; return out; }
|
||||
try { verifyInstall(dataRoot, pin); verified.set(paths.dir, identity); out.verified = true; }
|
||||
catch (error) { out.problem = /^proxy-install-(missing|invalid)$/.test(error.code ?? '') ? error.code : 'proxy-install-invalid'; }
|
||||
return out;
|
||||
}
|
||||
|
||||
export function initState(configDir, dataRoot) {
|
||||
const files = initFiles(configDir, dataRoot);
|
||||
const found = Object.fromEntries(Object.entries(files).map(([name, file]) => [name, present(file)]));
|
||||
let settings = null, problem = null;
|
||||
if (found.settings) {
|
||||
try {
|
||||
const proxy = loadProxy(configDir, dataRoot);
|
||||
settings = { port: proxy.port, rotateGraceMinutes: proxy.rotateGraceMinutes ?? null, firstOutputSeconds: proxy.firstOutputSeconds ?? null };
|
||||
} catch (error) { problem = /^proxy-[a-z-]+$/.test(error.code ?? '') ? error.code : 'proxy-config-invalid'; }
|
||||
}
|
||||
const initialized = found.settings && found.key && found.proxyDir && !problem;
|
||||
const partial = !initialized && (found.settings || found.key || found.proxyDir);
|
||||
return { initialized, partial, problem, settings, files: found, lock: found.lock };
|
||||
}
|
||||
|
||||
// Read-only view of the uncertain-write fence. Never constructs a proxy lock, which writes.
|
||||
export function fenceState(dataRoot) {
|
||||
const file = path.join(dataRoot, 'proxy', 'mosaic-keys.uncertain');
|
||||
let stat;
|
||||
try { stat = fs.lstatSync(file); } catch (error) { if (error.code === 'ENOENT') return null; return { invalid: true }; }
|
||||
if (!stat.isFile() || stat.uid !== process.getuid() || (stat.mode & 0o077) || stat.size > 4096) return { invalid: true };
|
||||
let entry;
|
||||
try { entry = JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return { invalid: true }; }
|
||||
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return { invalid: true };
|
||||
return {
|
||||
org: typeof entry.org === 'string' ? entry.org : null,
|
||||
op: typeof entry.op === 'string' ? entry.op : null,
|
||||
time: typeof entry.time === 'string' ? entry.time : null,
|
||||
pid: Number.isSafeInteger(entry.pid) ? entry.pid : null,
|
||||
};
|
||||
}
|
||||
|
||||
// README conditions for a stale init lock: the bus is stopped (so no init is running), and
|
||||
// none of proxy.json, the key file or the proxy dir exists. `buses` is the caller's proof:
|
||||
// { stopped: boolean, running: [label] }.
|
||||
export function initLockStatus({ configDir, dataRoot, buses }) {
|
||||
const state = initState(configDir, dataRoot);
|
||||
const blockers = [];
|
||||
if (!buses || buses.stopped !== true) blockers.push(`bus running: ${(buses?.running ?? ['unknown']).join(', ')}`);
|
||||
for (const name of ['settings', 'key', 'proxyDir']) if (state.files[name]) blockers.push(`${FILE_LABELS[name]} exists`);
|
||||
return { present: state.lock, clearable: state.lock && blockers.length === 0, blockers: state.lock ? blockers : [] };
|
||||
}
|
||||
|
||||
export function clearInitLock({ configDir, dataRoot, buses, now = () => new Date() }) {
|
||||
const status = initLockStatus({ configDir, dataRoot, buses });
|
||||
if (!status.present) refuse('proxy-init-lock-absent');
|
||||
if (!status.clearable) refuse('proxy-init-lock-held');
|
||||
const lock = initFiles(configDir, dataRoot).lock;
|
||||
const stat = fs.lstatSync(lock);
|
||||
if (!stat.isFile() || stat.uid !== process.getuid()) refuse('proxy-init-lock-held');
|
||||
fs.unlinkSync(lock);
|
||||
return appendReceipt(dataRoot, { op: 'init-lock.clear', time: now().toISOString(), conditions: ['bus stopped', 'no proxy.json', 'no management key file', 'no proxy state directory'] });
|
||||
}
|
||||
|
||||
// Append-only admin receipts beside the other state logs. Labels and key ids only.
|
||||
export function receiptFile(dataRoot) { return path.join(dataRoot, 'state', 'proxy-admin.jsonl'); }
|
||||
export function appendReceipt(dataRoot, record) {
|
||||
const dir = path.join(dataRoot, 'state');
|
||||
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
||||
const fd = fs.openSync(receiptFile(dataRoot), 'a', 0o600);
|
||||
try { fs.writeFileSync(fd, JSON.stringify(record) + '\n'); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
|
||||
return record;
|
||||
}
|
||||
|
||||
// The last few receipts, oldest first, for the page after a reload. Reads only the file's tail and
|
||||
// skips a line that does not parse (a torn last write). Receipts never hold a key or a body.
|
||||
export function recentReceipts(dataRoot, limit = 10) {
|
||||
let fd;
|
||||
try { fd = fs.openSync(receiptFile(dataRoot), 'r'); } catch { return []; }
|
||||
try {
|
||||
const size = fs.fstatSync(fd).size, length = Math.min(size, 65536), buffer = Buffer.alloc(length);
|
||||
fs.readSync(fd, buffer, 0, length, size - length);
|
||||
const lines = buffer.toString('utf8').split('\n').slice(size > length ? 1 : 0).filter(Boolean);
|
||||
const out = [];
|
||||
for (const line of lines) { try { const value = JSON.parse(line); if (value && typeof value === 'object' && typeof value.op === 'string') out.push(value); } catch { /* torn line */ } }
|
||||
return out.slice(-limit);
|
||||
} finally { fs.closeSync(fd); }
|
||||
}
|
||||
|
||||
export function unitName(instance) {
|
||||
if (instance !== null && instance !== undefined && instance !== 'dev' && !INSTANCE_NAME.test(instance)) refuse('proxy-unit-usage');
|
||||
return instance && instance !== 'dev' ? `mosaic-proxy-${instance}.service` : 'mosaic-proxy.service';
|
||||
}
|
||||
|
||||
export function defaultRun(command, args, { env, timeout = 60000 } = {}) {
|
||||
return new Promise(resolve => {
|
||||
execFile(command, args, { env, timeout, maxBuffer: 1024 * 1024, killSignal: 'SIGKILL' }, (error, stdout, stderr) => {
|
||||
resolve({ code: error ? (typeof error.code === 'number' ? error.code : 1) : 0, stdout: String(stdout ?? ''), stderr: String(stderr ?? '') });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function probePort(port, { host = '127.0.0.1', timeout = 1000 } = {}) {
|
||||
return new Promise(resolve => {
|
||||
if (!Number.isInteger(port)) return resolve(false);
|
||||
const socket = net.connect({ host, port });
|
||||
const done = value => { socket.destroy(); resolve(value); };
|
||||
socket.setTimeout(timeout, () => done(false));
|
||||
socket.once('connect', () => done(true));
|
||||
socket.once('error', () => done(false));
|
||||
});
|
||||
}
|
||||
|
||||
const UNIT_CODES = ['proxy-unit-usage', 'proxy-unit-exists', 'proxy-unit-dropin', 'proxy-unit-copy', 'proxy-unit-path', 'proxy-not-configured'];
|
||||
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
||||
|
||||
// The proxy user unit for one instance. `port` is a function so a fresh init is seen.
|
||||
// `unitDir` is passed to proxy-service.sh --dir; null keeps that script's default.
|
||||
export function createUnitControl({ instance = null, port = () => null, unitDir = null, env = process.env, run = defaultRun, repo = REPO, probe = probePort, waitMs = 30000, pollMs = 250, now = () => new Date() } = {}) {
|
||||
const unit = unitName(instance);
|
||||
if (unitDir !== null && (typeof unitDir !== 'string' || !path.isAbsolute(unitDir))) refuse('proxy-unit-path');
|
||||
const script = path.join(repo, 'scripts', 'proxy-service.sh');
|
||||
const instanceArgs = instance && instance !== 'dev' ? ['--instance', instance] : [];
|
||||
async function service(op) {
|
||||
const result = await run('bash', [script, ...instanceArgs, op, ...(unitDir && op === 'install' ? ['--dir', unitDir] : [])], { env });
|
||||
if (result.code === 0) return result;
|
||||
const code = /^proxy-service: (proxy-[a-z-]+)$/m.exec(result.stderr)?.[1];
|
||||
refuse(UNIT_CODES.includes(code) ? code : 'proxy-unit-failed');
|
||||
}
|
||||
async function status() {
|
||||
const result = await run('systemctl', ['--user', 'show', unit, '--property=LoadState,ActiveState,SubState,MainPID,ExecMainStartTimestampMonotonic,NRestarts'], { env, timeout: 15000 });
|
||||
if (result.code !== 0) refuse('proxy-unit-failed');
|
||||
const fields = Object.fromEntries(result.stdout.split('\n').filter(Boolean).map(line => { const i = line.indexOf('='); return [line.slice(0, i), line.slice(i + 1)]; }));
|
||||
const pid = /^[1-9][0-9]*$/.test(fields.MainPID ?? '') ? Number(fields.MainPID) : null;
|
||||
const since = /^[1-9][0-9]*$/.test(fields.ExecMainStartTimestampMonotonic ?? '') ? Number(fields.ExecMainStartTimestampMonotonic) : null;
|
||||
const p = port();
|
||||
const listening = fields.ActiveState === 'active' && Number.isInteger(p) ? await probe(p) : false;
|
||||
return {
|
||||
unit,
|
||||
installed: fields.LoadState === 'loaded',
|
||||
load: fields.LoadState ?? 'unknown', active: fields.ActiveState ?? 'unknown', sub: fields.SubState ?? 'unknown',
|
||||
pid, since, restarts: /^[0-9]+$/.test(fields.NRestarts ?? '') ? Number(fields.NRestarts) : null,
|
||||
port: Number.isInteger(p) ? p : null, listening,
|
||||
healthy: fields.ActiveState === 'active' && pid !== null && listening,
|
||||
};
|
||||
}
|
||||
async function waitFor(test) {
|
||||
const deadline = Date.now() + waitMs;
|
||||
for (;;) {
|
||||
const state = await status();
|
||||
if (test(state)) return state;
|
||||
if (Date.now() >= deadline) return state;
|
||||
await sleep(pollMs);
|
||||
}
|
||||
}
|
||||
async function systemctl(verb) {
|
||||
const result = await run('systemctl', ['--user', verb, unit], { env, timeout: 90000 });
|
||||
if (result.code !== 0) refuse('proxy-unit-failed');
|
||||
}
|
||||
return {
|
||||
unit,
|
||||
status,
|
||||
async render() { return (await service('render')).stdout; },
|
||||
async install() { await service('install'); return status(); },
|
||||
async start() {
|
||||
const before = await status();
|
||||
if (!before.installed) refuse('proxy-unit-missing');
|
||||
await systemctl('start');
|
||||
const after = await waitFor(s => s.healthy);
|
||||
if (!after.healthy) refuse('proxy-unit-unhealthy');
|
||||
return after;
|
||||
},
|
||||
async stop() {
|
||||
const before = await status();
|
||||
if (!before.installed) refuse('proxy-unit-missing');
|
||||
await systemctl('stop');
|
||||
return waitFor(s => s.active === 'inactive' || s.active === 'failed');
|
||||
},
|
||||
// Restart evidence is what a fence clear needs: a new main process, started after the old
|
||||
// one, active and answering on the proxy port. Anything less is not a confirmed restart.
|
||||
async restart() {
|
||||
const before = await status();
|
||||
if (!before.installed) refuse('proxy-unit-missing');
|
||||
await systemctl('restart');
|
||||
const changed = s => s.healthy && s.pid !== null && s.pid !== before.pid && s.since !== null && (before.since === null || s.since > before.since);
|
||||
const after = await waitFor(changed);
|
||||
const confirmed = changed(after);
|
||||
return {
|
||||
state: after,
|
||||
evidence: {
|
||||
unit, confirmed,
|
||||
before: { pid: before.pid, since: before.since, active: before.active },
|
||||
after: { pid: after.pid, since: after.since, active: after.active, listening: after.listening },
|
||||
observedAt: now().toISOString(),
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Process start time as wall-clock milliseconds, from /proc (start ticks since boot + btime).
|
||||
export function processStartMs(pid, { proc = '/proc' } = {}) {
|
||||
try {
|
||||
const stat = fs.readFileSync(path.join(proc, String(pid), 'stat'), 'utf8');
|
||||
const fields = stat.slice(stat.lastIndexOf(')') + 2).split(' ');
|
||||
const ticks = Number(fields[19]);
|
||||
const btime = Number(/^btime ([0-9]+)$/m.exec(fs.readFileSync(path.join(proc, 'stat'), 'utf8'))?.[1]);
|
||||
if (!Number.isFinite(ticks) || !Number.isFinite(btime)) return null;
|
||||
return btime * 1000 + Math.floor(ticks * 10);
|
||||
} catch { return null; }
|
||||
}
|
||||
// `pid` and every process descended from it, from each process's parent field in /proc.
|
||||
export function processTree(pid, { proc = '/proc' } = {}) {
|
||||
const parents = new Map();
|
||||
let names; try { names = fs.readdirSync(proc); } catch { return [pid]; }
|
||||
for (const name of names) {
|
||||
if (!/^[1-9][0-9]*$/.test(name)) continue;
|
||||
try {
|
||||
const stat = fs.readFileSync(path.join(proc, name, 'stat'), 'utf8');
|
||||
const ppid = Number(stat.slice(stat.lastIndexOf(')') + 2).split(' ')[1]);
|
||||
if (!parents.has(ppid)) parents.set(ppid, []);
|
||||
parents.get(ppid).push(Number(name));
|
||||
} catch { /* gone */ }
|
||||
}
|
||||
const out = [pid];
|
||||
for (let i = 0; i < out.length && out.length < 256; i++) out.push(...(parents.get(out[i]) ?? []));
|
||||
return out;
|
||||
}
|
||||
// True when `pid` or one of its descendants holds a listening TCP socket on `port` (IPv4 or
|
||||
// IPv6), read from /proc. The unit's main process is the node runner; the pinned binary it
|
||||
// spawns is the listener.
|
||||
export function pidListensOn(pid, port, { proc = '/proc' } = {}) {
|
||||
const hex = port.toString(16).toUpperCase().padStart(4, '0');
|
||||
const inodes = new Set();
|
||||
for (const table of ['tcp', 'tcp6']) {
|
||||
let text; try { text = fs.readFileSync(path.join(proc, 'net', table), 'utf8'); } catch { continue; }
|
||||
for (const line of text.split('\n').slice(1)) {
|
||||
const cols = line.trim().split(/\s+/);
|
||||
if (cols.length > 9 && cols[1].endsWith(`:${hex}`) && cols[3] === '0A') inodes.add(cols[9]);
|
||||
}
|
||||
}
|
||||
if (inodes.size === 0) return false;
|
||||
for (const member of processTree(pid, { proc })) {
|
||||
let fds; try { fds = fs.readdirSync(path.join(proc, String(member), 'fd')); } catch { continue; }
|
||||
for (const fd of fds) {
|
||||
let link; try { link = fs.readlinkSync(path.join(proc, String(member), 'fd', fd)); } catch { continue; }
|
||||
const m = /^socket:\[([0-9]+)\]$/.exec(link);
|
||||
if (m && inodes.has(m[1])) return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// A fence clear accepts restart evidence only after re-proving it here, on the host: the
|
||||
// evidence names a confirmed restart to a new process, and that process is alive now, started
|
||||
// after the fence was set, and is the one listening on the proxy port. Caller-supplied fields
|
||||
// are never trusted on their own.
|
||||
export function verifyRestart(evidence, { fence, port, startMs = processStartMs, listens = pidListensOn, now = () => Date.now() } = {}) {
|
||||
const int = v => Number.isSafeInteger(v) && v > 0;
|
||||
const plain = v => v !== null && typeof v === 'object' && !Array.isArray(v);
|
||||
if (!plain(evidence) || Object.keys(evidence).sort().join(',') !== 'after,before,confirmed,observedAt,unit'
|
||||
|| typeof evidence.unit !== 'string' || !/^mosaic-proxy(-[a-z][a-z0-9]{1,15})?\.service$/.test(evidence.unit)
|
||||
|| typeof evidence.observedAt !== 'string' || Number.isNaN(Date.parse(evidence.observedAt))
|
||||
|| !plain(evidence.before) || !plain(evidence.after)) refuse('proxy-restart-unconfirmed');
|
||||
const { before, after } = evidence;
|
||||
if (evidence.confirmed !== true || !int(after.pid) || !int(after.since) || after.pid === before.pid) refuse('proxy-restart-unconfirmed');
|
||||
const observed = Date.parse(evidence.observedAt);
|
||||
if (observed > now() + 60000 || now() - observed > 10 * 60 * 1000) refuse('proxy-restart-unconfirmed');
|
||||
const started = startMs(after.pid);
|
||||
if (started === null) refuse('proxy-restart-unconfirmed');
|
||||
const fenceTime = fence && typeof fence.time === 'string' ? Date.parse(fence.time) : NaN;
|
||||
if (fence && !(Number.isFinite(fenceTime) && started > fenceTime)) refuse('proxy-restart-unconfirmed');
|
||||
if (!Number.isInteger(port) || !listens(after.pid, port)) refuse('proxy-restart-unconfirmed');
|
||||
return { unit: evidence.unit, before: { pid: int(before.pid) ? before.pid : null }, after: { pid: after.pid, startedAt: new Date(started).toISOString() }, observedAt: evidence.observedAt, listeningPort: port };
|
||||
}
|
||||
@@ -29,15 +29,21 @@ export async function downloadRelease(url, { testOrigin } = {}) {
|
||||
}
|
||||
refuse('proxy-fetch-redirect');
|
||||
}
|
||||
export async function fetchPinned({ dataRoot, pin = PIN, download = downloadRelease }) {
|
||||
// `progress(step)` names each stage for a caller that shows it (#1873 Console page); never a path.
|
||||
export async function fetchPinned({ dataRoot, pin = PIN, download = downloadRelease, progress = () => {} }) {
|
||||
safePath(dataRoot, 'proxy-fetch-existing');
|
||||
const paths = installPaths(dataRoot, pin);
|
||||
safePath(paths.dir, 'proxy-fetch-existing');
|
||||
// Fetch and verify both assets before any install filesystem mutation.
|
||||
progress('download-archive');
|
||||
const archive = await download(pin.archive.url);
|
||||
progress('verify-archive');
|
||||
if (sha256(archive) !== pin.archive.sha256) refuse('proxy-fetch-hash');
|
||||
progress('download-panel');
|
||||
const panel = await download(pin.panel.url);
|
||||
progress('verify-panel');
|
||||
if (sha256(panel) !== pin.panel.sha256) refuse('proxy-fetch-hash');
|
||||
progress('extract-binary');
|
||||
let binary;
|
||||
try {
|
||||
const listing = execFileSync('tar', ['-tzf', '-'], { input: archive, maxBuffer: 1024 * 1024, stdio: ['pipe', 'pipe', 'pipe'] }).toString().split('\n').filter(Boolean);
|
||||
@@ -45,7 +51,9 @@ export async function fetchPinned({ dataRoot, pin = PIN, download = downloadRele
|
||||
// Only the root executable is read to stdout. No archive path is extracted to disk.
|
||||
binary = execFileSync('tar', ['-xOzf', '-', 'cli-proxy-api'], { input: archive, maxBuffer: 256 * 1024 * 1024, stdio: ['pipe', 'pipe', 'pipe'] });
|
||||
} catch { refuse('proxy-fetch-archive'); }
|
||||
progress('verify-binary');
|
||||
if (sha256(binary) !== pin.binary.sha256) refuse('proxy-fetch-hash');
|
||||
progress('install');
|
||||
try {
|
||||
fs.lstatSync(paths.dir);
|
||||
if (fs.readdirSync(paths.dir).sort().join(',') !== 'cli-proxy-api,static'
|
||||
|
||||
@@ -0,0 +1,229 @@
|
||||
// #1873 the setup controller the Console page and `mosaic proxy` share: next steps from state,
|
||||
// a fetch receipt with every pinned hash, init through the bus call, and the fence clear that
|
||||
// needs a restart this controller confirmed.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { createProxyAdmin } from '../src/admin-control.mjs';
|
||||
import { initFiles, receiptFile } from '../src/admin.mjs';
|
||||
import { PIN } from '../src/common.mjs';
|
||||
|
||||
const code = name => error => error.code === name;
|
||||
const SENTINEL = 'pa-sentinel-management-key-0123456789';
|
||||
const stopped = async () => ({ stopped: true, running: [] });
|
||||
|
||||
function fixture(t, { unitState = { installed: false, active: 'inactive', healthy: false }, restart = null, fetch = null, pin = PIN, statusHook = null } = {}) {
|
||||
const root = fs.mkdtempSync(join(tmpdir(), 'pc-'));
|
||||
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
|
||||
const configDir = join(root, 'config'), dataRoot = join(root, 'data');
|
||||
fs.mkdirSync(configDir, { mode: 0o700 }); fs.mkdirSync(dataRoot, { mode: 0o700 });
|
||||
const ops = [];
|
||||
const unit = {
|
||||
unit: 'mosaic-proxy-zz.service',
|
||||
status: async () => { await statusHook?.(); return { unit: 'mosaic-proxy-zz.service', ...unitState }; },
|
||||
render: async () => `ExecStart=/usr/bin/node ${process.env.HOME ?? '/nohome'}/repo/run.mjs\n`,
|
||||
install: async () => { ops.push('install'); return { ...unitState, installed: true }; },
|
||||
start: async () => { ops.push('start'); return { ...unitState, active: 'active', pid: 11, healthy: true }; },
|
||||
stop: async () => { ops.push('stop'); return { ...unitState, active: 'inactive', pid: null }; },
|
||||
restart: async () => { ops.push('restart'); return restart ?? { state: unitState, evidence: { unit: 'mosaic-proxy-zz.service', confirmed: true, before: { pid: 10, since: 1, active: 'active' }, after: { pid: 11, since: 2, active: 'active', listening: true }, observedAt: new Date(0).toISOString() } }; },
|
||||
};
|
||||
const admin = createProxyAdmin({ dataRoot, configDir, unit, buses: stopped, fetch: fetch ?? (async () => ({})), now: () => new Date(0), orgs: async () => [{ id: 'acme', catalogue: 'ready' }], pin });
|
||||
return { root, configDir, dataRoot, admin, ops, files: initFiles(configDir, dataRoot) };
|
||||
}
|
||||
const receipts = dataRoot => { try { return fs.readFileSync(receiptFile(dataRoot), 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse); } catch { return []; } };
|
||||
|
||||
test('a fresh instance says fetch first; status names files by label and offers no own proxy', async t => {
|
||||
const { root, admin } = fixture(t);
|
||||
const status = await admin.status();
|
||||
assert.deepEqual(status.next, ['fetch']);
|
||||
assert.equal(status.release.version, PIN.version);
|
||||
assert.equal(status.release.fetched, false);
|
||||
assert.deepEqual(status.init.files.map(f => f.label), ['proxy.json', 'the management key file', 'the proxy state directory', 'the init lock']);
|
||||
assert.deepEqual(status.ownProxy, { available: false, state: 'not built', reason: 'This instance has one shared proxy. A separate proxy per organization is not built yet.' });
|
||||
assert.deepEqual(status.organizations, [{ id: 'acme', catalogue: 'ready' }]);
|
||||
assert.equal(JSON.stringify(status).includes(root), false);
|
||||
});
|
||||
|
||||
test('fetch runs in the background, reports each step and writes a receipt with every pinned hash', async t => {
|
||||
const steps = [];
|
||||
let release;
|
||||
const gate = new Promise(r => { release = r; });
|
||||
const { dataRoot, admin } = fixture(t, { fetch: async ({ pin, progress }) => { assert.equal(pin, PIN); progress('verify-archive'); steps.push('called'); await gate; } });
|
||||
assert.deepEqual(admin.fetchStart(), { state: 'running' });
|
||||
await new Promise(r => setImmediate(r));
|
||||
assert.throws(() => admin.fetchStart(), code('proxy-admin-busy'));
|
||||
await assert.rejects(admin.unitAction('start'), code('proxy-admin-busy'));
|
||||
const running = await admin.status();
|
||||
assert.equal(running.fetch.state, 'running'); assert.equal(running.fetch.step, 'verify-archive'); assert.equal(running.busy, 'fetch');
|
||||
release();
|
||||
await new Promise(r => setTimeout(r, 10));
|
||||
const done = await admin.status();
|
||||
assert.equal(done.fetch.state, 'done');
|
||||
assert.deepEqual(done.fetch.receipt, { time: new Date(0).toISOString(), op: 'fetch', version: PIN.version, panelVersion: PIN.panel.version, archiveSha256: PIN.archive.sha256, panelSha256: PIN.panel.sha256, binarySha256: PIN.binary.sha256, verified: true });
|
||||
assert.deepEqual(receipts(dataRoot), [done.fetch.receipt]);
|
||||
});
|
||||
|
||||
test('a fetch that ends while status is being read is never reported done with the release still not fetched', async t => {
|
||||
// E2E finding: status read the release before its awaits and the job after them, so the page
|
||||
// saw "done" next to "Not fetched", stopped polling and kept offering Fetch.
|
||||
const sha = text => createHash('sha256').update(text).digest('hex');
|
||||
const pin = { ...PIN, archive: { ...PIN.archive, sha256: sha('a') }, binary: { sha256: sha('b') }, panel: { ...PIN.panel, sha256: sha('p') } };
|
||||
let release, during = null;
|
||||
const gate = new Promise(r => { release = r; });
|
||||
const f = fixture(t, { pin, statusHook: async () => { const d = during; during = null; await d?.(); }, fetch: async ({ dataRoot }) => {
|
||||
await gate;
|
||||
const dir = join(dataRoot, 'opt', 'cliproxyapi', PIN.version);
|
||||
fs.mkdirSync(join(dir, 'static'), { recursive: true });
|
||||
fs.writeFileSync(join(dir, 'cli-proxy-api'), 'b'); fs.writeFileSync(join(dir, 'static', 'management.html'), 'p');
|
||||
} });
|
||||
f.admin.fetchStart();
|
||||
await new Promise(r => setImmediate(r));
|
||||
during = async () => { release(); await new Promise(r => setTimeout(r, 10)); };
|
||||
const racing = await f.admin.status();
|
||||
assert.equal(during, null, 'the fetch ended inside this status read');
|
||||
assert.equal(racing.fetch.state === 'done' && !racing.release.fetched, false, `fetch ${racing.fetch.state} with release fetched=${racing.release.fetched}`);
|
||||
const after = await f.admin.status();
|
||||
assert.equal(after.fetch.state, 'done'); assert.equal(after.release.verified, true); assert.equal(after.next[0], 'init');
|
||||
});
|
||||
|
||||
test('a refused fetch keeps its code and writes no receipt', async t => {
|
||||
const { dataRoot, admin } = fixture(t, { fetch: async () => { throw Object.assign(Error('/home/x upstream body'), { code: 'proxy-fetch-hash' }); } });
|
||||
admin.fetchStart();
|
||||
await new Promise(r => setTimeout(r, 10));
|
||||
const status = await admin.status();
|
||||
assert.equal(status.fetch.state, 'failed'); assert.equal(status.fetch.error, 'proxy-fetch-hash');
|
||||
assert.equal(JSON.stringify(status).includes('upstream body'), false);
|
||||
assert.deepEqual(receipts(dataRoot), []);
|
||||
});
|
||||
|
||||
test('init sends the arguments to proxy.init and its receipt carries settings only', async t => {
|
||||
const { dataRoot, admin } = fixture(t);
|
||||
const calls = [];
|
||||
const result = await admin.init(async (verb, args) => { calls.push([verb, args]); return { port: 18432, binary: '/fixture', rotateGraceMinutes: 5, firstOutputSeconds: 90 }; }, { generate: true, port: 18432 });
|
||||
assert.deepEqual(calls, [['proxy.init', { generate: true, port: 18432 }]]);
|
||||
assert.deepEqual(result.settings, { port: 18432, rotateGraceMinutes: 5, firstOutputSeconds: 90 });
|
||||
assert.deepEqual(receipts(dataRoot), [{ time: new Date(0).toISOString(), op: 'init', generated: true, port: 18432, rotateGraceMinutes: 5, firstOutputSeconds: 90 }]);
|
||||
await admin.init(async () => ({ port: 18433, binary: '/fixture' }), { key: SENTINEL });
|
||||
assert.equal(fs.readFileSync(receiptFile(dataRoot), 'utf8').includes(SENTINEL), false);
|
||||
assert.equal(JSON.stringify(receipts(dataRoot)).includes('/fixture'), false);
|
||||
});
|
||||
|
||||
test('fence clear refuses without a restart, and after an unconfirmed one, before calling the bus', async t => {
|
||||
const { admin, ops } = fixture(t, { restart: { state: {}, evidence: { unit: 'mosaic-proxy-zz.service', confirmed: false, before: { pid: 10 }, after: { pid: 10, listening: true }, observedAt: new Date(0).toISOString() } } });
|
||||
const calls = [];
|
||||
const call = async (verb, args) => { calls.push([verb, args]); return { cleared: true }; };
|
||||
await assert.rejects(admin.fenceClear(call), code('proxy-restart-required'));
|
||||
const restart = await admin.unitAction('restart');
|
||||
assert.equal(restart.evidence.confirmed, false);
|
||||
assert.equal(restart.receipt.confirmed, false);
|
||||
await assert.rejects(admin.fenceClear(call), code('proxy-restart-required'));
|
||||
assert.deepEqual(calls, []);
|
||||
assert.deepEqual(ops, ['restart']);
|
||||
});
|
||||
|
||||
test('fence clear after a confirmed restart sends that evidence once; another unit action ends it', async t => {
|
||||
const { dataRoot, admin } = fixture(t, { unitState: { installed: true, active: 'active', healthy: true, pid: 11 } });
|
||||
const calls = [];
|
||||
const call = async (verb, args) => { calls.push([verb, args]); return { cleared: true, evidence: { after: { pid: 11 } } }; };
|
||||
const restart = await admin.unitAction('restart');
|
||||
assert.equal(restart.evidence.confirmed, true);
|
||||
assert.deepEqual((await admin.status()).restart, { confirmed: true, observedAt: new Date(0).toISOString(), pid: 11 });
|
||||
const cleared = await admin.fenceClear(call);
|
||||
assert.deepEqual(calls, [['proxy.fence.clear', { evidence: restart.evidence }]]);
|
||||
assert.equal(cleared.cleared, true);
|
||||
assert.deepEqual(cleared.receipt.evidence, { after: { pid: 11 } });
|
||||
await assert.rejects(admin.fenceClear(call), code('proxy-restart-required'), 'one restart, one clear');
|
||||
await admin.unitAction('restart');
|
||||
await admin.unitAction('stop');
|
||||
await assert.rejects(admin.fenceClear(call), code('proxy-restart-required'), 'a stop ends the confirmed restart');
|
||||
assert.deepEqual(receipts(dataRoot).map(r => r.op), ['unit.restart', 'fence.clear', 'unit.restart', 'unit.stop']);
|
||||
});
|
||||
|
||||
test('a fence clear the bus did not answer keeps the confirmed restart for a retry', async t => {
|
||||
const { admin } = fixture(t, { unitState: { installed: true, active: 'active', healthy: true, pid: 11 } });
|
||||
let fail = true;
|
||||
const call = async () => { if (fail) { fail = false; throw Object.assign(new Error('bus down'), { code: 'bus-unavailable' }); } return { cleared: true }; };
|
||||
await admin.unitAction('restart');
|
||||
await assert.rejects(admin.fenceClear(call), /bus down/);
|
||||
assert.equal((await admin.status()).restart?.confirmed, true, 'the restart is still confirmed');
|
||||
assert.equal((await admin.fenceClear(call)).cleared, true);
|
||||
assert.equal((await admin.status()).restart, null);
|
||||
});
|
||||
|
||||
test('next step walks fetch, init lock, repair, init, unit install, start, restart, fence and accounts', async t => {
|
||||
const sha = text => createHash('sha256').update(text).digest('hex');
|
||||
const pin = { ...PIN, archive: { ...PIN.archive, sha256: sha('a') }, binary: { sha256: sha('b') }, panel: { ...PIN.panel, sha256: sha('p') } };
|
||||
const state = { installed: false, active: 'inactive', healthy: false, pid: null };
|
||||
const f = fixture(t, { unitState: state, pin });
|
||||
const next = async () => (await f.admin.status()).next[0];
|
||||
assert.equal(await next(), 'fetch');
|
||||
const binDir = join(f.dataRoot, 'opt', 'cliproxyapi', PIN.version);
|
||||
fs.mkdirSync(join(binDir, 'static'), { recursive: true });
|
||||
fs.writeFileSync(join(binDir, 'cli-proxy-api'), 'b'); fs.writeFileSync(join(binDir, 'static', 'management.html'), 'wrong');
|
||||
assert.equal(await next(), 'fetch', 'a hash mismatch is not a fetched release');
|
||||
fs.rmSync(join(binDir, 'static', 'management.html')); fs.writeFileSync(join(binDir, 'static', 'management.html'), 'p');
|
||||
fs.mkdirSync(join(f.configDir, 'secrets'), { mode: 0o700 });
|
||||
fs.writeFileSync(f.files.key, 'fixture', { mode: 0o600 });
|
||||
fs.writeFileSync(f.files.lock, '', { mode: 0o600 });
|
||||
assert.equal(await next(), 'init-lock-wait', 'a key file blocks the lock clear');
|
||||
fs.rmSync(f.files.key);
|
||||
assert.equal(await next(), 'init-lock-clear');
|
||||
fs.rmSync(f.files.lock);
|
||||
fs.writeFileSync(f.files.key, 'fixture', { mode: 0o600 });
|
||||
assert.equal(await next(), 'init-repair');
|
||||
fs.rmSync(f.files.key);
|
||||
assert.equal(await next(), 'init');
|
||||
fs.writeFileSync(f.files.key, 'fixture', { mode: 0o600 });
|
||||
fs.mkdirSync(f.files.proxyDir, { mode: 0o700 });
|
||||
fs.writeFileSync(f.files.settings, JSON.stringify({ binary: join(binDir, 'cli-proxy-api'), port: 18432 }), { mode: 0o600 });
|
||||
const initialized = await f.admin.status();
|
||||
assert.equal(initialized.init.initialized, true);
|
||||
assert.deepEqual(initialized.init.settings, { port: 18432, rotateGraceMinutes: null, firstOutputSeconds: null });
|
||||
assert.equal(initialized.next[0], 'unit-install');
|
||||
state.installed = true;
|
||||
assert.equal(await next(), 'unit-start');
|
||||
state.active = 'active';
|
||||
assert.equal(await next(), 'unit-restart', 'active but not answering');
|
||||
state.healthy = true;
|
||||
assert.equal(await next(), 'accounts');
|
||||
fs.writeFileSync(join(f.files.proxyDir, 'mosaic-keys.uncertain'), JSON.stringify({ org: 'acme', op: 'write', time: new Date(0).toISOString(), pid: 1 }), { mode: 0o600 });
|
||||
const fenced = await f.admin.status();
|
||||
assert.deepEqual(fenced.fence, { set: true, org: 'acme', op: 'write', time: new Date(0).toISOString(), invalid: false });
|
||||
assert.equal(fenced.next[0], 'unit-restart', 'a fence needs a restart first');
|
||||
await f.admin.unitAction('restart');
|
||||
assert.equal(await next(), 'fence-clear');
|
||||
});
|
||||
|
||||
test('the init lock clears through the controller only on the README conditions', async t => {
|
||||
const { dataRoot, admin, files } = fixture(t);
|
||||
await assert.rejects(admin.initLockClear(), code('proxy-init-lock-absent'));
|
||||
fs.writeFileSync(files.lock, '', { mode: 0o600 });
|
||||
fs.mkdirSync(files.proxyDir, { mode: 0o700 });
|
||||
await assert.rejects(admin.initLockClear(), code('proxy-init-lock-held'));
|
||||
assert.deepEqual((await admin.status()).initLock, { present: true, clearable: false, blockers: ['the proxy state directory exists'] });
|
||||
fs.rmdirSync(files.proxyDir);
|
||||
const result = await admin.initLockClear();
|
||||
assert.equal(result.receipt.op, 'init-lock.clear');
|
||||
assert.equal(fs.existsSync(files.lock), false);
|
||||
assert.deepEqual(receipts(dataRoot).map(r => r.op), ['init-lock.clear']);
|
||||
});
|
||||
|
||||
test('render replaces the home directory with ~', async t => {
|
||||
const { admin } = fixture(t);
|
||||
const { unit, text } = await admin.render();
|
||||
assert.equal(unit, 'mosaic-proxy-zz.service');
|
||||
if (process.env.HOME && process.env.HOME !== '/') assert.equal(text.includes(process.env.HOME), false);
|
||||
});
|
||||
|
||||
test('status carries the last receipts from proxy-admin.jsonl, so the page shows them after a reload', async t => {
|
||||
const { dataRoot, admin } = fixture(t, { unitState: { installed: true, active: 'active', healthy: true, pid: 11 } });
|
||||
assert.deepEqual((await admin.status()).receipts, []);
|
||||
for (let i = 0; i < 12; i++) await admin.unitAction('stop');
|
||||
fs.appendFileSync(receiptFile(dataRoot), '{"op":"torn');
|
||||
const { receipts } = await admin.status();
|
||||
assert.equal(receipts.length, 10);
|
||||
assert.ok(receipts.every(r => r.op === 'unit.stop'));
|
||||
});
|
||||
@@ -0,0 +1,195 @@
|
||||
// #1873 proxy setup host helpers: restart proof from a fake /proc, the stale init-lock rules,
|
||||
// unit control over a fake systemctl, and receipts that carry no path or key.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { PIN } from '../src/common.mjs';
|
||||
import { installPaths } from '../src/settings.mjs';
|
||||
import {
|
||||
processTree, pidListensOn, processStartMs, verifyRestart, initLockStatus, clearInitLock, initFiles, initState,
|
||||
fenceState, createUnitControl, releaseState, receiptFile, unitName,
|
||||
} from '../src/admin.mjs';
|
||||
|
||||
const code = name => error => error.code === name;
|
||||
const scratch = t => { const dir = fs.mkdtempSync(join(tmpdir(), 'pa-')); t.after(() => fs.rmSync(dir, { recursive: true, force: true })); return dir; };
|
||||
|
||||
// A fake /proc: runner pid 100 (start tick 500), its child 101 listens on `port` via socket inode 5555.
|
||||
function fakeProc(t, { port = 8080, listener = 101, start = 500 } = {}) {
|
||||
const proc = join(scratch(t), 'proc');
|
||||
const stat = (pid, ppid, ticks) => `${pid} (node) S ${ppid} 1 1 0 -1 0 0 0 0 0 0 0 0 0 20 0 1 0 ${ticks} 0 0\n`;
|
||||
fs.mkdirSync(join(proc, 'net'), { recursive: true });
|
||||
fs.writeFileSync(join(proc, 'stat'), 'cpu 0\nbtime 1000\n');
|
||||
for (const [pid, ppid, ticks] of [[1, 0, 1], [100, 1, start], [101, 100, start + 1], [200, 1, 1]]) {
|
||||
fs.mkdirSync(join(proc, String(pid), 'fd'), { recursive: true });
|
||||
fs.writeFileSync(join(proc, String(pid), 'stat'), stat(pid, ppid, ticks));
|
||||
}
|
||||
fs.symlinkSync('socket:[5555]', join(proc, String(listener), 'fd', '3'));
|
||||
fs.symlinkSync('/dev/null', join(proc, '100', 'fd', '0'));
|
||||
const hex = port.toString(16).toUpperCase().padStart(4, '0');
|
||||
fs.writeFileSync(join(proc, 'net', 'tcp'), ' sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode\n'
|
||||
+ ` 0: 0100007F:${hex} 00000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 5555 1 0 100 0 0 10 0\n`
|
||||
+ ` 1: 0100007F:${hex} 0100007F:9999 01 00000000:00000000 00:00000000 00000000 1000 0 7777 1 0 100 0 0 10 0\n`);
|
||||
return proc;
|
||||
}
|
||||
|
||||
test('processTree follows parent links; pidListensOn finds a listener in a child of the unit main pid', t => {
|
||||
const proc = fakeProc(t);
|
||||
assert.deepEqual(processTree(100, { proc }).sort(), [100, 101]);
|
||||
assert.equal(pidListensOn(100, 8080, { proc }), true);
|
||||
assert.equal(pidListensOn(200, 8080, { proc }), false, 'an unrelated process does not count');
|
||||
assert.equal(pidListensOn(100, 8081, { proc }), false, 'another port does not count');
|
||||
assert.equal(processStartMs(100, { proc }), 1000 * 1000 + 5000);
|
||||
assert.equal(processStartMs(999, { proc }), null);
|
||||
});
|
||||
|
||||
const evidence = (over = {}) => ({
|
||||
unit: 'mosaic-proxy.service', confirmed: true,
|
||||
before: { pid: 50, since: 10, active: 'active' }, after: { pid: 100, since: 20, active: 'active', listening: true },
|
||||
observedAt: new Date(2000).toISOString(), ...over,
|
||||
});
|
||||
const proof = { fence: { time: new Date(1000).toISOString() }, port: 8080, startMs: () => 1500, listens: (pid, port) => pid === 100 && port === 8080, now: () => 2000 };
|
||||
|
||||
test('verifyRestart accepts a confirmed restart to a new live listener started after the fence', () => {
|
||||
const result = verifyRestart(evidence(), proof);
|
||||
assert.deepEqual(result, { unit: 'mosaic-proxy.service', before: { pid: 50 }, after: { pid: 100, startedAt: new Date(1500).toISOString() }, observedAt: new Date(2000).toISOString(), listeningPort: 8080 });
|
||||
});
|
||||
|
||||
test('verifyRestart refuses every unproved restart with proxy-restart-unconfirmed', () => {
|
||||
const cases = [
|
||||
['not confirmed', evidence({ confirmed: false }), proof],
|
||||
['same pid', evidence({ before: { pid: 100, since: 10, active: 'active' } }), proof],
|
||||
['extra field', { ...evidence(), forged: true }, proof],
|
||||
['bad unit name', evidence({ unit: '../x.service' }), proof],
|
||||
['stale observation', evidence({ observedAt: new Date(2000).toISOString() }), { ...proof, now: () => 2000 + 11 * 60 * 1000 }],
|
||||
['process gone', evidence(), { ...proof, startMs: () => null }],
|
||||
['started before the fence', evidence(), { ...proof, startMs: () => 900 }],
|
||||
['fence without time', evidence(), { ...proof, fence: { invalid: true } }],
|
||||
['not listening', evidence(), { ...proof, listens: () => false }],
|
||||
['no port', evidence(), { ...proof, port: null }],
|
||||
['not an object', 'restarted', proof],
|
||||
];
|
||||
for (const [name, value, options] of cases) assert.throws(() => verifyRestart(value, options), code('proxy-restart-unconfirmed'), name);
|
||||
});
|
||||
|
||||
function dirs(t) {
|
||||
const root = scratch(t);
|
||||
const configDir = join(root, 'config'), dataRoot = join(root, 'data');
|
||||
fs.mkdirSync(configDir, { recursive: true, mode: 0o700 }); fs.mkdirSync(dataRoot, { recursive: true, mode: 0o700 });
|
||||
return { root, configDir, dataRoot, files: initFiles(configDir, dataRoot) };
|
||||
}
|
||||
|
||||
test('init lock: absent refuses, held names each README blocker, clear needs every condition and writes a receipt', t => {
|
||||
const { root, configDir, dataRoot, files } = dirs(t);
|
||||
const stopped = { stopped: true, running: [] };
|
||||
assert.deepEqual(initLockStatus({ configDir, dataRoot, buses: stopped }), { present: false, clearable: false, blockers: [] });
|
||||
assert.throws(() => clearInitLock({ configDir, dataRoot, buses: stopped }), code('proxy-init-lock-absent'));
|
||||
fs.writeFileSync(files.lock, '', { mode: 0o600 });
|
||||
fs.mkdirSync(files.proxyDir, { mode: 0o700 });
|
||||
const held = initLockStatus({ configDir, dataRoot, buses: { stopped: false, running: ['organization acme'] } });
|
||||
assert.equal(held.clearable, false);
|
||||
assert.deepEqual(held.blockers, ['bus running: organization acme', 'the proxy state directory exists']);
|
||||
assert.equal(JSON.stringify(held).includes(root), false, 'blockers name labels, never paths');
|
||||
assert.throws(() => clearInitLock({ configDir, dataRoot, buses: { stopped: false, running: ['the instance bus'] } }), code('proxy-init-lock-held'));
|
||||
assert.throws(() => clearInitLock({ configDir, dataRoot, buses: stopped }), code('proxy-init-lock-held'));
|
||||
assert.equal(fs.existsSync(files.lock), true);
|
||||
fs.rmdirSync(files.proxyDir);
|
||||
assert.throws(() => clearInitLock({ configDir, dataRoot, buses: undefined }), code('proxy-init-lock-held'), 'no bus proof counts as running');
|
||||
const receipt = clearInitLock({ configDir, dataRoot, buses: stopped, now: () => new Date(0) });
|
||||
assert.equal(fs.existsSync(files.lock), false);
|
||||
assert.deepEqual(receipt, { op: 'init-lock.clear', time: new Date(0).toISOString(), conditions: ['bus stopped', 'no proxy.json', 'no management key file', 'no proxy state directory'] });
|
||||
assert.deepEqual(fs.readFileSync(receiptFile(dataRoot), 'utf8').trim().split('\n').map(JSON.parse), [receipt]);
|
||||
assert.equal((fs.statSync(receiptFile(dataRoot)).mode & 0o777), 0o600);
|
||||
});
|
||||
|
||||
test('initState reports partial state from file presence and fenceState reads the fence without writing', t => {
|
||||
const { configDir, dataRoot, files } = dirs(t);
|
||||
assert.deepEqual(initState(configDir, dataRoot), { initialized: false, partial: false, problem: null, settings: null, files: { settings: false, key: false, proxyDir: false, lock: false }, lock: false });
|
||||
fs.mkdirSync(files.proxyDir, { mode: 0o700 });
|
||||
assert.equal(initState(configDir, dataRoot).partial, true);
|
||||
assert.equal(fenceState(dataRoot), null);
|
||||
fs.writeFileSync(join(files.proxyDir, 'mosaic-keys.uncertain'), JSON.stringify({ org: 'acme', op: 'apikey.add', time: '2026-11-11T00:00:00.000Z', pid: 7 }), { mode: 0o600 });
|
||||
assert.deepEqual(fenceState(dataRoot), { org: 'acme', op: 'apikey.add', time: '2026-11-11T00:00:00.000Z', pid: 7 });
|
||||
fs.chmodSync(join(files.proxyDir, 'mosaic-keys.uncertain'), 0o644);
|
||||
assert.deepEqual(fenceState(dataRoot), { invalid: true });
|
||||
assert.deepEqual(fs.readdirSync(files.proxyDir), ['mosaic-keys.uncertain']);
|
||||
});
|
||||
|
||||
test('releaseState: nothing fetched names the next step without a path', t => {
|
||||
const { root, dataRoot } = dirs(t);
|
||||
const state = releaseState(dataRoot);
|
||||
assert.equal(state.fetched, false); assert.equal(state.verified, false); assert.equal(state.problem, 'proxy-install-missing');
|
||||
assert.equal(JSON.stringify(state).includes(root), false);
|
||||
});
|
||||
|
||||
test('releaseState re-hashes a release file rewritten in place with its mtime set back', async t => {
|
||||
const { dataRoot } = dirs(t);
|
||||
const sha = bytes => createHash('sha256').update(bytes).digest('hex');
|
||||
const binary = Buffer.from('made-up proxy binary 0001'), panel = Buffer.from('<html>made-up panel</html>');
|
||||
const pin = { ...PIN, version: '0.0.0-test', binary: { ...PIN.binary, sha256: sha(binary) }, panel: { ...PIN.panel, sha256: sha(panel) } };
|
||||
const paths = installPaths(dataRoot, pin);
|
||||
fs.mkdirSync(join(paths.dir, 'static'), { recursive: true });
|
||||
fs.writeFileSync(paths.binary, binary, { mode: 0o755 }); fs.writeFileSync(paths.panel, panel);
|
||||
const when = 1700000000;
|
||||
for (const file of [paths.binary, paths.panel]) fs.utimesSync(file, when, when);
|
||||
assert.equal(releaseState(dataRoot, pin).verified, true);
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
const before = fs.statSync(paths.binary, { bigint: true });
|
||||
fs.writeFileSync(paths.binary, Buffer.from('made-up proxy binary 0002'), { flag: 'r+' });
|
||||
fs.utimesSync(paths.binary, when, when);
|
||||
const after = fs.statSync(paths.binary, { bigint: true });
|
||||
assert.deepEqual([after.ino, after.size, after.mtimeNs, after.mode], [before.ino, before.size, before.mtimeNs, before.mode], 'only ctime tells the files apart');
|
||||
const state = releaseState(dataRoot, pin);
|
||||
assert.equal(state.verified, false); assert.equal(state.problem, 'proxy-install-invalid');
|
||||
});
|
||||
|
||||
test('unit names follow the instance and refuse a bad instance', () => {
|
||||
assert.equal(unitName(null), 'mosaic-proxy.service');
|
||||
assert.equal(unitName('dev'), 'mosaic-proxy.service');
|
||||
assert.equal(unitName('uiorg'), 'mosaic-proxy-uiorg.service');
|
||||
assert.throws(() => unitName('../x'), code('proxy-unit-usage'));
|
||||
});
|
||||
|
||||
// A fake systemctl / proxy-service.sh. `states` is the sequence `systemctl show` reports.
|
||||
function fakeUnit(states, { install = { code: 0, stdout: '', stderr: '' }, verbs = [] } = {}) {
|
||||
let i = 0;
|
||||
const run = async (command, args) => {
|
||||
if (command === 'systemctl' && args[1] === 'show') {
|
||||
const s = states[Math.min(i++, states.length - 1)];
|
||||
return { code: 0, stdout: `LoadState=${s.load ?? 'loaded'}\nActiveState=${s.active}\nSubState=running\nMainPID=${s.pid ?? 0}\nExecMainStartTimestampMonotonic=${s.since ?? 0}\nNRestarts=0\n`, stderr: '' };
|
||||
}
|
||||
if (command === 'systemctl') { verbs.push(args[1]); return { code: 0, stdout: '', stderr: '' }; }
|
||||
verbs.push(args.filter(a => !a.endsWith('proxy-service.sh')).join(' '));
|
||||
return install;
|
||||
};
|
||||
return createUnitControl({ instance: 'zz', port: () => 18432, run, probe: async () => true, waitMs: 0, pollMs: 0, now: () => new Date(0) });
|
||||
}
|
||||
|
||||
test('unit restart is confirmed only when a new main process is active and listening', async () => {
|
||||
const verbs = [];
|
||||
const control = fakeUnit([{ active: 'active', pid: 10, since: 5 }, { active: 'active', pid: 11, since: 6 }], { verbs });
|
||||
const { evidence: e } = await control.restart();
|
||||
assert.equal(e.confirmed, true);
|
||||
assert.deepEqual(e.before, { pid: 10, since: 5, active: 'active' });
|
||||
assert.deepEqual(e.after, { pid: 11, since: 6, active: 'active', listening: true });
|
||||
assert.equal(e.unit, 'mosaic-proxy-zz.service');
|
||||
assert.deepEqual(verbs, ['restart']);
|
||||
const same = await fakeUnit([{ active: 'active', pid: 10, since: 5 }, { active: 'active', pid: 10, since: 5 }]).restart();
|
||||
assert.equal(same.evidence.confirmed, false, 'the same process is not a restart');
|
||||
const down = await fakeUnit([{ active: 'active', pid: 10, since: 5 }, { active: 'failed', pid: 0, since: 0 }]).restart();
|
||||
assert.equal(down.evidence.confirmed, false, 'a failed unit is not a restart');
|
||||
});
|
||||
|
||||
test('unit actions map proxy-service refusals by code and refuse a missing unit', async () => {
|
||||
await assert.rejects(fakeUnit([{ load: 'not-found', active: 'inactive' }]).start(), code('proxy-unit-missing'));
|
||||
await assert.rejects(fakeUnit([{ load: 'not-found', active: 'inactive' }]).restart(), code('proxy-unit-missing'));
|
||||
await assert.rejects(fakeUnit([{ active: 'inactive' }], { install: { code: 3, stdout: '', stderr: 'proxy-service: proxy-unit-exists\n' } }).install(), code('proxy-unit-exists'));
|
||||
await assert.rejects(fakeUnit([{ active: 'inactive' }], { install: { code: 3, stdout: '', stderr: 'proxy-service: /home/x secret detail\n' } }).install(), code('proxy-unit-failed'));
|
||||
await assert.rejects(fakeUnit([{ active: 'active', pid: 0 }, { active: 'failed', pid: 0 }]).start(), code('proxy-unit-unhealthy'));
|
||||
const verbs = [];
|
||||
const started = await fakeUnit([{ active: 'inactive' }, { active: 'active', pid: 9, since: 1 }], { verbs }).start();
|
||||
assert.equal(started.healthy, true); assert.deepEqual(verbs, ['start']);
|
||||
assert.throws(() => createUnitControl({ unitDir: 'relative' }), code('proxy-unit-path'));
|
||||
});
|
||||
@@ -0,0 +1,318 @@
|
||||
// Copy credential bindings, slices CB0 and CB1 (#1873; design
|
||||
// docs/plans/2026-11-11_copy-credential-binding-design.md sections 3, 5 and 7).
|
||||
//
|
||||
// An organization binds a service to one concrete copy (a root agent's copy or a project agent's
|
||||
// copy) through a record under the organization file's top-level `copyBindings`, keyed by the
|
||||
// copy's instance name. A record holds identities, known scope names, project ids and integers:
|
||||
// never a token, a token fragment or digest, a path or an environment name.
|
||||
//
|
||||
// { copyId, orgIncarnation, source: { level, name, project }, definition: { ref, rev, sha256 },
|
||||
// generation, state, services: { gitea?: { slot, scopes, resources }, vikunja?: {...} },
|
||||
// decision, tracker? }
|
||||
//
|
||||
// copyId is 128 random bits minted on the first approval for that copy; it is kept, with the
|
||||
// generation counting up, through every later change and the unbound tombstone. orgIncarnation is
|
||||
// the registry's (org-registry.mjs): a record copied from another organization, or from a removed
|
||||
// organization with the same id, never matches. `source` is the copy's level, name and project, so
|
||||
// a dotted-name collision or a reused source name never inherits a record. `definition` pins the
|
||||
// copy's definition identity: an org definition by its rev and the hash of its whole entry
|
||||
// (composition, instructions and personality), a role template by the hash readTemplate takes over
|
||||
// its file and contract.
|
||||
//
|
||||
// States: `pending` is approved and not verified. Only a verifier (slice CB2, not built) may turn a
|
||||
// record `bound`, and only `bound` resolves. `unbound` is the tombstone an unbind leaves. Nothing
|
||||
// in CB1 writes `bound`, so no copy binding resolves and no copy launches with one.
|
||||
//
|
||||
// Everything here is pure: no file is read, no secret, no slot.
|
||||
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { refuse } from "./errors.mjs";
|
||||
import { GITEA_SCOPE_CATEGORIES, HOST_SERVICE, ID_PATTERN, SERVICES, VIKUNJA_GRANTABLE } from "./vocabulary.mjs";
|
||||
import { ORG_PREFIX } from "./definitions.mjs";
|
||||
import { canonicalJson, isPlainObject, sha256 } from "./util.mjs";
|
||||
|
||||
export const COPY_ID = /^[0-9a-f]{32}$/;
|
||||
export const INCARNATION = /^[0-9a-f]{32}$/;
|
||||
const SHA256 = /^[0-9a-f]{64}$/;
|
||||
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
||||
const BOT_NAME = /^bot-[a-z0-9][a-z0-9._-]{0,60}$/;
|
||||
const GITEA_SCOPE = /^(read|write):([a-z]+)$/;
|
||||
const RECORD_KEYS = ["copyId", "orgIncarnation", "source", "definition", "generation", "state", "services", "decision", "tracker"];
|
||||
const SERVICE_KEYS = ["slot", "scopes", "resources"];
|
||||
export const BINDING_STATES = Object.freeze(["pending", "bound", "unbound"]);
|
||||
// CB1 binds the core services (vocabulary SERVICES) only. A plugin service has no verifier design yet.
|
||||
export const BINDABLE_SERVICES = SERVICES;
|
||||
export const MAX_BINDINGS = 256;
|
||||
|
||||
export const newCopyId = () => randomBytes(16).toString("hex");
|
||||
|
||||
// Fixed text for every binding refusal. `service` is a registered service label or null, never a
|
||||
// submitted value.
|
||||
const TEXT = {
|
||||
"human-required": () => "Only the organization owner can approve credential binding.",
|
||||
"credential-unbound": (s) => `credentials.${s} needs an owner-approved binding.`,
|
||||
"credential-scope-exceeded": (s) => `credentials.${s}.scopes exceeds the approved role.`,
|
||||
"credential-scope-unverified": (s) => `credentials.${s} permissions could not be verified.`,
|
||||
"credential-binding-changed": (s) => `credentials.${s} changed; read and approve again.`,
|
||||
"credential-role-changed": (s) => `credentials.${s} no longer matches the approved definition; the owner must review the binding.`,
|
||||
"copy-unavailable": () => "copy is no longer active in this organization.",
|
||||
"copy-refused": () => "copy identity is ambiguous; the owner must resolve the definition collision.",
|
||||
};
|
||||
const label = (service) => (BINDABLE_SERVICES.includes(service) ? service : "service");
|
||||
export function bindingText(code, service = null) {
|
||||
const text = TEXT[code];
|
||||
return text ? text(label(service)) : code;
|
||||
}
|
||||
|
||||
export class CopyBindingError extends Error {
|
||||
constructor(code, service = null) {
|
||||
super(bindingText(code, service));
|
||||
this.name = "CopyBindingError";
|
||||
this.code = code;
|
||||
if (service !== null) this.service = label(service);
|
||||
}
|
||||
}
|
||||
export const bindingFail = (code, service = null) => { throw new CopyBindingError(code, service); };
|
||||
|
||||
// ---- Definition pin ----
|
||||
|
||||
// The identity a binding pins for a copy's definition. `definitions` is the merged root and org
|
||||
// definition map as the boot snapshot holds it; `readTemplate` reads a role template (definitions.mjs).
|
||||
// An org definition: its rev and the hash of its canonical entry. A template: rev null and the
|
||||
// template hash. A version 1 template has no contract and cannot be pinned: null.
|
||||
export function definitionPin(ref, { definitions = {}, rolesDir, readTemplate }) {
|
||||
if (typeof ref !== "string") return null;
|
||||
if (ref.startsWith(ORG_PREFIX)) {
|
||||
const name = ref.slice(ORG_PREFIX.length);
|
||||
const entry = Object.hasOwn(definitions, name) ? definitions[name] : null;
|
||||
if (!isPlainObject(entry) || !Number.isSafeInteger(entry.rev) || entry.rev < 1) return null;
|
||||
return { ref, rev: entry.rev, sha256: sha256(canonicalJson(entry)) };
|
||||
}
|
||||
if (!ID_PATTERN.test(ref) || typeof readTemplate !== "function") return null;
|
||||
try {
|
||||
return { ref, rev: null, sha256: readTemplate(rolesDir, ref).sha256 };
|
||||
} catch (error) {
|
||||
if (error?.name === "BusinessError") return null;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
export const samePin = (a, b) => isPlainObject(a) && isPlainObject(b) && a.ref === b.ref && a.rev === b.rev && a.sha256 === b.sha256;
|
||||
|
||||
// ---- Scope semantics ----
|
||||
|
||||
// Gitea: {category: "read"|"write"}, one level per category. admin and all are never granted.
|
||||
function giteaLevels(scopes) {
|
||||
if (!Array.isArray(scopes)) return null;
|
||||
const out = {};
|
||||
for (const scope of scopes) {
|
||||
const m = typeof scope === "string" ? GITEA_SCOPE.exec(scope) : null;
|
||||
if (!m || !GITEA_SCOPE_CATEGORIES.includes(m[2]) || Object.hasOwn(out, m[2])) return null;
|
||||
out[m[2]] = m[1];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
const RANK = { read: 1, write: 2 };
|
||||
// Vikunja: the set of "group verb" pairs, each one VIKUNJA_GRANTABLE lists.
|
||||
function vikunjaPairs(scopes) {
|
||||
if (!isPlainObject(scopes)) return null;
|
||||
const out = new Set();
|
||||
for (const [group, verbs] of Object.entries(scopes)) {
|
||||
if (!Object.hasOwn(VIKUNJA_GRANTABLE, group) || !Array.isArray(verbs) || verbs.length === 0) return null;
|
||||
for (const verb of verbs) {
|
||||
if (typeof verb !== "string" || !VIKUNJA_GRANTABLE[group].includes(verb) || out.has(`${group} ${verb}`)) return null;
|
||||
out.add(`${group} ${verb}`);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
export function validScopes(service, scopes) {
|
||||
if (service === "gitea") { const l = giteaLevels(scopes); return l !== null && Object.keys(l).length > 0; }
|
||||
if (service === "vikunja") { const p = vikunjaPairs(scopes); return p !== null && p.size > 0; }
|
||||
return false;
|
||||
}
|
||||
|
||||
// Whether `approved` is within `role` for the service, compared by the service's semantics: Gitea
|
||||
// read is narrower than write in one category and categories are independent; Vikunja compares
|
||||
// each group and verb pair. A missing category or group is no permission. Never a string compare.
|
||||
export function scopesWithin(service, approved, role) {
|
||||
if (service === "gitea") {
|
||||
const a = giteaLevels(approved), r = giteaLevels(role);
|
||||
if (!a || !r) return false;
|
||||
return Object.entries(a).every(([category, level]) => Object.hasOwn(r, category) && RANK[level] <= RANK[r[category]]);
|
||||
}
|
||||
if (service === "vikunja") {
|
||||
const a = vikunjaPairs(approved), r = vikunjaPairs(role);
|
||||
if (!a || !r) return false;
|
||||
return [...a].every((pair) => r.has(pair));
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// effective(V) = role-declared(V) intersect approved(V), as scopes of the service's own shape.
|
||||
// Null when the intersection is empty.
|
||||
export function intersectScopes(service, approved, role) {
|
||||
if (service === "gitea") {
|
||||
const a = giteaLevels(approved), r = giteaLevels(role);
|
||||
if (!a || !r) return null;
|
||||
const out = Object.keys(a).filter((c) => Object.hasOwn(r, c)).sort()
|
||||
.map((c) => `${RANK[a[c]] <= RANK[r[c]] ? a[c] : r[c]}:${c}`);
|
||||
return out.length ? out : null;
|
||||
}
|
||||
if (service === "vikunja") {
|
||||
const a = vikunjaPairs(approved), r = vikunjaPairs(role);
|
||||
if (!a || !r) return null;
|
||||
const out = {};
|
||||
for (const pair of [...a].filter((p) => r.has(p)).sort()) {
|
||||
const [group, verb] = pair.split(" ");
|
||||
(out[group] ??= []).push(verb);
|
||||
}
|
||||
return Object.keys(out).length ? out : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// The role's declared scopes per bindable service. A host service is bound per host elsewhere and
|
||||
// is not a copy binding; a service outside the core pair cannot be bound in CB1.
|
||||
export function roleServices(role) {
|
||||
const out = {};
|
||||
for (const c of role?.credentials ?? []) {
|
||||
if (c.service === HOST_SERVICE) continue;
|
||||
out[c.service] = c.scopes;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ---- Record schema ----
|
||||
|
||||
// The positional refusal for record `i` (1-based): it never names a submitted key or value.
|
||||
const at = (i, field) => `copyBindings entry ${i} ${field} field is invalid`;
|
||||
|
||||
// Checks the whole `copyBindings` value of an organization document. `trackers` is the list of
|
||||
// {bot, botId} the document's instances and its tracker sync already use; a copy's tracker bot is
|
||||
// unique against them and against other copies. Throws a BusinessError with a positional message.
|
||||
export function validateCopyBindings(value, { trackers = [] } = {}) {
|
||||
if (value === undefined) return;
|
||||
if (!isPlainObject(value)) refuse("copyBindings field is invalid");
|
||||
const keys = Object.keys(value);
|
||||
if (keys.length > MAX_BINDINGS) refuse("copyBindings field is invalid");
|
||||
const bots = new Set(trackers.map((t) => t.bot)), botIds = new Set(trackers.map((t) => t.botId)), copyIds = new Set();
|
||||
keys.forEach((instance, n) => {
|
||||
const i = n + 1, r = value[instance];
|
||||
if (!ID_PATTERN.test(instance)) refuse(`copyBindings entry ${i} name is invalid`);
|
||||
if (!isPlainObject(r)) refuse(`copyBindings entry ${i} is invalid`);
|
||||
const unknown = Object.keys(r).some((k) => !RECORD_KEYS.includes(k));
|
||||
if (unknown) refuse(`copyBindings entry ${i} has an unknown field`);
|
||||
if (typeof r.copyId !== "string" || !COPY_ID.test(r.copyId)) refuse(at(i, "copyId"));
|
||||
if (copyIds.has(r.copyId)) refuse(at(i, "copyId"));
|
||||
copyIds.add(r.copyId);
|
||||
if (typeof r.orgIncarnation !== "string" || !INCARNATION.test(r.orgIncarnation)) refuse(at(i, "orgIncarnation"));
|
||||
const s = r.source;
|
||||
if (!isPlainObject(s) || Object.keys(s).sort().join(",") !== "level,name,project"
|
||||
|| !["root", "project"].includes(s.level) || typeof s.name !== "string" || !ID_PATTERN.test(s.name)
|
||||
|| (s.level === "root" ? s.project !== null : typeof s.project !== "string" || !ID_PATTERN.test(s.project))
|
||||
|| (s.level === "root" ? s.name : `${s.name}.${s.project}`) !== instance) refuse(at(i, "source"));
|
||||
const d = r.definition;
|
||||
if (!isPlainObject(d) || Object.keys(d).sort().join(",") !== "ref,rev,sha256" || typeof d.ref !== "string"
|
||||
|| typeof d.sha256 !== "string" || !SHA256.test(d.sha256)
|
||||
|| (d.ref.startsWith(ORG_PREFIX)
|
||||
? !ID_PATTERN.test(d.ref.slice(ORG_PREFIX.length)) || !Number.isSafeInteger(d.rev) || d.rev < 1
|
||||
: !ID_PATTERN.test(d.ref) || d.rev !== null)) refuse(at(i, "definition"));
|
||||
if (!Number.isSafeInteger(r.generation) || r.generation < 1) refuse(at(i, "generation"));
|
||||
if (!BINDING_STATES.includes(r.state)) refuse(at(i, "state"));
|
||||
if (typeof r.decision !== "string" || !UUID.test(r.decision)) refuse(at(i, "decision"));
|
||||
if (!isPlainObject(r.services)) refuse(at(i, "services"));
|
||||
const services = Object.keys(r.services);
|
||||
if ((r.state === "unbound") !== (services.length === 0)) refuse(at(i, "services"));
|
||||
for (const service of services) {
|
||||
const v = r.services[service];
|
||||
if (!BINDABLE_SERVICES.includes(service) || !isPlainObject(v) || Object.keys(v).sort().join(",") !== SERVICE_KEYS.slice().sort().join(",")
|
||||
|| v.slot !== "self" || !validScopes(service, v.scopes)) refuse(at(i, "services"));
|
||||
const p = v.resources;
|
||||
if (!isPlainObject(p) || Object.keys(p).join(",") !== "projects" || !Array.isArray(p.projects) || p.projects.length === 0
|
||||
|| p.projects.some((x) => typeof x !== "string" || !ID_PATTERN.test(x)) || new Set(p.projects).size !== p.projects.length
|
||||
|| (s.level === "project" && (p.projects.length !== 1 || p.projects[0] !== s.project))) refuse(at(i, "services"));
|
||||
}
|
||||
if (r.tracker !== undefined) {
|
||||
const t = r.tracker;
|
||||
if (!services.includes("vikunja") || !isPlainObject(t) || Object.keys(t).sort().join(",") !== "bot,botId"
|
||||
|| typeof t.bot !== "string" || !BOT_NAME.test(t.bot) || !Number.isSafeInteger(t.botId) || t.botId < 1) refuse(at(i, "tracker"));
|
||||
if (bots.has(t.bot) || botIds.has(t.botId)) refuse(`copyBindings entry ${i} tracker field uses a bot another instance uses`);
|
||||
bots.add(t.bot);
|
||||
botIds.add(t.botId);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Resolver (CB0) ----
|
||||
|
||||
// What a copy's binding grants in this organization, from plain inputs only:
|
||||
// orgId, incarnation the organization and its live registry incarnation (null: none)
|
||||
// snapshot the boot snapshot materializeCopies made for orgId
|
||||
// instance the copy's instance name
|
||||
// record copyBindings[instance] from the organization document, or undefined
|
||||
// pin definitionPin of the copy's current definition (null: cannot pin)
|
||||
// role the copy's current resolved role ({credentials: [...]})
|
||||
// projects the project ids that admit this copy now: for a root copy, the projects
|
||||
// whose union holds it (an excluded or refusing project is left out), not
|
||||
// the organization's whole project list
|
||||
// Returns { ok: true, copyId, generation, services: { V: { scopes, projects } } } when every
|
||||
// service the role needs is bound, or { ok: false, code, service } with the first refusal. Only a
|
||||
// `bound` record resolves; pending, partial, tombstoned, foreign and stale records never do.
|
||||
export function resolveCopyBinding({ orgId, incarnation, snapshot, instance, record, pin, role, projects = [] }) {
|
||||
const no = (code, service = null) => ({ ok: false, code, service: service === null ? null : label(service) });
|
||||
if (!snapshot || snapshot.org !== orgId) return no("copy-unavailable");
|
||||
const copy = Object.hasOwn(snapshot.copies ?? {}, instance) ? snapshot.copies[instance] : null;
|
||||
// An org instance of the same name shadows the copy, and a removed or excluded source makes none.
|
||||
if (!copy) return no("copy-unavailable");
|
||||
if (copy.state === "refused") return no(copy.claims ? "copy-refused" : "copy-unavailable");
|
||||
const needed = roleServices(role);
|
||||
const first = Object.keys(needed).sort()[0] ?? null;
|
||||
if (record === undefined || record === null) return no("credential-unbound", first);
|
||||
if (typeof incarnation !== "string" || !INCARNATION.test(incarnation) || record.orgIncarnation !== incarnation) return no("credential-unbound", first);
|
||||
const source = { level: copy.level, name: copy.name, project: copy.project };
|
||||
if (canonicalJson(record.source) !== canonicalJson(source)) return no("credential-unbound", first);
|
||||
if (!pin || !samePin(record.definition, pin) || record.definition.ref !== copy.definition) return no("credential-role-changed", first);
|
||||
if (record.state !== "bound") return no("credential-unbound", first);
|
||||
const approved = record.services ?? {};
|
||||
for (const service of Object.keys(approved).sort()) {
|
||||
if (!Object.hasOwn(needed, service)) return no("credential-scope-exceeded", service);
|
||||
if (!scopesWithin(service, approved[service].scopes, needed[service])) return no("credential-scope-exceeded", service);
|
||||
}
|
||||
const admitted = new Set(copy.level === "project" ? (projects.includes(copy.project) ? [copy.project] : []) : projects);
|
||||
const services = {};
|
||||
for (const service of Object.keys(needed).sort()) {
|
||||
if (!BINDABLE_SERVICES.includes(service) || !Object.hasOwn(approved, service)) return no("credential-unbound", service);
|
||||
const scopes = intersectScopes(service, approved[service].scopes, needed[service]);
|
||||
const reach = approved[service].resources.projects.filter((p) => admitted.has(p)).sort();
|
||||
if (!scopes || reach.length === 0) return no("credential-unbound", service);
|
||||
services[service] = { scopes, projects: reach };
|
||||
}
|
||||
if (!Object.keys(services).length) return no("credential-unbound", first);
|
||||
return { ok: true, copyId: record.copyId, generation: record.generation, services };
|
||||
}
|
||||
|
||||
// ---- Proposal checks (CB1) ----
|
||||
|
||||
// The binding payload a chief proposes: { service: { scopes, projects } } for a bind, or null for
|
||||
// an unbind. Each service must be one the copy's role declares, its scopes within the role and its
|
||||
// projects admitted for the copy (`projects`: for a root copy, the projects whose union holds it).
|
||||
// Never trims a broader request. Returns the normalized services.
|
||||
export function checkBindingRequest(services, { copy, role, projects }) {
|
||||
if (services === null) return null;
|
||||
if (!isPlainObject(services) || Object.keys(services).length === 0) refuse("binding services field is invalid");
|
||||
const needed = roleServices(role);
|
||||
const out = {};
|
||||
for (const service of Object.keys(services).sort()) {
|
||||
if (!BINDABLE_SERVICES.includes(service)) refuse("binding services field is invalid");
|
||||
const v = services[service];
|
||||
if (!isPlainObject(v) || Object.keys(v).sort().join(",") !== "projects,scopes") refuse("binding services field is invalid");
|
||||
if (!validScopes(service, v.scopes)) refuse("binding scopes field is invalid");
|
||||
if (!Object.hasOwn(needed, service) || !scopesWithin(service, v.scopes, needed[service])) bindingFail("credential-scope-exceeded", service);
|
||||
if (!Array.isArray(v.projects) || v.projects.length === 0 || v.projects.some((p) => typeof p !== "string" || !ID_PATTERN.test(p))
|
||||
|| new Set(v.projects).size !== v.projects.length) refuse("binding projects field is invalid");
|
||||
const allowed = copy.level === "project" ? [copy.project] : projects;
|
||||
if (v.projects.some((p) => !allowed.includes(p))) bindingFail("credential-scope-exceeded", service);
|
||||
out[service] = { slot: "self", scopes: structuredClone(v.scopes), resources: { projects: [...v.projects].sort() } };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -13,14 +13,29 @@ import { OrganizationError } from './organization.mjs';
|
||||
import { resolveInstance } from './resolve.mjs';
|
||||
import { checkRoutes } from './launch-settings.mjs';
|
||||
import { VERB_RIGHTS } from './org-rights.mjs';
|
||||
import { readRegistry, withRegistryWriter } from './org-registry.mjs';
|
||||
import { ensureOrgIncarnation, orgIncarnation, readRegistry, withRegistryWriter } from './org-registry.mjs';
|
||||
import { checkDefinitions } from './definitions.mjs';
|
||||
import { loadRole } from './role.mjs';
|
||||
import { copyInstanceName } from './agent-copies.mjs';
|
||||
import { resolveAgentLevels } from './agent-levels.mjs';
|
||||
import { bindingFail, checkBindingRequest, definitionPin, newCopyId, samePin } from './copy-bindings.mjs';
|
||||
const fail = code => { throw new OrganizationError(code); };
|
||||
const hash = x => createHash('sha256').update(canonicalJson(x)).digest('hex');
|
||||
const UUID = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/;
|
||||
const keys = (x, allowed, required = []) => { if (!isPlainObject(x) || Object.keys(x).some(k => !allowed.includes(k)) || required.some(k => !Object.hasOwn(x,k))) fail('invalid-request'); };
|
||||
const id = x => { if (typeof x !== 'string' || !ID_PATTERN.test(x) || ['human','tester','user'].includes(x)) fail('invalid-request'); };
|
||||
const requestKeys = ['op','level','project','name','role','credentials','personality','route','reason'];
|
||||
const requestKeys = ['op','level','project','name','role','credentials','personality','route','reason','binding'];
|
||||
// #1873 CB1: a copy credential binding (bind or unbind) is owner-only. No agent resolves it, whatever
|
||||
// authority decision or resolver it carries; a person needs org.hire and must be the current owner
|
||||
// of the organization. An instance operator alone is not.
|
||||
const bindingOwnerOnly = () => fail('human-required');
|
||||
export function authorizeHire(proposal, caller) {
|
||||
if(proposal?.binding!==undefined) {
|
||||
if(!caller || caller.agent!==false)bindingOwnerOnly();
|
||||
if(!caller.rights?.includes(VERB_RIGHTS['hire.resolve.console']))fail('right-required');
|
||||
if(caller.owner!==true)bindingOwnerOnly();
|
||||
return;
|
||||
}
|
||||
if(caller?.agent===true) {
|
||||
if(typeof caller.authorityDecision!=='string' || typeof caller.resolver!=='string')fail('human-required');
|
||||
if(caller.authorityDecision!==proposal.id || !caller.rights?.includes('decision.resolve.authority') || proposal.flagged.length || proposal.credentialLines.length || proposal.level==='root' || caller.resolver===proposal.name)fail('hire-owner-required');
|
||||
@@ -38,12 +53,16 @@ export function credentialLines(credentials) {
|
||||
const publicOf = p => ({ id:p.id, revision:p.revision, op:p.request.op, level:p.request.level, name:p.request.name,
|
||||
...(p.request.project === undefined ? {} : {project:p.request.project}), route:p.request.route ?? null,
|
||||
...(p.entry ? {role:structuredClone(p.entry.role)} : {}),
|
||||
// #1873 CB1: ids, scope names, project ids and integers. Never a token, a slot path or a file path.
|
||||
...(p.kind === 'binding' ? {binding:structuredClone(p.binding)} : {}),
|
||||
// reason and all free text stay in the private proposal as well.
|
||||
flagged:p.flagged, credentialLines:p.credentialLines, expiresAt:p.expiresAt, digest:hash(p) });
|
||||
// org-defscan (#1873): `scanDefinition(name, entry)` is the bus's definition check on the approved
|
||||
// entry; it throws (definition-holds-credential, naming the definition and key) when the live store
|
||||
// holds text in it. Without one, the entry still goes through assertClean whole.
|
||||
export function hireDecisions({ writer, rolesDir, configDir, dataRoot, assertClean, scanDefinition, system = {}, levels = () => null, catalogueFor = async () => null, registryFor = () => buildRegistry() }) {
|
||||
// `copies(business)` (#1873 CB1) is the bus's boot snapshot of this organization's copies
|
||||
// (agent-copies.mjs materializeCopies). A bus without one refuses every binding: copy-unavailable.
|
||||
export function hireDecisions({ writer, rolesDir, configDir, dataRoot, assertClean, scanDefinition, system = {}, levels = () => null, catalogueFor = async () => null, registryFor = () => buildRegistry(), copies = () => null }) {
|
||||
const scanEntry = (name, entry) => {
|
||||
if (typeof scanDefinition === 'function') return scanDefinition(name, structuredClone(entry));
|
||||
if (typeof assertClean !== 'function') fail('definition-secret-check-required');
|
||||
@@ -83,12 +102,97 @@ export function hireDecisions({ writer, rolesDir, configDir, dataRoot, assertCle
|
||||
}
|
||||
const location = value => { if (!UUID.test(value)) fail('invalid-request'); return join(home,`${value}.json`); };
|
||||
const read = value => { try { return readJsonFile(location(value),'private hire proposal',st=>{if(st.uid!==process.getuid() || st.mode&0o077 || st.nlink!==1)fail('decision-stale');}); } catch { fail('decision-stale'); } };
|
||||
// ---- #1873 CB1: copy credential binding subtype ----
|
||||
// The copy, its pinned definition and role, as this bus booted them and the file holds them now.
|
||||
// Every refusal is a fixed code; nothing names a token, a slot or a path.
|
||||
const bindingFacts = (business, request, doc) => {
|
||||
const snapshot = copies(business);
|
||||
const project = request.level === 'project' ? request.project : null;
|
||||
const instance = copyInstanceName(request.name, project);
|
||||
const copy = instance !== null && snapshot?.org === business && Object.hasOwn(snapshot.copies ?? {}, instance) ? snapshot.copies[instance] : null;
|
||||
if (!copy || copy.name !== request.name || copy.project !== project || copy.level !== request.level) fail('copy-unavailable');
|
||||
if (copy.state === 'refused') fail(copy.claims ? 'copy-refused' : 'copy-unavailable');
|
||||
// A root copy is only as fresh as its root receipts: removed, replaced or unreadable since boot refuses.
|
||||
if (copy.level === 'root') { const stale = rootRefusal(copy.name); if (stale) fail(stale); }
|
||||
// A named instance added since boot shadows the copy (design section 6).
|
||||
if (Object.hasOwn(doc.roles ?? {}, instance)) fail('copy-unavailable');
|
||||
const definitions = { ...(snapshot.definitions ?? {}), ...(doc.definitions ?? {}) };
|
||||
const pin = definitionPin(copy.definition, { definitions, rolesDir, readTemplate });
|
||||
if (!pin) fail('credential-definition-unpinned');
|
||||
const role = copy.definition.startsWith('org:') ? checkDefinitions(definitions, 'copy definitions')[copy.definition] : loadRole(rolesDir, copy.definition);
|
||||
if (!role) fail('credential-definition-unpinned');
|
||||
// The projects that admit this copy now. A root copy reaches only the projects whose union,
|
||||
// recomputed from this document and the boot's root agents, still holds it: an exclude entry or a
|
||||
// refusing union bars it, and a request naming that project refuses (credential-scope-exceeded).
|
||||
const code = Object.keys(doc.projects ?? {}).filter(p => doc.projects[p]?.kind !== 'library');
|
||||
let projects = code;
|
||||
if (copy.level === 'root') {
|
||||
let levels;
|
||||
try { levels = resolveAgentLevels({ orgId: business, doc: { ...doc, definitions }, root: { agents: snapshot.rootAgents ?? {} }, rolesDir }); }
|
||||
catch (error) { if (error?.name === 'BusinessError') fail('copy-unavailable'); throw error; }
|
||||
projects = code.filter(p => levels.unions[p]?.ok && levels.unions[p].agents.some(a => a.level === 'root' && a.name === copy.name));
|
||||
}
|
||||
return { instance, copy, pin, role, definitions, projects, source: { level: copy.level, name: copy.name, project } };
|
||||
};
|
||||
const bindingLines = services => credentialLines(Object.entries(services ?? {}).map(([service, v]) => ({ service, scopes: v.scopes })));
|
||||
async function prepareBinding(business, input, { id: decision, expiresAt }) {
|
||||
if (input.op !== 'change' || !['root','project'].includes(input.level) || ['role','route','credentials','personality'].some(k => k in input)) fail('invalid-request');
|
||||
if (input.level === 'project') id(input.project); else if ('project' in input) fail('invalid-request');
|
||||
keys(input.binding, ['services'], ['services']);
|
||||
const { document: doc, revision } = writer.read(business);
|
||||
const facts = bindingFacts(business, input, doc);
|
||||
let after;
|
||||
try { after = checkBindingRequest(input.binding.services, { copy: facts.copy, role: facts.role, projects: facts.projects }); }
|
||||
catch (error) { if (error?.name === 'CopyBindingError') fail(error.code); fail('invalid-request'); }
|
||||
const current = doc.copyBindings?.[facts.instance] ?? null;
|
||||
if (current && canonicalJson(current.source) !== canonicalJson(facts.source)) fail('credential-binding-changed');
|
||||
if (after === null && (!current || current.state === 'unbound')) fail('credential-unbound');
|
||||
// An org reserved before CB0 gets its incarnation here, under the registry lock.
|
||||
const incarnation = ensureOrgIncarnation(configDir, business);
|
||||
if (incarnation === null) fail('copy-unavailable');
|
||||
if (current && current.orgIncarnation !== incarnation) fail('credential-binding-changed');
|
||||
const binding = {
|
||||
instance: facts.instance, source: facts.source, copyId: current?.copyId ?? newCopyId(), fresh: !current, orgIncarnation: incarnation,
|
||||
definition: facts.pin, before: current && current.state !== 'unbound' ? structuredClone(current.services) : null, after, generation: current?.generation ?? 0,
|
||||
};
|
||||
// Only the structured fields are kept. The free-text reason is never written to the proposal or
|
||||
// covered by its digest: a chief may paste a path or a token fragment there by mistake.
|
||||
const request = { op: input.op, level: input.level, name: input.name, ...(input.level === 'project' ? { project: input.project } : {}), binding: { services: structuredClone(input.binding.services) } };
|
||||
const p = { id: decision, business, kind: 'binding', prior: null, revision, rootRevision: null, request, entry: null, definition: null,
|
||||
binding, flagged: [], credentialLines: bindingLines(after ?? binding.before), expiresAt };
|
||||
mkdirSync(home, { recursive: true, mode: 0o700 });
|
||||
const st = lstatSync(home); if (!st.isDirectory() || st.isSymbolicLink() || st.uid !== process.getuid() || st.mode & 0o077) fail('organization-refused');
|
||||
const fd = openSync(location(decision), 'wx', 0o600);
|
||||
try { writeFileSync(fd, JSON.stringify(p)); fsyncSync(fd); } finally { closeSync(fd); }
|
||||
return publicOf(p);
|
||||
}
|
||||
// Everything the approval pinned, read again before any effect: the file revision, the copy, its
|
||||
// definition, the registry incarnation, the generation and the requested scopes.
|
||||
async function checkBinding(business, p) {
|
||||
const { document: doc, revision } = writer.read(business);
|
||||
if (revision !== p.revision) fail('decision-stale');
|
||||
const facts = bindingFacts(business, p.request, doc);
|
||||
const b = p.binding;
|
||||
if (facts.instance !== b.instance || canonicalJson(facts.source) !== canonicalJson(b.source)) fail('credential-binding-changed');
|
||||
if (!samePin(facts.pin, b.definition)) fail('credential-role-changed');
|
||||
if (orgIncarnation(await readRegistry({ configDir }), business) !== b.orgIncarnation) fail('decision-stale');
|
||||
const current = doc.copyBindings?.[b.instance] ?? null;
|
||||
if ((current?.generation ?? 0) !== b.generation || (current ? current.copyId !== b.copyId : !b.fresh)) fail('credential-binding-changed');
|
||||
if (b.after !== null) {
|
||||
let after;
|
||||
try { after = checkBindingRequest(p.request.binding.services, { copy: facts.copy, role: facts.role, projects: facts.projects }); }
|
||||
catch (error) { if (error?.name === 'CopyBindingError') fail(error.code); fail('invalid-request'); }
|
||||
if (canonicalJson(after) !== canonicalJson(b.after)) fail('decision-stale');
|
||||
}
|
||||
return facts;
|
||||
}
|
||||
async function prepare(business, input, { id: decision, expiresAt }) {
|
||||
keys(input,requestKeys,['op','level','name','reason']); id(input.name);
|
||||
if (!['hire','fire','change'].includes(input.op) || !['org','project','root'].includes(input.level)) fail('invalid-request');
|
||||
if (typeof input.reason!=='string' || input.reason.trim().length===0 || input.reason.length>500) fail('invalid-request');
|
||||
if (typeof assertClean!=='function') fail('definition-secret-check-required');
|
||||
assertClean(input);
|
||||
if ('binding' in input) return prepareBinding(business, input, { id: decision, expiresAt });
|
||||
if (input.level==='project') id(input.project); else if ('project' in input) fail('invalid-request');
|
||||
if (input.op!=='fire') {
|
||||
keys(input.route,['harness','model','thinking'],['harness','model']);
|
||||
@@ -169,6 +273,8 @@ export function hireDecisions({ writer, rolesDir, configDir, dataRoot, assertCle
|
||||
// issued after the proposal refuses it here, before preview, check or apply goes further.
|
||||
if(p.entry)scanEntry(p.definition,p.entry);
|
||||
if(adjust===undefined)return p;
|
||||
// A binding is never adjusted: narrowing or replacing it is a new decision (design section 4).
|
||||
if(p.kind==='binding')fail('invalid-request');
|
||||
keys(adjust,['role','credentials','route']);
|
||||
if(!p.entry)fail('invalid-request');
|
||||
assertClean(adjust);
|
||||
@@ -212,6 +318,7 @@ export function hireDecisions({ writer, rolesDir, configDir, dataRoot, assertCle
|
||||
if (p.business!==business || caller.digest!==view.digest)fail('decision-stale');
|
||||
if(Date.parse(p.expiresAt)<=(caller.now??Date.now)())fail('decision-expired');
|
||||
authorizeHire(view,caller);
|
||||
if(p.kind==='binding'){await checkBinding(business,p);return p;}
|
||||
if(writer.read(business).revision!==p.revision)fail('decision-stale');
|
||||
// Re-run composition against current policy and scan private text again before any effect.
|
||||
if(p.entry) {
|
||||
@@ -229,6 +336,14 @@ export function hireDecisions({ writer, rolesDir, configDir, dataRoot, assertCle
|
||||
}
|
||||
async function apply(business, decision, caller) {
|
||||
const p=await check(business,decision,caller);
|
||||
if(p.kind==='binding') {
|
||||
const facts=await checkBinding(business,p);
|
||||
// A root copy's receipts are read again under the organization lock, before the write and
|
||||
// before publication, so a removal or replacement after checkBinding still refuses.
|
||||
const fresh=()=>{if(facts.copy.level==='root'){const stale=rootRefusal(facts.copy.name);if(stale)fail(stale);}};
|
||||
return writer.applyCopyBindingDecision(business,{...p,digest:publicOf(p).digest},{actor:caller.actor,owner:caller.owner===true,definitions:facts.definitions,
|
||||
beforeWrite:()=>{fresh();caller.beforeWrite?.();},beforePublish:()=>{fresh();caller.beforePublish?.();}});
|
||||
}
|
||||
const r=p.request;
|
||||
const vars=r.route ? {harness:r.route.harness,model:r.route.model,...(r.route.thinking?{thinking:r.route.thinking}:{})} : {};
|
||||
const currentDoc=r.level==='root'?(await readRootAgentsFile(configDir)).document:writer.read(business).document;
|
||||
@@ -248,7 +363,7 @@ export function hireDecisions({ writer, rolesDir, configDir, dataRoot, assertCle
|
||||
return writer.applyHireDecision(business,p,{actor:caller.actor,assertClean,beforeWrite:caller.beforeWrite,beforePublish:caller.beforePublish,authorityDecision:caller.authorityDecision});
|
||||
}
|
||||
async function reverse(business,decision,caller) {
|
||||
const p=read(decision);if(p.business!==business || !p.prior || p.request.level==='root')fail('decision-stale');
|
||||
const p=read(decision);if(p.business!==business || p.kind==='binding' || !p.prior || p.request.level==='root')fail('decision-stale');
|
||||
const r=p.request,old=writer.read(business),current=r.level==='org'?old.document.roles[r.name]:old.document.projects[r.project]?.agents?.[r.name];
|
||||
// A later approved change must not be overwritten by an earlier inverse.
|
||||
if(r.op==='fire') {
|
||||
|
||||
@@ -50,9 +50,19 @@ async function readPrivate(file) {
|
||||
async function privateDir(path, what = 'a directory') { const st = await fs.lstat(path).catch(rethrow(what)); if (!st.isDirectory() || st.isSymbolicLink() || st.uid !== process.getuid() || (st.mode & 0o077)) fail('org-mismatch', 'unsafe private directory', path); }
|
||||
function validateRegistry(doc, file) {
|
||||
if (!doc || doc.version !== 1 || typeof doc.instance !== 'string' || !doc.orgs || typeof doc.orgs !== 'object' || Array.isArray(doc.orgs)) fail('org-mismatch', 'invalid registry', file);
|
||||
for (const [id, entry] of Object.entries(doc.orgs)) { idCheck(id); if (!entry || entry.id !== id || typeof entry.root !== 'string' || !['reserved', 'active', 'deleting', 'archived'].includes(entry.state) || !Number.isSafeInteger(entry.revision) || entry.revision < 1 || typeof entry.created !== 'string') fail('org-mismatch', 'invalid registry entry', file); }
|
||||
for (const [id, entry] of Object.entries(doc.orgs)) { idCheck(id); if (!entry || entry.id !== id || typeof entry.root !== 'string' || !['reserved', 'active', 'deleting', 'archived'].includes(entry.state) || !Number.isSafeInteger(entry.revision) || entry.revision < 1 || typeof entry.created !== 'string' || (entry.incarnation !== undefined && (typeof entry.incarnation !== 'string' || !INCARNATION.test(entry.incarnation)))) fail('org-mismatch', 'invalid registry entry', file); }
|
||||
return doc;
|
||||
}
|
||||
// Registry incarnation (CB0, #1873): 128 random bits minted when an id is
|
||||
// reserved. A removed and recreated organization with the same id gets a new
|
||||
// value, so metadata bound to the old one never matches. An entry reserved
|
||||
// before this field existed has none; nothing binds to it (orgIncarnation
|
||||
// returns null and every binding refuses).
|
||||
export const INCARNATION = /^[0-9a-f]{32}$/;
|
||||
export function orgIncarnation(registry, id) {
|
||||
const value = registry?.orgs && Object.hasOwn(registry.orgs, id) ? registry.orgs[id]?.incarnation : undefined;
|
||||
return typeof value === 'string' && INCARNATION.test(value) ? value : null;
|
||||
}
|
||||
export async function readRegistry({ configDir }) {
|
||||
await safeDir(configDir, false, CONFIG); await privateDir(configDir, CONFIG); const file = join(configDir, 'org-runtime.json'); let doc;
|
||||
try { doc = await readPrivate(file); } catch (e) { if (e.code === 'ENOENT') return null; if (e instanceof OrgError) throw e; fail('org-mismatch', `invalid registry: ${e instanceof SyntaxError ? e.message : quiet(e, 'registry file').message}`); }
|
||||
@@ -126,6 +136,17 @@ export function withRegistryWriter(configDir, fn) {
|
||||
return result;
|
||||
} finally { if (!pending) release(); }
|
||||
}
|
||||
// An active entry reserved before CB0 has no incarnation. The first binding proposal for it mints
|
||||
// one under the registry lock; no binding can exist before that, so none is orphaned. A removed and
|
||||
// recreated id is reserved afresh and gets a new value. Returns null for any entry not active.
|
||||
export function ensureOrgIncarnation(configDir, id) {
|
||||
return withRegistryWriter(configDir, (registry, publish) => {
|
||||
const entry = registry?.orgs && Object.hasOwn(registry.orgs, id) ? registry.orgs[id] : null;
|
||||
if (!entry || entry.state !== 'active') return null;
|
||||
if (orgIncarnation(registry, id) === null) { entry.incarnation = randomBytes(16).toString('hex'); publish(); }
|
||||
return entry.incarnation;
|
||||
});
|
||||
}
|
||||
export async function reserveOrg(ctx, id) {
|
||||
const root = orgRoot(ctx.dataRoot, id);
|
||||
return withRegistry(ctx, async registry => {
|
||||
@@ -135,7 +156,7 @@ export async function reserveOrg(ctx, id) {
|
||||
await safeDir(dirname(root), true, 'org-runtime under the data root'); await privateDir(dirname(root), 'org-runtime under the data root');
|
||||
try { await fs.mkdir(root, { mode: 0o700 }); } catch (e) { if (e.code === 'EEXIST') fail('org-exists', 'organization root already exists', root); throw quiet(e, 'the organization root'); }
|
||||
await fs.writeFile(join(root, '.mosaic-org.json'), JSON.stringify({ instance: ctx.instance, id }) + '\n', { mode: 0o600, flag: 'wx' }).catch(rethrow('the organization marker'));
|
||||
const entry = { id, root, state: 'reserved', created: new Date().toISOString(), revision: 1 }; registry.orgs[id] = entry; return { ...entry };
|
||||
const entry = { id, root, state: 'reserved', created: new Date().toISOString(), revision: 1, incarnation: randomBytes(16).toString('hex') }; registry.orgs[id] = entry; return { ...entry };
|
||||
});
|
||||
}
|
||||
export async function resolveOrg(ctx, id) {
|
||||
|
||||
@@ -9,7 +9,7 @@ import { ID_PATTERN } from './vocabulary.mjs';
|
||||
import { OrgError, orgRoot, quiet, readRegistry, reserveOrg, resolveOrg, withRegistry, withOrgLifecycle } from './org-registry.mjs';
|
||||
import { loadInstalled } from '../../plugins/src/loader.mjs';
|
||||
import { BINDING_NAME } from '../../discord/src/binding.mjs';
|
||||
import { parseJsonFileText } from './util.mjs';
|
||||
import { canonicalJson, parseJsonFileText } from './util.mjs';
|
||||
const rolesDir = resolve(dirname(fileURLToPath(import.meta.url)), '../../../roles');
|
||||
const owner = Object.freeze({ provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner' });
|
||||
const fail = (code, message, path) => { throw new OrgError(code, message, path); };
|
||||
@@ -38,6 +38,9 @@ async function receipts(root) {
|
||||
}
|
||||
function normalizeDoc(ctx, id, input, existing) {
|
||||
if (!input || input.id !== id) fail('org-invalid', 'document id must match organization id', '$.id');
|
||||
// #1873 CB1: copy bindings change only through the owner-approved binding decision's writer.
|
||||
// A create or a whole-document update that adds, edits or drops one is refused.
|
||||
if (canonicalJson(input.copyBindings ?? null) !== canonicalJson(existing?.copyBindings ?? null)) fail('organization-bindings-protected', 'copyBindings change only through an owner-approved binding decision', '$.copyBindings');
|
||||
const doc = structuredClone(input); const root = orgRoot(ctx.dataRoot, id);
|
||||
for (const [project, entry] of Object.entries(doc.projects ?? {})) {
|
||||
checkId(project); if (!entry || entry.kind === 'library') fail('org-invalid', 'configured projects must be Git projects', `$.projects.${project}`);
|
||||
|
||||
@@ -20,6 +20,7 @@ import { checkMapping } from "../../library/src/mapping.mjs";
|
||||
import { forkProblem, giteaName, parseGiteaUrl, HOST_SERVICE, CREATIVE_DEFINITIONS } from "./vocabulary.mjs";
|
||||
import { checkAppearance } from "./appearance.mjs";
|
||||
import { checkProjectAgents } from "./agent-levels.mjs";
|
||||
import { validateCopyBindings } from "./copy-bindings.mjs";
|
||||
import {
|
||||
requireObject, rejectUnknownKeys, requireId, requirePositiveInt, requireString, requireDistinctList, readJsonFile, deepFreeze,
|
||||
} from "./util.mjs";
|
||||
@@ -28,7 +29,7 @@ const BOT_NAME = /^bot-[a-z0-9][a-z0-9._-]{0,60}$/;
|
||||
// How long a thread's session stays up with no turn before the launcher stops
|
||||
// it, when launch.threadIdleSeconds is absent (REQ-THREAD-2, row 121).
|
||||
export const THREAD_IDLE_SECONDS = 900;
|
||||
const TOP_KEYS = ["businessVersion", "id", "human", "arbiters", "projects", "vars", "tracker", "roles", "launch", "plugins", "hosts", "retired", "replyLimits", "usage", "appearance", "definitions", "autonomy"];
|
||||
const TOP_KEYS = ["businessVersion", "id", "human", "arbiters", "projects", "vars", "tracker", "roles", "launch", "plugins", "hosts", "retired", "replyLimits", "usage", "appearance", "definitions", "autonomy", "copyBindings"];
|
||||
|
||||
// The config directory: next to the system config, $MOSAIC_CONFIG or
|
||||
// ~/.config/mosaic-dev/config.json, the same rule mosaic-config.mjs uses.
|
||||
@@ -344,6 +345,10 @@ export function validateOrganizationDocument(document, file, { rolesDir, dataRoo
|
||||
refuse(`${file} tracker.sync must use its own bot, not the one roles.${instance} uses`);
|
||||
}
|
||||
}
|
||||
// #1873 CB1: copy credential bindings. Each copy's tracker bot is unique against every named
|
||||
// instance's bot, the tracker sync bot and the other copies.
|
||||
validateCopyBindings(document.copyBindings, { trackers: [...Object.values(roles).map((r) => r.tracker).filter(Boolean), ...(tracker ? [tracker.sync] : [])] });
|
||||
const copyBindings = document.copyBindings === undefined ? undefined : structuredClone(document.copyBindings);
|
||||
const launch = document.launch === undefined ? null : checkLaunch(document.launch, roles, definitions, file);
|
||||
// REQ-PLUG-1: the module names switched on per kind. The names are checked
|
||||
// here; whether each module loads is the loader's check, at load time.
|
||||
@@ -372,6 +377,7 @@ export function validateOrganizationDocument(document, file, { rolesDir, dataRoo
|
||||
return deepFreeze({
|
||||
businessVersion: 1, id: document.id, file, human, arbiters, projects, vars, tracker, roles, launch, plugins, definitions,
|
||||
...(autonomy === undefined ? {} : { autonomy }), ...(retired ? { retired } : {}), ...(usage === undefined ? {} : { usage }), ...(appearance === undefined ? {} : { appearance }),
|
||||
...(copyBindings === undefined ? {} : { copyBindings }),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -12,9 +12,10 @@ import { isDailyModelQuota } from "../../plugins/src/model-quota.mjs";
|
||||
import { checkReplyLimits } from "../../library/src/limits.mjs";
|
||||
import { ADDITION_VERB, additionProblem } from "./project-bootstrap.mjs";
|
||||
import { remoteProblem, remoteTarget } from "./project-remotes.mjs";
|
||||
import { withRegistryWriter } from "./org-registry.mjs";
|
||||
import { orgIncarnation, withRegistryWriter } from "./org-registry.mjs";
|
||||
import { bindingText, definitionPin, samePin } from "./copy-bindings.mjs";
|
||||
import { entryForm, projectAgentProblem, rootForProjectCheck } from "./agent-levels-write.mjs";
|
||||
import { checkDefinition, checkDefinitionName, instancesNaming } from "./definitions.mjs";
|
||||
import { checkDefinition, checkDefinitionName, instancesNaming, readTemplate } from "./definitions.mjs";
|
||||
|
||||
export class OrganizationError extends Error {
|
||||
constructor(code, message = code) {
|
||||
@@ -27,6 +28,15 @@ const fail = (code, message) => {
|
||||
throw new OrganizationError(code, message);
|
||||
};
|
||||
|
||||
// #1873 CB1: the capability only applyCopyBindingDecision holds. Every other commit refuses a change
|
||||
// to `copyBindings`, whatever options its caller spreads in (write() passes its caller's options).
|
||||
const BINDING_WRITE = Symbol("copy-binding-write");
|
||||
const bindingsFence = (old, document, capability) => {
|
||||
if (capability !== BINDING_WRITE && canonicalJson(document?.copyBindings ?? null) !== canonicalJson(old.copyBindings ?? null)) {
|
||||
fail("organization-bindings-protected", "copyBindings change only through an owner-approved binding decision");
|
||||
}
|
||||
};
|
||||
|
||||
function regular(file) {
|
||||
let fd;
|
||||
try {
|
||||
@@ -282,7 +292,7 @@ export function organizationWriter({ dir, rolesDir, dataRoot = null, barrier = (
|
||||
// the old document, for a writer that builds what it publishes from what it read under the lock.
|
||||
// #1873 A2: `pathsOnly` logs each change's path and presence without its values, for a verb whose
|
||||
// values can hold a var a person typed (a secret pasted in the wrong place must not reach evidence).
|
||||
function commit(id, document, { revision, actor, detail, context, beforeWrite = null, beforePublish = null, guard, pathsOnly = false }) {
|
||||
function commit(id, document, { revision, actor, detail, context, beforeWrite = null, beforePublish = null, guard, pathsOnly = false, capability = null, incarnation = undefined }) {
|
||||
requireId(actor, "authenticated actor");
|
||||
requireId(id, "organization id");
|
||||
let revoked = false;
|
||||
@@ -297,9 +307,13 @@ export function organizationWriter({ dir, rolesDir, dataRoot = null, barrier = (
|
||||
fail('org-mismatch', 'organization document and registry disagree');
|
||||
}
|
||||
if (revision !== before) fail("revision-conflict", "organization revision changed; read again");
|
||||
// #1873 CB1: a binding names the registry incarnation it was approved in. A removed and
|
||||
// recreated organization with the same id, or a legacy entry with none, never matches.
|
||||
if (incarnation !== undefined && (!entry || orgIncarnation(registry, id) === null || orgIncarnation(registry, id) !== incarnation)) fail("decision-stale", "organization incarnation changed; raise the decision again");
|
||||
const old = parseJsonFileText(oldBytes, file, "organization file");
|
||||
if (typeof document === "function") document = document(old);
|
||||
guard(old, document);
|
||||
bindingsFence(old, document, capability);
|
||||
replyLimitsFence(old, document, actor);
|
||||
modelQuotaFence(old, document, actor);
|
||||
usageFence(old, document, actor);
|
||||
@@ -311,6 +325,7 @@ export function organizationWriter({ dir, rolesDir, dataRoot = null, barrier = (
|
||||
// can't carry (a Date, a Map) serializes as something else (row 131, sage-0314).
|
||||
const written = JSON.parse(bytes);
|
||||
guard(old, written);
|
||||
bindingsFence(old, written, capability);
|
||||
replyLimitsFence(old, written, actor);
|
||||
modelQuotaFence(old, written, actor);
|
||||
usageFence(old, written, actor);
|
||||
@@ -373,7 +388,7 @@ export function organizationWriter({ dir, rolesDir, dataRoot = null, barrier = (
|
||||
if (canonicalJson(document?.definitions ?? {}) !== canonicalJson(old.definitions ?? {})) fail("organization-definitions-protected");
|
||||
};
|
||||
function write(id, document, options) {
|
||||
return commit(id, document, { ...options, guard: (old, next) => { projectsProtected(old, next); definitionsProtected(old, next); } });
|
||||
return commit(id, document, { ...options, capability: null, incarnation: undefined, guard: (old, next) => { projectsProtected(old, next); definitionsProtected(old, next); } });
|
||||
}
|
||||
|
||||
// #1873 T1: the only writer of `definitions`. `next(current)` returns the new entry without its
|
||||
@@ -532,6 +547,53 @@ export function organizationWriter({ dir, rolesDir, dataRoot = null, barrier = (
|
||||
},context:{decision:proposal.id,digest:proposal.digest??null},detail:{verb:'hire.apply',decision:proposal.id,op:r.op,agent:r.name}});
|
||||
}
|
||||
|
||||
// #1873 CB1 (design sections 3 and 4): the only writer of `copyBindings`, one copy per call. The
|
||||
// broker has consumed the owner's approval of this exact digest before it gets here; the writer
|
||||
// still refuses the tester, every agent identity and a caller that is not the owner, and rechecks
|
||||
// under the locks the registry incarnation, the document revision, the copy's binding generation
|
||||
// and copyId, and its definition pin. A bind writes `pending` (approved, not verified: nothing
|
||||
// resolves or launches it until a verifier exists); an unbind writes the `unbound` tombstone. The
|
||||
// record holds ids, scope names and integers only; the receipt holds paths only.
|
||||
// `definitions` is the boot snapshot's merged definition map, for a root copy whose definition
|
||||
// is not in this file.
|
||||
function applyCopyBindingDecision(id, proposal, { actor, owner = false, definitions = {}, beforeWrite = null, beforePublish = null }) {
|
||||
const { binding: b, revision } = proposal ?? {};
|
||||
if (typeof proposal?.id !== "string" || proposal.kind !== "binding" || !b || typeof b.instance !== "string") fail("invalid-request");
|
||||
const humanOnly = () => fail("human-required", bindingText("human-required"));
|
||||
// Before the lock: no owner, no write. The agent identities are checked again under it.
|
||||
if (owner !== true || actor === "tester") humanOnly();
|
||||
const build = old => {
|
||||
const agents = [
|
||||
...Object.entries(old.roles ?? {}).flatMap(([n, e]) => [n, `${e?.definition}.${n}`]),
|
||||
...Object.entries(old.projects ?? {}).flatMap(([p, v]) => Object.entries(v?.agents ?? {}).flatMap(([n, e]) => [n, `${n}.${p}`, `${e?.definition}.${n}.${p}`])),
|
||||
...Object.keys(old.copyBindings ?? {}), ...Object.keys(definitions ?? {}), b.instance, b.source?.name,
|
||||
];
|
||||
if (owner !== true || actor === "tester" || agents.includes(actor)) humanOnly();
|
||||
const current = old.copyBindings?.[b.instance];
|
||||
if ((current?.generation ?? 0) !== b.generation || (current ? current.copyId !== b.copyId : b.fresh !== true)
|
||||
|| (current && canonicalJson(current.source) !== canonicalJson(b.source))) fail("credential-binding-changed", bindingText("credential-binding-changed", Object.keys(b.after ?? b.before ?? {})[0] ?? null));
|
||||
if (b.after === null && (!current || current.state === "unbound")) fail("credential-binding-changed", bindingText("credential-binding-changed"));
|
||||
const pin = definitionPin(b.definition.ref, { definitions: { ...(definitions ?? {}), ...(old.definitions ?? {}) }, rolesDir, readTemplate });
|
||||
if (!samePin(pin, b.definition)) fail("credential-role-changed", bindingText("credential-role-changed", Object.keys(b.after ?? b.before ?? {})[0] ?? null));
|
||||
const record = {
|
||||
copyId: b.copyId, orgIncarnation: b.orgIncarnation, source: structuredClone(b.source), definition: structuredClone(b.definition),
|
||||
generation: b.generation + 1, state: b.after === null ? "unbound" : "pending",
|
||||
services: b.after === null ? {} : structuredClone(b.after), decision: proposal.id,
|
||||
};
|
||||
return { ...structuredClone(old), copyBindings: { ...(old.copyBindings ?? {}), [b.instance]: record } };
|
||||
};
|
||||
return commit(id, build, {
|
||||
revision, actor, beforeWrite, beforePublish, pathsOnly: true, capability: BINDING_WRITE, incarnation: b.orgIncarnation,
|
||||
guard: (old, next) => {
|
||||
if (canonicalJson(next) !== canonicalJson(build(old))) fail("organization-bindings-protected", "copyBindings change only through an owner-approved binding decision");
|
||||
projectsProtected(old, next);
|
||||
definitionsProtected(old, next);
|
||||
},
|
||||
context: { decision: proposal.id, digest: proposal.digest ?? null },
|
||||
detail: { verb: b.after === null ? "binding.unbind" : "binding.apply", decision: proposal.id, agent: b.instance, generation: b.generation + 1 },
|
||||
});
|
||||
}
|
||||
|
||||
// AU inverse, from an immutable private proposal. It touches only the named instance and
|
||||
// definition; writer validation, fresh revision and owner fence still apply. Bindings never restore.
|
||||
function reverseHireDecision(id,p,{actor,beforeWrite,beforePublish}) {
|
||||
@@ -637,5 +699,5 @@ export function organizationWriter({ dir, rolesDir, dataRoot = null, barrier = (
|
||||
});
|
||||
}
|
||||
|
||||
return { logFile, read, readRaw, write, addProject, setProjectRemote, setProjectAgent, setDefinition, applyHireDecision, reverseHireDecision, hold, holdAsync, findAddition, record, recover: id => withLock(id, () => recover(id)) };
|
||||
return { logFile, read, readRaw, write, addProject, setProjectRemote, setProjectAgent, setDefinition, applyHireDecision, reverseHireDecision, applyCopyBindingDecision, hold, holdAsync, findAddition, record, recover: id => withLock(id, () => recover(id)) };
|
||||
}
|
||||
@@ -0,0 +1,480 @@
|
||||
// #1873 copy credential binding, slices CB0 and CB1 (design
|
||||
// docs/plans/2026-11-11_copy-credential-binding-design.md section 8, rows CB0 and CB1).
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { hireDecisions, authorizeHire } from '../src/hire-decisions.mjs';
|
||||
import { organizationWriter } from '../src/organization.mjs';
|
||||
import { checkRootAgents } from '../src/agent-levels.mjs';
|
||||
import { copyRefusal, loadWithCopies, materializeCopies } from '../src/agent-copies.mjs';
|
||||
import { readTemplate } from '../src/definitions.mjs';
|
||||
import { validateBusinessDocument } from '../src/organization-config.mjs';
|
||||
import { activateOrg, ensureOrgIncarnation, orgIncarnation, orgRoot, readRegistry, reserveOrg, withRegistryWriter } from '../src/org-registry.mjs';
|
||||
import { definitionPin, intersectScopes, resolveCopyBinding, validateCopyBindings } from '../src/copy-bindings.mjs';
|
||||
import { businessDoc, rolesCopy, writeJson } from './helpers.mjs';
|
||||
import { createOrg, showOrg, templateRender, updateOrg } from '../src/org-service.mjs';
|
||||
|
||||
const SECRET = 'sk-test-NOT-A-SECRET-1234';
|
||||
const OWNER = { actor: 'jason', agent: false, rights: ['org.hire'], owner: true, instanceOwner: true, subject: { provider: 'test', iss: 'https://test', sub: 'owner' } };
|
||||
const FUTURE = '2099-01-01T00:00:00.000Z';
|
||||
const ID1 = '11111111-1111-4111-8111-111111111111', ID2 = '22222222-2222-4222-8222-222222222222', ID3 = '33333333-3333-4333-8333-333333333333';
|
||||
const GITEA = { gitea: { scopes: ['read:repository'], projects: ['stack'] } };
|
||||
const sha = b => createHash('sha256').update(b).digest('hex');
|
||||
|
||||
// ---- CB0: pure resolver fixtures ----
|
||||
|
||||
const rolesDir = join(new URL('../../..', import.meta.url).pathname, 'roles');
|
||||
const rootOf = agents => ({ state: 'ok', ...checkRootAgents({ agentsVersion: 1, agents }, { rolesDir }) });
|
||||
const reviewer = () => JSON.parse(readFileSync(join(rolesDir, 'reviewer.json'), 'utf8'));
|
||||
const pinOf = (ref, definitions = {}) => definitionPin(ref, { definitions, rolesDir, readTemplate });
|
||||
const INC_A = 'a'.repeat(32), INC_B = 'b'.repeat(32);
|
||||
const docFor = (id, edit = d => d) => edit({ id, roles: {}, projects: { stack: {}, web: {} } });
|
||||
const record = (over = {}) => ({
|
||||
copyId: 'c'.repeat(32), orgIncarnation: INC_A, source: { level: 'root', name: 'rev', project: null },
|
||||
definition: pinOf('reviewer'), generation: 2, state: 'bound',
|
||||
services: { gitea: { slot: 'self', scopes: ['read:repository', 'write:issue'], resources: { projects: ['stack'] } },
|
||||
vikunja: { slot: 'self', scopes: { tasks: ['read_one'] }, resources: { projects: ['stack'] } } },
|
||||
decision: ID1, ...over,
|
||||
});
|
||||
const resolve = (over = {}) => {
|
||||
const snapshot = over.snapshot ?? materializeCopies({ orgId: 'acme', doc: docFor('acme'), root: rootOf({ rev: { definition: 'reviewer' } }), rolesDir });
|
||||
return resolveCopyBinding({ orgId: 'acme', incarnation: INC_A, snapshot, instance: 'rev', record: record(), pin: pinOf('reviewer'), role: reviewer(), projects: ['stack', 'web'], ...over });
|
||||
};
|
||||
|
||||
test('CB0 a bound record resolves to effective = role intersect approved, only on admitted projects', () => {
|
||||
const r = resolve();
|
||||
assert.equal(r.ok, true);
|
||||
// The role declares write:issue, write:repository and read:user; the owner approved less.
|
||||
assert.deepEqual(r.services.gitea, { scopes: ['write:issue', 'read:repository'], projects: ['stack'] });
|
||||
assert.deepEqual(r.services.vikunja, { scopes: { tasks: ['read_one'] }, projects: ['stack'] });
|
||||
assert.deepEqual(intersectScopes('gitea', ['write:repository', 'read:issue'], ['read:repository', 'write:issue']), ['read:issue', 'read:repository']);
|
||||
// A project no longer admitted drops out of the reach; none left, no binding.
|
||||
assert.equal(resolve({ projects: ['web'] }).code, 'credential-unbound');
|
||||
// Approved broader than the role (the role narrowed): never widened, refused.
|
||||
const wide = record(); wide.services.gitea.scopes = ['write:admin'];
|
||||
assert.equal(resolve({ record: wide }).ok, false);
|
||||
const extra = record(); extra.services.gitea.scopes = ['write:repository', 'write:organization'];
|
||||
assert.deepEqual([resolve({ record: extra }).code, resolve({ record: extra }).service], ['credential-scope-exceeded', 'gitea']);
|
||||
});
|
||||
|
||||
test('CB0 pending, partial and tombstoned records never resolve', () => {
|
||||
assert.equal(resolve({ record: record({ state: 'pending' }) }).code, 'credential-unbound');
|
||||
assert.equal(resolve({ record: record({ state: 'unbound', services: {} }) }).code, 'credential-unbound');
|
||||
const partial = record(); delete partial.services.vikunja;
|
||||
assert.deepEqual([resolve({ record: partial }).code, resolve({ record: partial }).service], ['credential-unbound', 'vikunja']);
|
||||
assert.equal(resolve({ record: undefined }).code, 'credential-unbound');
|
||||
});
|
||||
|
||||
test('CB0 identical root copies in two organizations never share a binding', () => {
|
||||
const root = rootOf({ rev: { definition: 'reviewer' } });
|
||||
const a = materializeCopies({ orgId: 'acme', doc: docFor('acme'), root, rolesDir });
|
||||
const b = materializeCopies({ orgId: 'beta', doc: docFor('beta'), root, rolesDir });
|
||||
assert.deepEqual({ ...a.copies.rev }, { ...b.copies.rev }, 'the same copy shape in both');
|
||||
assert.equal(resolve({ snapshot: a }).ok, true);
|
||||
// A's record copied into B carries A's incarnation.
|
||||
assert.equal(resolveCopyBinding({ orgId: 'beta', incarnation: INC_B, snapshot: b, instance: 'rev', record: record(), pin: pinOf('reviewer'), role: reviewer(), projects: ['stack'] }).code, 'credential-unbound');
|
||||
// A's snapshot never answers for B.
|
||||
assert.equal(resolveCopyBinding({ orgId: 'beta', incarnation: INC_A, snapshot: a, instance: 'rev', record: record(), pin: pinOf('reviewer'), role: reviewer(), projects: ['stack'] }).code, 'copy-unavailable');
|
||||
});
|
||||
|
||||
test('CB0 project dotted-name collisions never attach a record', () => {
|
||||
const root = rootOf({});
|
||||
// project "a.b" agent "c" and project "b" agent "c.a" both make instance c.a.b: both refuse.
|
||||
const both = materializeCopies({ orgId: 'acme', doc: docFor('acme', d => ({ ...d, projects: { 'a.b': { agents: { c: { definition: 'reviewer' } } }, b: { agents: { 'c.a': { definition: 'reviewer' } } } } })), root, rolesDir });
|
||||
assert.equal(both.copies['c.a.b'].state, 'refused');
|
||||
const old = record({ source: { level: 'project', name: 'c.a', project: 'b' }, services: { gitea: { slot: 'self', scopes: ['read:repository'], resources: { projects: ['b'] } } } });
|
||||
const args = { orgId: 'acme', incarnation: INC_A, instance: 'c.a.b', record: old, pin: pinOf('reviewer'), role: reviewer(), projects: ['a.b', 'b'] };
|
||||
assert.equal(resolveCopyBinding({ ...args, snapshot: both }).code, 'copy-refused');
|
||||
// Project b drops its agent; project a.b's "c" now owns c.a.b alone. b's record never attaches.
|
||||
const one = materializeCopies({ orgId: 'acme', doc: docFor('acme', d => ({ ...d, projects: { 'a.b': { agents: { c: { definition: 'reviewer' } } }, b: {} } })), root, rolesDir });
|
||||
assert.equal(one.copies['c.a.b'].state, 'unbound');
|
||||
assert.equal(resolveCopyBinding({ ...args, snapshot: one, record: { ...old, state: 'bound' } }).code, 'credential-unbound');
|
||||
// A root agent named like a project copy: the root record never attaches to the project copy.
|
||||
const proj = materializeCopies({ orgId: 'acme', doc: docFor('acme', d => ({ ...d, projects: { stack: { agents: { checker: { definition: 'reviewer' } } } } })), root, rolesDir });
|
||||
const rootRecord = record({ source: { level: 'root', name: 'checker.stack', project: null } });
|
||||
assert.equal(resolveCopyBinding({ ...args, instance: 'checker.stack', snapshot: proj, record: rootRecord, projects: ['stack'] }).code, 'credential-unbound');
|
||||
});
|
||||
|
||||
test('CB0 an organization instance shadows the copy; its record never resolves', () => {
|
||||
const snapshot = materializeCopies({ orgId: 'acme', doc: docFor('acme', d => ({ ...d, roles: { rev: { definition: 'reviewer' } } })), root: rootOf({ rev: { definition: 'reviewer' } }), rolesDir });
|
||||
assert.equal(Object.hasOwn(snapshot.copies, 'rev'), false);
|
||||
assert.equal(resolve({ snapshot }).code, 'copy-unavailable');
|
||||
});
|
||||
|
||||
test('CB0 a removed and recreated organization has a new incarnation; the old record never resolves', async t => {
|
||||
const base = mkdtempSync(join(tmpdir(), 'cb0-')); t.after(() => rmSync(base, { recursive: true, force: true }));
|
||||
const c = { configDir: join(base, 'config'), dataRoot: join(base, 'data'), instance: 'test' };
|
||||
mkdirSync(c.configDir, { mode: 0o700 }); mkdirSync(c.dataRoot, { mode: 0o700 });
|
||||
const first = await reserveOrg(c, 'acme');
|
||||
assert.match(first.incarnation, /^[0-9a-f]{32}$/);
|
||||
assert.notEqual((await reserveOrg(c, 'beta')).incarnation, first.incarnation);
|
||||
withRegistryWriter(c.configDir, (registry, publish) => { delete registry.orgs.acme; publish(); });
|
||||
rmSync(orgRoot(c.dataRoot, 'acme'), { recursive: true });
|
||||
const again = await reserveOrg(c, 'acme');
|
||||
assert.notEqual(again.incarnation, first.incarnation);
|
||||
assert.equal(orgIncarnation(await readRegistry(c), 'acme'), again.incarnation);
|
||||
assert.equal(resolve({ incarnation: again.incarnation, record: record({ orgIncarnation: first.incarnation }) }).code, 'credential-unbound');
|
||||
assert.equal(resolve({ incarnation: null }).code, 'credential-unbound', 'no incarnation, no binding');
|
||||
// An invalid incarnation in the registry refuses the registry.
|
||||
withRegistryWriter(c.configDir, (registry, publish) => { registry.orgs.acme.incarnation = 'x'; publish(); });
|
||||
await assert.rejects(readRegistry(c), { code: 'org-mismatch' });
|
||||
});
|
||||
|
||||
test('CB0 a reused source name with another definition or level never inherits a record', () => {
|
||||
// Root "rev" was a reviewer; it is removed and a new root "rev" is a coder.
|
||||
const snapshot = materializeCopies({ orgId: 'acme', doc: docFor('acme'), root: rootOf({ rev: { definition: 'coder' } }), rolesDir });
|
||||
const coder = JSON.parse(readFileSync(join(rolesDir, 'coder.json'), 'utf8'));
|
||||
assert.equal(resolve({ snapshot, pin: pinOf('coder'), role: coder }).code, 'credential-role-changed');
|
||||
// An org definition changed in place (same name, new rev or text) changes the pin.
|
||||
const defs = { d: { rev: 1, role: { template: 'reviewer' } } };
|
||||
const before = pinOf('org:d', defs), after = pinOf('org:d', { d: { ...defs.d, instructions: 'other' } });
|
||||
assert.ok(before && after && before.sha256 !== after.sha256);
|
||||
// The copyId is kept only with its own record: a record for another copyId does not move.
|
||||
assert.equal(resolve({ record: record({ source: { level: 'project', name: 'rev', project: 'stack' } }) }).code, 'credential-unbound');
|
||||
});
|
||||
|
||||
test('CB0 copyBindings schema: ids and scope names only, every refusal positional', () => {
|
||||
const ok = { rev: record() };
|
||||
assert.doesNotThrow(() => validateCopyBindings(ok));
|
||||
const cases = [
|
||||
{ rev: { ...record(), token: SECRET } },
|
||||
{ rev: { ...record(), services: { gitea: { ...record().services.gitea, file: '/x/token' } } } },
|
||||
{ rev: { ...record(), services: { gitea: { ...record().services.gitea, slot: '/srv/token' } } } },
|
||||
{ rev: { ...record(), copyId: SECRET } },
|
||||
{ rev: { ...record(), orgIncarnation: 'z' } },
|
||||
{ rev: { ...record(), generation: 0 } },
|
||||
{ rev: { ...record(), state: 'active' } },
|
||||
{ rev: { ...record(), source: { level: 'root', name: 'other', project: null } } },
|
||||
{ rev: { ...record(), state: 'unbound' } },
|
||||
{ rev: record(), other: { ...record(), source: { level: 'root', name: 'other', project: null } } },
|
||||
];
|
||||
for (const value of cases) {
|
||||
assert.throws(() => validateCopyBindings(value), e => e.name === 'BusinessError' && !e.message.includes(SECRET) && !e.message.includes('/'));
|
||||
}
|
||||
});
|
||||
|
||||
// ---- CB1: decision subtype, guarded writer ----
|
||||
|
||||
function setup(t, { incarnation = true, edit = d => d } = {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'cb1-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const roles = rolesCopy(root);
|
||||
const doc = { ...businessDoc(root, 'demo'), plugins: { harness: ['pi'] } };
|
||||
doc.projects.stack.agents = { checker: { definition: 'reviewer' } };
|
||||
edit(doc);
|
||||
const file = writeJson(join(root, 'businesses/demo.json'), doc);
|
||||
const dataRoot = join(root, 'data'); mkdirSync(dataRoot, { mode: 0o700 });
|
||||
const ctx = { configDir: root, dataRoot, instance: 'test' };
|
||||
const ready = (async () => {
|
||||
await reserveOrg(ctx, 'demo'); await activateOrg(ctx, 'demo');
|
||||
if (!incarnation) withRegistryWriter(root, (registry, publish) => { delete registry.orgs.demo.incarnation; publish(); });
|
||||
})();
|
||||
const snapshot = materializeCopies({ orgId: 'demo', doc, root: { state: 'ok', ...checkRootAgents({ agentsVersion: 1, agents: { rev: { definition: 'reviewer' } } }, { rolesDir: roles }) }, rolesDir: roles });
|
||||
const writer = organizationWriter({ dir: root, rolesDir: roles });
|
||||
const service = hireDecisions({ writer, rolesDir: roles, configDir: root, dataRoot, assertClean: v => { if (JSON.stringify(v).includes(SECRET)) throw Object.assign(new Error('x'), { code: 'definition-holds-credential' }); }, copies: id => snapshot.org === id ? snapshot : null });
|
||||
const bind = (services = GITEA, extra = {}) => ({ op: 'change', level: 'root', name: 'rev', reason: 'Bind the reviewer copy', binding: { services }, ...extra });
|
||||
const prepare = async (input = bind(), id = ID1, expiresAt = FUTURE) => { await ready; return service.prepare('demo', input, { id, expiresAt }); };
|
||||
const bytes = () => readFileSync(file);
|
||||
return { root, roles, file, writer, service, snapshot, bind, prepare, bytes, ready, ctx, dataRoot };
|
||||
}
|
||||
|
||||
test('CB1 owner binds and unbinds; the record holds ids only and stays pending; receipts are paths only', async t => {
|
||||
const f = setup(t);
|
||||
const p = await f.prepare();
|
||||
assert.equal(p.binding.instance, 'rev');
|
||||
assert.deepEqual(p.credentialLines, [{ service: 'gitea', scope: 'read:repository' }]);
|
||||
await f.service.apply('demo', p.id, { ...OWNER, digest: p.digest });
|
||||
const rec = f.writer.read('demo').document.copyBindings.rev;
|
||||
assert.equal(rec.state, 'pending');
|
||||
assert.equal(rec.generation, 1);
|
||||
assert.deepEqual(rec.services, { gitea: { slot: 'self', scopes: ['read:repository'], resources: { projects: ['stack'] } } });
|
||||
assert.deepEqual(rec.source, { level: 'root', name: 'rev', project: null });
|
||||
assert.equal(rec.orgIncarnation, orgIncarnation(await readRegistry(f.ctx), 'demo'));
|
||||
assert.deepEqual(rec.definition, definitionPin('reviewer', { rolesDir: f.roles, readTemplate }));
|
||||
assert.equal(rec.decision, p.id);
|
||||
const text = JSON.stringify(rec);
|
||||
for (const bad of [SECRET, f.root, '/', 'token']) assert.ok(!text.includes(bad), bad);
|
||||
const log = readFileSync(join(f.root, 'businesses/.history/demo/changes.jsonl'), 'utf8');
|
||||
assert.ok(log.includes(p.id)); assert.ok(log.includes('binding.apply'));
|
||||
assert.ok(!log.includes('read:repository'), 'paths only: the receipt holds no scope value');
|
||||
// The owner unbinds through the same decision kind; a tombstone stays with the same copyId.
|
||||
const u = await f.prepare(f.bind(null), ID2);
|
||||
await f.service.apply('demo', u.id, { ...OWNER, digest: u.digest });
|
||||
const tomb = f.writer.read('demo').document.copyBindings.rev;
|
||||
assert.deepEqual({ state: tomb.state, services: tomb.services, generation: tomb.generation, copyId: tomb.copyId }, { state: 'unbound', services: {}, generation: 2, copyId: rec.copyId });
|
||||
assert.ok(readFileSync(join(f.root, 'businesses/.history/demo/changes.jsonl'), 'utf8').includes('binding.unbind'));
|
||||
// Unbinding a tombstone refuses.
|
||||
await assert.rejects(f.prepare(f.bind(null), ID3), { code: 'credential-unbound' });
|
||||
});
|
||||
|
||||
test('CB1 a project copy binds only its own project; a role-exceeding request refuses, never trims', async t => {
|
||||
const f = setup(t);
|
||||
const project = (services, id) => f.prepare({ op: 'change', level: 'project', project: 'stack', name: 'checker', reason: 'Bind', binding: { services } }, id);
|
||||
const p = await project(GITEA, ID1);
|
||||
assert.equal(p.binding.instance, 'checker.stack');
|
||||
await assert.rejects(project({ gitea: { scopes: ['write:admin'], projects: ['stack'] } }, ID2), { code: 'invalid-request' });
|
||||
await assert.rejects(project({ gitea: { scopes: ['write:organization'], projects: ['stack'] } }, ID2), { code: 'credential-scope-exceeded' });
|
||||
await assert.rejects(project({ gitea: { scopes: ['read:repository'], projects: ['other'] } }, ID2), { code: 'credential-scope-exceeded' });
|
||||
await assert.rejects(f.prepare(f.bind({ gitea: { scopes: ['read:repository'], projects: ['stack'], token: SECRET } }), ID2), e => !JSON.stringify(e).includes(SECRET) && !e.message.includes(SECRET));
|
||||
// A copy the bus did not boot, and an org instance, are not copies.
|
||||
await assert.rejects(f.prepare({ ...f.bind(), name: 'nobody' }, ID2), { code: 'copy-unavailable' });
|
||||
await assert.rejects(f.prepare({ ...f.bind(), name: 'coder' }, ID2), { code: 'copy-unavailable' });
|
||||
// A binding is only ever a change of a root or project copy, with no role, route or credentials.
|
||||
for (const extra of [{ op: 'hire' }, { level: 'org' }, { route: { harness: 'pi', model: 'm' } }, { credentials: {} }]) {
|
||||
await assert.rejects(f.prepare({ ...f.bind(), ...extra }, ID2), { code: 'invalid-request' });
|
||||
}
|
||||
});
|
||||
|
||||
test('CB1 only the owner resolves: member with org.hire or decide, chief, arbiter, subject, tester and reader all fail', async t => {
|
||||
const plain = { binding: {}, flagged: [], credentialLines: [], level: 'root', id: ID1, name: 'rev' };
|
||||
const member = { ...OWNER, owner: false, instanceOwner: false };
|
||||
assert.throws(() => authorizeHire(plain, member), { code: 'human-required' });
|
||||
assert.throws(() => authorizeHire(plain, { ...member, instanceOwner: true }), { code: 'human-required' }, 'an instance operator alone is not the owner');
|
||||
assert.throws(() => authorizeHire(plain, { ...OWNER, rights: ['decide'] }), { code: 'right-required' });
|
||||
assert.throws(() => authorizeHire(plain, { ...OWNER, rights: [] }), { code: 'right-required' }, 'reader');
|
||||
for (const agent of ['chief', 'cto', 'rev', 'tester']) {
|
||||
// An agent, even one carrying an authority decision and the authority right, never resolves.
|
||||
assert.throws(() => authorizeHire(plain, { actor: agent, agent: true, rights: ['org.hire', 'decision.resolve.authority'], owner: true, authorityDecision: ID1, resolver: agent }), { code: 'human-required' });
|
||||
}
|
||||
assert.doesNotThrow(() => authorizeHire(plain, OWNER));
|
||||
// Through the service: each refused caller leaves the file byte-identical.
|
||||
const f = setup(t);
|
||||
const p = await f.prepare();
|
||||
const before = f.bytes();
|
||||
for (const caller of [member, { ...OWNER, rights: ['decide'] }, { ...OWNER, rights: [] },
|
||||
{ actor: 'chief', agent: true, rights: ['org.hire'], owner: true }, { actor: 'cto', agent: true, rights: ['decision.resolve.authority', 'org.hire'], authorityDecision: p.id, resolver: 'cto' },
|
||||
{ actor: 'rev', agent: true, rights: ['org.hire'] }, { actor: 'tester', agent: true, rights: ['org.hire'] }]) {
|
||||
await assert.rejects(f.service.apply('demo', p.id, { ...caller, digest: p.digest }));
|
||||
}
|
||||
assert.deepEqual(f.bytes(), before);
|
||||
// The writer refuses the tester and every agent identity even when told owner:true.
|
||||
const full = { ...JSON.parse(readFileSync(join(f.dataRoot, 'authority-proposals', `${p.id}.json`), 'utf8')), digest: p.digest };
|
||||
for (const actor of ['tester', 'chief', 'rev', 'checker.stack', 'reviewer', 'pm']) {
|
||||
assert.throws(() => f.writer.applyCopyBindingDecision('demo', full, { actor, owner: true }), { code: 'human-required' }, actor);
|
||||
}
|
||||
assert.throws(() => f.writer.applyCopyBindingDecision('demo', full, { actor: 'jason', owner: false }), { code: 'human-required' });
|
||||
assert.deepEqual(f.bytes(), before);
|
||||
});
|
||||
|
||||
test('CB1 generic writes never change copyBindings, whatever options they pass', async t => {
|
||||
const f = setup(t); await f.ready;
|
||||
const { document, revision } = f.writer.read('demo');
|
||||
const forged = { ...document, copyBindings: { rev: { ...record(), orgIncarnation: orgIncarnation(await readRegistry(f.ctx), 'demo'), state: 'bound' } } };
|
||||
assert.doesNotThrow(() => validateBusinessDocument(forged, f.file, { rolesDir: f.roles }), 'a valid document is still refused by the writer');
|
||||
const before = f.bytes();
|
||||
for (const options of [{}, { capability: Symbol('copy-binding-write') }, { capability: Symbol.for('copy-binding-write'), incarnation: forged.copyBindings.rev.orgIncarnation }, { guard: () => {} }]) {
|
||||
assert.throws(() => f.writer.write('demo', forged, { revision, actor: 'jason', ...options }), { code: 'organization-bindings-protected' });
|
||||
}
|
||||
assert.throws(() => f.writer.write('demo', old => ({ ...old, copyBindings: forged.copyBindings }), { revision, actor: 'jason' }), { code: 'organization-bindings-protected' });
|
||||
assert.deepEqual(f.bytes(), before);
|
||||
// Once a record exists, a generic write can neither remove nor edit it.
|
||||
const p = await f.prepare(); await f.service.apply('demo', p.id, { ...OWNER, digest: p.digest });
|
||||
const now = f.writer.read('demo');
|
||||
const { copyBindings: _, ...stripped } = now.document;
|
||||
assert.throws(() => f.writer.write('demo', stripped, { revision: now.revision, actor: 'jason' }), { code: 'organization-bindings-protected' });
|
||||
const promoted = structuredClone(now.document); promoted.copyBindings.rev.state = 'bound';
|
||||
assert.throws(() => f.writer.write('demo', promoted, { revision: now.revision, actor: 'jason' }), { code: 'organization-bindings-protected' });
|
||||
// An unrelated write keeps the record.
|
||||
const edited = structuredClone(now.document); edited.vars['tracker.pollSeconds'] = 61;
|
||||
f.writer.write('demo', edited, { revision: now.revision, actor: 'jason' });
|
||||
assert.deepEqual(f.writer.read('demo').document.copyBindings, now.document.copyBindings);
|
||||
});
|
||||
|
||||
test('CB1 stale revision, digest or generation, expiry, replay and a wrong incarnation activate nothing', async t => {
|
||||
const f = setup(t);
|
||||
// Digest.
|
||||
const p = await f.prepare();
|
||||
const before = f.bytes();
|
||||
await assert.rejects(f.service.apply('demo', p.id, { ...OWNER, digest: '0'.repeat(64) }), { code: 'decision-stale' });
|
||||
// Expiry.
|
||||
await assert.rejects(f.service.apply('demo', p.id, { ...OWNER, digest: p.digest, now: () => Date.parse('2099-06-01T00:00:00Z') }), { code: 'decision-expired' });
|
||||
assert.deepEqual(f.bytes(), before);
|
||||
// Revision: any change to the file after the proposal.
|
||||
const q = await f.prepare(f.bind(), ID2);
|
||||
const r = f.writer.read('demo'), edited = structuredClone(r.document); edited.vars['tracker.pollSeconds'] = 61;
|
||||
f.writer.write('demo', edited, { revision: r.revision, actor: 'jason' });
|
||||
await assert.rejects(f.service.apply('demo', q.id, { ...OWNER, digest: q.digest }), { code: 'decision-stale' });
|
||||
assert.equal(f.writer.read('demo').document.copyBindings, undefined);
|
||||
// Replay: applying the same approval twice changes nothing the second time.
|
||||
const s = await f.prepare(f.bind(), ID3);
|
||||
await f.service.apply('demo', s.id, { ...OWNER, digest: s.digest });
|
||||
const once = f.bytes();
|
||||
await assert.rejects(f.service.apply('demo', s.id, { ...OWNER, digest: s.digest }), { code: 'decision-stale' });
|
||||
const full = { ...JSON.parse(readFileSync(join(f.dataRoot, 'authority-proposals', `${s.id}.json`), 'utf8')), digest: s.digest };
|
||||
// Generation: the writer refuses a proposal at an old generation even with a fresh revision.
|
||||
assert.throws(() => f.writer.applyCopyBindingDecision('demo', { ...full, revision: f.writer.read('demo').revision }, { actor: 'jason', owner: true }), { code: 'credential-binding-changed' });
|
||||
assert.deepEqual(f.bytes(), once);
|
||||
// Wrong incarnation: the organization was recreated under the same id after approval.
|
||||
const u = await f.prepare(f.bind(null), '44444444-4444-4444-8444-444444444444');
|
||||
withRegistryWriter(f.root, (registry, publish) => { registry.orgs.demo.incarnation = 'f'.repeat(32); publish(); });
|
||||
await assert.rejects(f.service.apply('demo', u.id, { ...OWNER, digest: u.digest }), { code: 'decision-stale' });
|
||||
const uf = { ...JSON.parse(readFileSync(join(f.dataRoot, 'authority-proposals', `${u.id}.json`), 'utf8')), digest: u.digest };
|
||||
assert.throws(() => f.writer.applyCopyBindingDecision('demo', uf, { actor: 'jason', owner: true }), { code: 'decision-stale' });
|
||||
assert.deepEqual(f.bytes(), once);
|
||||
// A changed definition pin refuses too.
|
||||
assert.throws(() => f.writer.applyCopyBindingDecision('demo', { ...uf, binding: { ...uf.binding, orgIncarnation: 'f'.repeat(32), definition: { ...uf.binding.definition, sha256: '0'.repeat(64) } } }, { actor: 'jason', owner: true }), { code: 'credential-role-changed' });
|
||||
// A binding is never reversed through the AU inverse, and never adjusted.
|
||||
await assert.rejects(f.service.reverse('demo', s.id, OWNER), { code: 'decision-stale' });
|
||||
await assert.rejects(f.service.check('demo', u.id, { ...OWNER, digest: u.digest, adjust: { role: {} } }), { code: 'invalid-request' });
|
||||
});
|
||||
|
||||
test('CB1 a legacy organization gets its incarnation at the first proposal, under the registry lock', async t => {
|
||||
const f = setup(t, { incarnation: false });
|
||||
await f.ready;
|
||||
assert.equal(orgIncarnation(await readRegistry(f.ctx), 'demo'), null);
|
||||
const p = await f.prepare();
|
||||
const inc = orgIncarnation(await readRegistry(f.ctx), 'demo');
|
||||
assert.match(inc, /^[0-9a-f]{32}$/);
|
||||
assert.equal(p.binding.orgIncarnation, inc);
|
||||
assert.equal(ensureOrgIncarnation(f.root, 'demo'), inc, 'minted once');
|
||||
assert.equal(ensureOrgIncarnation(f.root, 'nobody'), null);
|
||||
});
|
||||
|
||||
test('CB1 a pending or partial binding never launches the copy', async t => {
|
||||
const f = setup(t);
|
||||
const p = await f.prepare();
|
||||
await f.service.apply('demo', p.id, { ...OWNER, digest: p.digest });
|
||||
const business = loadWithCopies('demo', { dir: f.root, rolesDir: f.roles, snapshot: f.snapshot });
|
||||
assert.equal(copyRefusal(business, 'rev').code, 'credential-unbound');
|
||||
assert.equal(Object.hasOwn(business.roles, 'rev'), false);
|
||||
const rec = business.copyBindings.rev;
|
||||
assert.equal(rec.state, 'pending');
|
||||
const pin = definitionPin('reviewer', { rolesDir: f.roles, readTemplate });
|
||||
const role = JSON.parse(readFileSync(join(f.roles, 'reviewer.json'), 'utf8'));
|
||||
const args = { orgId: 'demo', incarnation: rec.orgIncarnation, snapshot: f.snapshot, instance: 'rev', pin, role, projects: ['stack'] };
|
||||
assert.equal(resolveCopyBinding({ ...args, record: rec }).code, 'credential-unbound');
|
||||
// Even forced to bound, gitea alone is partial for a role that also needs vikunja.
|
||||
assert.deepEqual(resolveCopyBinding({ ...args, record: { ...rec, state: 'bound' } }), { ok: false, code: 'credential-unbound', service: 'vikunja' });
|
||||
// A pending record that covers every service the role needs is still not a binding; bound, it is.
|
||||
const whole = { ...rec, services: { ...rec.services, vikunja: { slot: 'self', scopes: { tasks: ['read_one'] }, resources: { projects: ['stack'] } } } };
|
||||
assert.equal(resolveCopyBinding({ ...args, record: { ...whole, state: 'bound' } }).ok, true);
|
||||
assert.deepEqual(resolveCopyBinding({ ...args, record: whole }), { ok: false, code: 'credential-unbound', service: 'gitea' });
|
||||
});
|
||||
|
||||
test('CB1 no proposal, record, receipt or refusal names a token or a path', async t => {
|
||||
const f = setup(t);
|
||||
const p = await f.prepare();
|
||||
await f.service.apply('demo', p.id, { ...OWNER, digest: p.digest });
|
||||
const texts = [JSON.stringify(p), readFileSync(join(f.root, 'businesses/.history/demo/changes.jsonl'), 'utf8'),
|
||||
...readdirSync(join(f.dataRoot, 'authority-proposals')).map(n => readFileSync(join(f.dataRoot, 'authority-proposals', n), 'utf8'))];
|
||||
for (const text of texts) {
|
||||
assert.ok(!text.includes(SECRET)); assert.ok(!text.includes(join(f.root, 'secrets')));
|
||||
}
|
||||
const refusals = [];
|
||||
for (const args of [[f.bind({ gitea: { scopes: ['write:organization'], projects: ['stack'] } }), ID2], [{ ...f.bind(), name: 'gone' }, ID3]]) {
|
||||
await f.prepare(...args).catch(e => refusals.push(e));
|
||||
}
|
||||
const t2 = { ...JSON.parse(readFileSync(join(f.dataRoot, 'authority-proposals', `${p.id}.json`), 'utf8')) };
|
||||
try { f.writer.applyCopyBindingDecision('demo', { ...t2, revision: f.writer.read('demo').revision }, { actor: 'chief', owner: true }); } catch (e) { refusals.push(e); }
|
||||
assert.equal(refusals.length, 3);
|
||||
for (const e of refusals) assert.ok(!e.message.includes('/') && !e.message.includes(SECRET), e.message);
|
||||
assert.equal(refusals[2].message, 'Only the organization owner can approve credential binding.');
|
||||
void sha;
|
||||
});
|
||||
|
||||
test('CB1 fix: the free-text reason is never written to a binding proposal', async t => {
|
||||
const f = setup(t);
|
||||
const leak = '/home/someone/.config/mosaic-dev/secrets/gitea tok-fragment-9f8e';
|
||||
const p = await f.prepare(f.bind(GITEA, { reason: `Bind; see ${leak}` }));
|
||||
const stored = readFileSync(join(f.dataRoot, 'authority-proposals', `${p.id}.json`), 'utf8');
|
||||
assert.ok(!stored.includes(leak) && !stored.includes('tok-fragment') && !stored.includes('"reason"'), 'no reason text on disk');
|
||||
assert.deepEqual(Object.keys(JSON.parse(stored).request).sort(), ['binding', 'level', 'name', 'op']);
|
||||
assert.ok(!JSON.stringify(p).includes('tok-fragment'));
|
||||
// The proposal still applies on its digest.
|
||||
await f.service.apply('demo', p.id, { ...OWNER, digest: p.digest });
|
||||
assert.equal(f.writer.read('demo').document.copyBindings.rev.state, 'pending');
|
||||
assert.ok(!readFileSync(join(f.root, 'businesses/.history/demo/changes.jsonl'), 'utf8').includes('tok-fragment'));
|
||||
});
|
||||
|
||||
test('CB1 fix: a root copy removed or replaced since boot refuses at prepare, at apply and before publication', async t => {
|
||||
const receipts = root => join(root, 'agents-receipts.jsonl');
|
||||
const add = (root, row) => writeFileSync(receipts(root), JSON.stringify(row) + '\n', { flag: 'a', mode: 0o600 });
|
||||
// Removed since boot: prepare refuses instance-fired; replaced: copy-stale.
|
||||
for (const [verb, code] of [['root.agent.remove', 'instance-fired'], ['root.agent.set', 'copy-stale']]) {
|
||||
const f = setup(t); await f.ready;
|
||||
add(f.root, { agent: 'rev', verb, at: FUTURE });
|
||||
await assert.rejects(f.prepare(), { code }, verb);
|
||||
let saved = []; try { saved = readdirSync(join(f.dataRoot, 'authority-proposals')); } catch (e) { if (e.code !== 'ENOENT') throw e; }
|
||||
assert.deepEqual(saved, [], verb);
|
||||
}
|
||||
// Prepared while fresh, then the root copy is removed: apply refuses and writes nothing.
|
||||
{
|
||||
const f = setup(t); const p = await f.prepare(); const before = f.bytes();
|
||||
add(f.root, { agent: 'rev', verb: 'root.agent.remove', at: FUTURE });
|
||||
await assert.rejects(f.service.apply('demo', p.id, { ...OWNER, digest: p.digest }), { code: 'instance-fired' });
|
||||
assert.deepEqual(f.bytes(), before);
|
||||
}
|
||||
// The change lands after checkBinding, under the lock: the publication fence still refuses.
|
||||
{
|
||||
const f = setup(t); const p = await f.prepare(); const before = f.bytes();
|
||||
let wrote = false;
|
||||
const late = () => { if (!wrote) { wrote = true; add(f.root, { agent: 'rev', verb: 'root.agent.set', at: FUTURE }); } };
|
||||
await assert.rejects(f.service.apply('demo', p.id, { ...OWNER, digest: p.digest, beforeWrite: late }), { code: 'copy-stale' });
|
||||
assert.equal(wrote, true);
|
||||
assert.deepEqual(f.bytes(), before);
|
||||
}
|
||||
// A receipt for another root agent leaves this copy fresh; an unreadable receipts file refuses.
|
||||
{
|
||||
const f = setup(t); await f.ready;
|
||||
add(f.root, { agent: 'other', verb: 'root.agent.remove', at: FUTURE });
|
||||
const p = await f.prepare();
|
||||
writeFileSync(receipts(f.root), 'not json\n', { mode: 0o600 });
|
||||
await assert.rejects(f.service.apply('demo', p.id, { ...OWNER, digest: p.digest }), { code: 'root-policy-unreadable' });
|
||||
}
|
||||
// A project copy does not read root receipts.
|
||||
{
|
||||
const f = setup(t); await f.ready;
|
||||
add(f.root, { agent: 'checker', verb: 'root.agent.remove', at: FUTURE });
|
||||
const p = await f.prepare({ op: 'change', level: 'project', project: 'stack', name: 'checker', reason: 'Bind', binding: { services: GITEA } });
|
||||
await f.service.apply('demo', p.id, { ...OWNER, digest: p.digest });
|
||||
assert.equal(f.writer.read('demo').document.copyBindings['checker.stack'].state, 'pending');
|
||||
}
|
||||
});
|
||||
|
||||
test('CB1 the org service refuses copyBindings on create and on a whole-document update', async t => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'cb1-svc-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const c = { configDir: join(root, 'config'), dataRoot: join(root, 'data'), instance: 'test', principal: { provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner' } };
|
||||
mkdirSync(c.configDir, { mode: 0o700 }); mkdirSync(c.dataRoot, { mode: 0o700 });
|
||||
const forged = { rev: { ...record(), state: 'bound' } };
|
||||
await assert.rejects(createOrg(c, 'alpha', { ...await templateRender('alpha'), copyBindings: forged }), { code: 'organization-bindings-protected' });
|
||||
await createOrg(c, 'alpha', await templateRender('alpha'));
|
||||
const current = await showOrg(c, 'alpha');
|
||||
await assert.rejects(updateOrg(c, 'alpha', { ...current.doc, copyBindings: forged }, { ifRevision: current.revision }), { code: 'organization-bindings-protected' });
|
||||
assert.equal((await showOrg(c, 'alpha')).doc.copyBindings, undefined);
|
||||
await updateOrg(c, 'alpha', current.doc, { ifRevision: current.revision });
|
||||
});
|
||||
|
||||
test('CB1 fix: a root copy binds only projects whose union still admits it', async t => {
|
||||
const f = setup(t, { edit: doc => { doc.projects.web = { root: doc.projects.stack.root }; doc.projects.stack.agents.rev = { exclude: true }; } });
|
||||
// stack excludes rev: a request naming it refuses whole, never trimmed to web.
|
||||
await assert.rejects(f.prepare(f.bind({ gitea: { scopes: ['read:repository'], projects: ['stack', 'web'] } })), { code: 'credential-scope-exceeded' });
|
||||
await assert.rejects(f.prepare(f.bind({ gitea: { scopes: ['read:repository'], projects: ['stack'] } }, {}), ID2), { code: 'credential-scope-exceeded' });
|
||||
// web still admits it.
|
||||
const p = await f.prepare(f.bind({ gitea: { scopes: ['read:repository'], projects: ['web'] } }), ID3);
|
||||
assert.deepEqual(p.binding.after.gitea.resources.projects, ['web']);
|
||||
});
|
||||
|
||||
test('CB1 fix: a named instance added after boot shadows the copy at proposal time', async t => {
|
||||
const f = setup(t); await f.ready;
|
||||
const r = f.writer.read('demo'), edited = structuredClone(r.document);
|
||||
const like = Object.values(edited.roles).find(e => e.definition === 'reviewer');
|
||||
edited.roles.rev = { ...structuredClone(like), tracker: { bot: 'bot-demo-rev', botId: 977 } };
|
||||
f.writer.write('demo', edited, { revision: r.revision, actor: 'jason' });
|
||||
await assert.rejects(f.prepare(), { code: 'copy-unavailable' });
|
||||
});
|
||||
|
||||
test('CB1 fix: the loader refuses a copyBindings record holding a token, naming neither the value nor a path', t => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'cb1-load-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const roles = rolesCopy(root);
|
||||
const doc = { ...businessDoc(root, 'demo'), copyBindings: { rev: { ...record(), state: 'pending', token: SECRET } } };
|
||||
const file = join(root, 'businesses/demo.json');
|
||||
assert.throws(() => validateBusinessDocument(doc, file, { rolesDir: roles }), e => e.name === 'BusinessError' && !e.message.includes(SECRET) && !e.message.includes(root));
|
||||
// The same document without the token loads.
|
||||
const { token: _, ...clean } = doc.copyBindings.rev;
|
||||
validateBusinessDocument({ ...doc, copyBindings: { rev: clean } }, file, { rolesDir: roles });
|
||||
});
|
||||
@@ -30,6 +30,8 @@ import { fileURLToPath } from 'node:url';
|
||||
import { isAbsolute, join } from 'node:path';
|
||||
import { actionTrail } from './action-trail.mjs';
|
||||
import { reviveBuses } from './bus-revive.mjs';
|
||||
import { createProxyAdmin, orgCatalogue } from './proxy-admin.mjs';
|
||||
import { busesStopped } from '../../cli/src/proxy-admin-host.mjs';
|
||||
import { agentLevelsWriteRoutes } from './agent-levels-write.mjs';
|
||||
import { RESTART_EXIT } from '../../cli/src/org-console.mjs';
|
||||
import { ID_PATTERN } from '../../organization/src/vocabulary.mjs';
|
||||
@@ -49,7 +51,7 @@ if (process.platform === 'linux') {
|
||||
}
|
||||
}
|
||||
const args = process.argv.slice(2);
|
||||
const usage = 'node packages/webui/src/cli.mjs serve [--port N] [--tester-socket] [--checkout PATH] [--board http://127.0.0.1:7331] [--business ID] [--sso | --dev-local-owner [--dev-insecure-bind ADDR --dev-allow-host NAME[,NAME...]]] [--send] [--supervised] [--public-host NAME --auth-header HEADER --allow-user ID [--proof-header NAME --proof-file PATH]]';
|
||||
const usage = 'node packages/webui/src/cli.mjs serve [--port N] [--tester-socket] [--checkout PATH] [--board http://127.0.0.1:7331] [--business ID] [--sso | --dev-local-owner [--dev-insecure-bind ADDR --dev-allow-host NAME[,NAME...]]] [--send] [--supervised] [--proxy-unit-dir PATH] [--public-host NAME --auth-header HEADER --allow-user ID [--proof-header NAME --proof-file PATH]]';
|
||||
try {
|
||||
if (args.length === 1 && ['--help', '-h'].includes(args[0])) { console.log(usage); process.exit(0); }
|
||||
if (args.shift() !== 'serve') throw new Error(usage);
|
||||
@@ -100,6 +102,11 @@ try {
|
||||
if (devBind !== null) throw new Error('--dev-insecure-bind takes one address');
|
||||
devBind = value;
|
||||
} else if (flag === '--dev-allow-host') devNames.push(value);
|
||||
// #1873: where the Proxy page installs the proxy user unit; proxy-service.sh's default otherwise.
|
||||
else if (flag === '--proxy-unit-dir') {
|
||||
if (!isAbsolute(value)) throw new Error('--proxy-unit-dir must be an absolute path');
|
||||
options.proxyUnitDir = value;
|
||||
}
|
||||
else throw new Error(`unknown option: ${flag}`);
|
||||
}
|
||||
const stamp = assertBuildStart({ serve: true, sso: ssoOn, devOwner, devBind, devNames });
|
||||
@@ -194,6 +201,23 @@ try {
|
||||
}) : null;
|
||||
// #1873 A1: the root agent level, read fresh on each request; the Agents grid's only new read.
|
||||
const agentLevels = organizations ? { loadRoot: () => loadRootAgents(configDir(), { rolesDir: rolesDir() }), rolesDir: rolesDir(), ctx: { configDir: configDir(), dataRoot: system.dataRoot, instance } } : null;
|
||||
// #1873: proxy setup for the instance owner (dev local owner, or the root owner under --sso).
|
||||
// Instance-wide: one pinned release, one proxy.json, one unit. Each organization uses it (shared).
|
||||
const proxyAdmin = system && (devOwner || ssoOn) ? createProxyAdmin({
|
||||
dataRoot: system.dataRoot, configDir: configDir(), instance, unitDir: options.proxyUnitDir ?? null,
|
||||
buses: () => busesStopped({ dataRoot: system.dataRoot, configDir: configDir() }),
|
||||
orgs: async () => {
|
||||
const catalogue = orgCatalogue;
|
||||
if (!organizations) return [{ id: options.business ?? null, proxy: 'shared', catalogue: await catalogue(ownerCall ?? (async () => { throw new Error(); })) }];
|
||||
const out = [];
|
||||
for (const org of await listOrgs({ configDir: configDir(), dataRoot: system.dataRoot, instance })) {
|
||||
if (org.state !== 'active') continue;
|
||||
let paths; try { paths = orgPaths(org.root); } catch { out.push({ id: org.id, proxy: 'shared', catalogue: { status: 'bus-unavailable', models: null } }); continue; }
|
||||
out.push({ id: org.id, proxy: 'shared', catalogue: await catalogue(busCaller({ dataRoot: org.root, socket: paths.socket, business: org.id, timeoutMs: 5000 })) });
|
||||
}
|
||||
return out;
|
||||
},
|
||||
}) : null;
|
||||
let server, testerServer = null;
|
||||
// F3: the restart route closes every listener and connection, then exits RESTART_EXIT for the
|
||||
// supervisor to relaunch. Without --supervised the route answers not-supervised.
|
||||
@@ -207,7 +231,7 @@ try {
|
||||
// #1873 A2: Settings > Agents writes for the root and project levels, beside the levels read.
|
||||
const agentLevelRoutes = organizations ? [agentLevelsWriteRoutes({ configDir: configDir(), rolesDir: rolesDir(), trail })] : [];
|
||||
try {
|
||||
server = await startServer({ restart, trail, ...(lan ? { host: lan.bind, devAllowHosts: lan.allowHosts } : {}), board: options.board, port: options.port, bus, reads, preview: options.preview, sso, proof, send, settingsWrite, projectFiles, projectFile, libraryFile, libraryStatus, librarySync, libraryRevert, librarySave, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, proxyCall, processing, decisionWrite, requestRead, requestWrite, userOptions, federation: system?.federation ?? null, organizations, agentLevels, routes: agentLevelRoutes, preselect: options.business ?? null });
|
||||
server = await startServer({ restart, trail, ...(lan ? { host: lan.bind, devAllowHosts: lan.allowHosts } : {}), board: options.board, port: options.port, bus, reads, preview: options.preview, sso, proof, send, settingsWrite, projectFiles, projectFile, libraryFile, libraryStatus, librarySync, libraryRevert, librarySave, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, proxyCall, proxyAdmin, processing, decisionWrite, requestRead, requestWrite, userOptions, federation: system?.federation ?? null, organizations, agentLevels, routes: agentLevelRoutes, preselect: options.business ?? null });
|
||||
} catch (err) { sso?.close(); throw err; }
|
||||
if (testerSocket) {
|
||||
const call = busCaller({ dataRoot: system.dataRoot, socket: socketPath(system.dataRoot), business: options.business ?? null, cli: TESTER_CLI });
|
||||
|
||||
@@ -10,7 +10,9 @@ import { views } from '../../bus/src/views.mjs';
|
||||
export const AGENT_VERBS = ['instance.launch', 'instance.regenerate', 'instance.end', 'veto.set', 'instance.request'];
|
||||
// P4 owner transport only; the HTTP boundary and broker both refuse users/testers.
|
||||
export const PROXY_VERBS = Object.freeze(['proxy.accounts', 'proxy.login.start', 'proxy.login.submit',
|
||||
'proxy.login.status', 'proxy.login.cancel', 'proxy.apikey.add', 'proxy.priority.set', 'proxy.group.models.set', 'proxy.keys', 'plugins', 'plugins.set']);
|
||||
'proxy.login.status', 'proxy.login.cancel', 'proxy.apikey.add', 'proxy.priority.set', 'proxy.group.models.set', 'proxy.keys', 'plugins', 'plugins.set',
|
||||
// #1873: proxy setup from the page. The broker gates both to the human owner.
|
||||
'proxy.init', 'proxy.fence.clear']);
|
||||
export const TESTER_AGENT_VERBS = ['instance.launch', 'instance.end', 'instance.request'];
|
||||
// U4b (#1873): the task board's writes, the person's built-in store verbs (broker tasks.*). The
|
||||
// caller refuses any other, so the board's taskWrite never reaches unarchive or delete.
|
||||
|
||||
@@ -18,6 +18,7 @@
|
||||
// POST /api/orgs/ID/setup/bus C3 busStart mosaic bus start --detach
|
||||
// POST /api/orgs/ID/restart/bus C3 busRestart mosaic bus restart (F3; busStart when stopped;
|
||||
// busRevive for a host that died, dev-local owner only, D2)
|
||||
// POST /api/orgs/ID/stop/bus C3 busStop mosaic bus stop (a stopped bus answers state stopped)
|
||||
//
|
||||
// GET /api/orgs (the list) and other /api/orgs/ID/... routes are S5's; this
|
||||
// handler returns false for them.
|
||||
@@ -35,7 +36,7 @@ const ID = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
||||
const KEY = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
||||
const ENV_NAME = /^[A-Z][A-Z0-9_]{0,63}$/;
|
||||
const MAX_BODY = 64 * 1024;
|
||||
const ROUTE = /^\/api\/orgs\/([^/]+)\/(admin|setup|delete|archive|restore|restart)(?:\/(queue|bus|project\/([^/]+)|records\/([^/]+)))?$/;
|
||||
const ROUTE = /^\/api\/orgs\/([^/]+)\/(admin|setup|delete|archive|restore|restart|stop)(?:\/(queue|bus|project\/([^/]+)|records\/([^/]+)))?$/;
|
||||
// The org-template placeholder (packages/organization/src/org-service.mjs templateRender).
|
||||
export const placeholderRoot = (id, project) => `/__mosaic_org__/${id}/projects/${project}`;
|
||||
// Project fields the server owns. The browser never sees or sends them. `agents` (#1873 A1) is
|
||||
@@ -54,6 +55,7 @@ export function verbFor(step, id, opts = {}) {
|
||||
if (step === 'records') return `mosaic project init ${id} ${opts.project}`;
|
||||
if (step === 'bus') return `mosaic bus start ${id} --dev-local-owner --detach`;
|
||||
if (step === 'bus-restart') return `mosaic bus restart ${id}`;
|
||||
if (step === 'bus-stop') return `mosaic bus stop ${id}`;
|
||||
if (step === 'bus-revive') return `mosaic bus recover ${id}; mosaic bus start ${id} --dev-local-owner --detach`;
|
||||
throw new Error(`unknown step ${step}`);
|
||||
}
|
||||
@@ -246,6 +248,10 @@ export function toCanonical(id, doc, stored = null) {
|
||||
};
|
||||
for (const [name, role] of Object.entries(stored?.roles ?? {})) if (out.roles?.[name] && role?.credentials) { out.roles[name].credentials ??= {}; restore(out.roles[name].credentials, role.credentials); }
|
||||
if (stored?.tracker?.sync?.credentials && out.tracker?.sync) { out.tracker.sync.credentials ??= {}; restore(out.tracker.sync.credentials, stored.tracker.sync.credentials); }
|
||||
// #1873 CB1: copy bindings are server-held on an update. Only an owner-approved binding decision
|
||||
// changes them, so the stored ones go back whatever the browser sent. A create passes the
|
||||
// browser's value through and the service refuses any binding there.
|
||||
if (stored) { if (Object.hasOwn(stored, 'copyBindings')) out.copyBindings = structuredClone(stored.copyBindings); else delete out.copyBindings; }
|
||||
return out;
|
||||
}
|
||||
|
||||
@@ -518,6 +524,7 @@ async function route(req, res, { path, search, org, admin, principal, write, log
|
||||
try { return json(res, 200, await readSetup(admin, ctx, id)); } catch (e) { return refusal(res, e, { step: 'setup', id }); }
|
||||
}
|
||||
if (kind === 'restart') return restartBus(req, res, { sub, write, id, ctx, service, admin, log, trail, by });
|
||||
if (kind === 'stop') return stopBus(req, res, { sub, write, id, ctx, service, admin, log, trail, by });
|
||||
if (kind !== 'setup' || !sub || !write) return json(res, 404, { error: 'not found' });
|
||||
|
||||
// Every setup write reads the step back first and never overwrites.
|
||||
@@ -609,6 +616,64 @@ async function restartBus(req, res, { sub, write, id, ctx, service, admin, log,
|
||||
return json(res, 200, { outcome: 'done', step, restarted: running, healthy, receipt: forReceipt(ctx, receipt), verb: running ? verbFor('bus-restart', id) : verbFor('bus', id) });
|
||||
}
|
||||
|
||||
// Stop bus: the same owner, origin, --send and trail rules as Restart bus. C3 busStop sends SIGTERM
|
||||
// to the proven host and waits for it to exit; the host's close() removes host.json and host.lock,
|
||||
// and the broker's store removes writer.lock, so a clean stop leaves no lock behind (an offline
|
||||
// migrate-schema run can follow). The bus state is read back after the stop: a host or lock still
|
||||
// there, or state that does not check out, is a failure, not a done receipt. A host whose state
|
||||
// cannot be proven refuses before anything changes; there is no recovery path here, unlike Restart
|
||||
// bus on the dev Console. A bus that is already stopped answers done with state stopped.
|
||||
async function stopBus(req, res, { sub, write, id, ctx, service, admin, log, trail, by }) {
|
||||
const step = 'bus-stop';
|
||||
if (sub !== 'bus' || !write) return json(res, 404, { error: 'not found' });
|
||||
const body = await readBody(req); strictKeys(body, []);
|
||||
try { await service.showOrg(ctx, id); } catch (e) { return refusal(res, e, { step, id }); }
|
||||
const bus = await need(admin, 'bus', res, step); if (!bus) return;
|
||||
if (typeof bus.busStop !== 'function') return json(res, 503, { error: 'not-configured', step, message: 'bus control cannot stop a bus on this Console yet' });
|
||||
if (!trail) return json(res, 503, { error: 'trail-unavailable', step, message: 'this Console has no action trail, so it does not stop buses' });
|
||||
// The dev Console's start-up revive may be bringing this bus back; the press acts on the result.
|
||||
await revivePending(id);
|
||||
let status;
|
||||
try { status = await bus.busStatus(ctx, id); }
|
||||
catch (e) {
|
||||
if (['org-unknown', 'org-mismatch', 'org-unavailable', 'org-archived'].includes(e?.code)) return refusal(res, e, { step, id });
|
||||
if (isCliError(e)) {
|
||||
// Refused before anything changed, but the owner asked: the trail records the attempt.
|
||||
try { trail({ by, action: 'bus.stop', org: id, outcome: 'refused', running: null }); } catch { log(`org-admin: ${id} bus stop: trail write failed after refused`); }
|
||||
return json(res, 409, { error: 'bus-refused', step, message: `${plainMessage(ctx, e.message)}. Inspect it with mosaic bus status ${id}; mosaic bus recover ${id} clears a host that died.` });
|
||||
}
|
||||
return refusal(res, e, { step, id });
|
||||
}
|
||||
const running = Number.isSafeInteger(status?.pid);
|
||||
try { trail({ by, action: 'bus.stop', org: id, outcome: 'requested', running }); }
|
||||
catch { return json(res, 500, { error: 'trail-unavailable', step, message: 'the action trail could not be written, so nothing was stopped' }); }
|
||||
const note = outcome => { try { trail({ by, action: 'bus.stop', org: id, outcome }); } catch { log(`org-admin: ${id} bus stop: trail write failed after ${outcome}`); } };
|
||||
if (!running) {
|
||||
note('done');
|
||||
return json(res, 200, { outcome: 'done', step, stopped: false, state: 'stopped', receipt: { org: id, stopped: false }, verb: verbFor('bus-stop', id) });
|
||||
}
|
||||
let receipt;
|
||||
try { receipt = await bus.busStop(ctx, id); }
|
||||
catch (e) {
|
||||
// Only exit 3 comes before the signal; a timeout or an unexpected error may follow SIGTERM.
|
||||
note(isCliError(e) && e.exitCode === 3 ? 'refused' : 'failed');
|
||||
if (!isCliError(e)) return refusal(res, e, { step, id, unknownOk: true });
|
||||
return json(res, 409, { error: e.exitCode === 3 ? 'bus-refused' : 'bus-failed', step, message: `${plainMessage(ctx, e.message)}. Inspect it with mosaic bus status ${id}.` });
|
||||
}
|
||||
// Read the state back: the stop is done only when no host and no lock is left.
|
||||
let after = null, left = 'a bus host still runs';
|
||||
try { after = await bus.busStatus(ctx, id); }
|
||||
catch (e) { if (!isCliError(e)) { note('failed'); return refusal(res, e, { step, id, unknownOk: true }); } left = plainMessage(ctx, e.message); }
|
||||
if (!after || Number.isSafeInteger(after.pid)) {
|
||||
note('failed');
|
||||
return json(res, 409, { error: 'bus-failed', step, message: `the bus host was stopped, but ${left}. Inspect it with mosaic bus status ${id}; mosaic bus recover ${id} clears a host that died.` });
|
||||
}
|
||||
note('done');
|
||||
log(`org-admin: ${id} bus stopped`);
|
||||
// A host that exited on its own between the read and the stop was not stopped by this press.
|
||||
return json(res, 200, { outcome: 'done', step, stopped: receipt?.stopped === true, state: 'stopped', receipt: forReceipt(ctx, { org: id, ...receipt }), verb: verbFor('bus-stop', id) });
|
||||
}
|
||||
|
||||
// The dev-local owner exactly (packages/cli/src/dev-local-owner.mjs). A signed-in session carries
|
||||
// its provider's issuer, so no SSO user matches.
|
||||
const isDevOwner = by => by?.provider === DEV_OWNER.provider && by.iss === DEV_OWNER.iss && by.sub === DEV_OWNER.sub;
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
// #1873 model proxy setup from the Console: the owner-only HTTP routes. Same checks as
|
||||
// proxy-http.mjs. The controller (packages/model-proxy/src/admin-control.mjs) runs host
|
||||
// actions; init and the fence clear go to the bus, which decides them. No key, token, path or
|
||||
// upstream text reaches a response, receipt or log line.
|
||||
import { isIP } from 'node:net';
|
||||
import { validManagementKey, proxyTiming } from '../../model-proxy/src/common.mjs';
|
||||
import { createProxyAdmin, proxyCode } from '../../model-proxy/src/admin-control.mjs';
|
||||
|
||||
export { createProxyAdmin };
|
||||
|
||||
// #1873: one organization's catalogue row for the setup page. A count is shown only when the bus
|
||||
// answered with a model list; otherwise models is null and the page shows a dash, never a made-up 0.
|
||||
export async function orgCatalogue(call) {
|
||||
try {
|
||||
const value = await call('routes');
|
||||
return { status: value?.catalogue?.status ?? 'unknown', models: Array.isArray(value?.catalogue?.models) ? value.catalogue.models.length : null };
|
||||
} catch { return { status: 'bus-unavailable', models: null }; }
|
||||
}
|
||||
|
||||
const ROUTES = Object.freeze({
|
||||
'/api/proxy/admin/status': ['GET', []],
|
||||
'/api/proxy/admin/unit/render': ['GET', []],
|
||||
'/api/proxy/admin/fetch': ['POST', []],
|
||||
'/api/proxy/admin/init': ['POST', null],
|
||||
'/api/proxy/admin/unit/install': ['POST', []],
|
||||
'/api/proxy/admin/unit/start': ['POST', []],
|
||||
'/api/proxy/admin/unit/stop': ['POST', []],
|
||||
'/api/proxy/admin/unit/restart': ['POST', []],
|
||||
'/api/proxy/admin/fence/clear': ['POST', []],
|
||||
'/api/proxy/admin/init-lock/clear': ['POST', []],
|
||||
});
|
||||
export const PROXY_ADMIN_PATHS = Object.freeze(Object.keys(ROUTES));
|
||||
const INIT_KEYS = ['generate', 'key', 'confirm', 'port', 'rotateGraceMinutes', 'firstOutputSeconds'];
|
||||
const json = (res, status, value) => {
|
||||
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(value));
|
||||
};
|
||||
const loopbackName = name => name === 'localhost' || name === '[::1]' || name === '::1' || (isIP(name) === 4 && name.startsWith('127.'));
|
||||
const loopbackAddress = address => typeof address === 'string' && (address === '::1' || /^(::ffff:)?127\./.test(address));
|
||||
// A pasted management key crosses only HTTPS or loopback. A plain-HTTP LAN page gets the generated key.
|
||||
export function keyTransportOk(origin, remoteAddress) {
|
||||
let url; try { url = new URL(origin); } catch { return false; }
|
||||
if (url.protocol === 'https:') return true;
|
||||
return url.protocol === 'http:' && loopbackName(url.hostname) && loopbackAddress(remoteAddress);
|
||||
}
|
||||
const STATUS = { 'human-required': 403, 'proxy-key-transport': 403, 'not-configured': 503, 'proxy-down': 503, 'outcome-unknown': 503, 'proxy-admin-busy': 409 };
|
||||
|
||||
// `owner` is true only for the instance owner's session (the dev local owner, or the root owner
|
||||
// signed in under --sso). `call` is the owner's transport to the selected bus, or null.
|
||||
export async function proxyAdminHTTP(req, res, { path, admin, call, named, tester, granted, owner, send, origin, user, log }) {
|
||||
const route = ROUTES[path];
|
||||
const done = (status, value) => {
|
||||
// Path, method and status only: never a body, key, evidence or exception text.
|
||||
log(`${new Date().toISOString()} ${route?.[0] ?? req.method} ${route ? path : '/api/proxy/admin/unknown'} ${status} user=${JSON.stringify(user ?? '')}`);
|
||||
return json(res, status, value);
|
||||
};
|
||||
const refuse = code => done(STATUS[code] ?? 400, { error: code });
|
||||
if (tester || granted || !named || owner !== true) return refuse('human-required');
|
||||
if (!route) return done(404, { error: 'not found' });
|
||||
if (req.method !== route[0]) return done(405, { error: 'method not allowed' });
|
||||
if (req.method === 'POST') {
|
||||
if (!send) return done(403, { error: 'read-only' });
|
||||
if (req.headersDistinct.origin?.length !== 1 || req.headers.origin !== origin) return done(403, { error: 'cross-origin request refused' });
|
||||
if (req.headersDistinct['sec-fetch-site']?.length !== 1 || req.headers['sec-fetch-site'] !== 'same-origin') return done(403, { error: 'cross-site request refused' });
|
||||
}
|
||||
if (!admin) return refuse('not-configured');
|
||||
let args = {};
|
||||
if (req.method === 'POST') {
|
||||
try {
|
||||
if ((req.headers['content-type'] ?? '').split(';')[0].trim().toLowerCase() !== 'application/json') throw Error();
|
||||
const chunks = []; let size = 0;
|
||||
for await (const chunk of req) { size += chunk.length; if (size > 16384) throw Error(); chunks.push(chunk); }
|
||||
args = JSON.parse(Buffer.concat(chunks));
|
||||
if (!args || typeof args !== 'object' || Array.isArray(args)) throw Error();
|
||||
const allowed = route[1] ?? INIT_KEYS;
|
||||
if (Object.keys(args).some(k => !allowed.includes(k))) throw Error();
|
||||
} catch { return refuse('invalid-request'); }
|
||||
}
|
||||
try {
|
||||
switch (path) {
|
||||
case '/api/proxy/admin/status': return done(200, await admin.status());
|
||||
case '/api/proxy/admin/unit/render': return done(200, await admin.render());
|
||||
case '/api/proxy/admin/fetch': return done(202, admin.fetchStart());
|
||||
case '/api/proxy/admin/init': {
|
||||
const timing = {};
|
||||
for (const field of ['port', 'rotateGraceMinutes', 'firstOutputSeconds']) if (Object.hasOwn(args, field)) timing[field] = args[field];
|
||||
if (Object.hasOwn(timing, 'port') && (!Number.isInteger(timing.port) || timing.port < 1024 || timing.port > 65535 || timing.port === 8317)) return refuse('proxy-config-invalid');
|
||||
try { proxyTiming(timing); } catch (error) { return refuse(proxyCode(error, 'proxy-config-invalid')); }
|
||||
let initArgs;
|
||||
if (args.generate === true && !Object.hasOwn(args, 'key') && !Object.hasOwn(args, 'confirm')) initArgs = { generate: true, ...timing };
|
||||
else if (!Object.hasOwn(args, 'generate') && Object.hasOwn(args, 'key')) {
|
||||
// The transport check comes before the key is examined, so a refused key is never judged.
|
||||
if (!keyTransportOk(origin, req.socket?.remoteAddress)) return refuse('proxy-key-transport');
|
||||
if (typeof args.key !== 'string' || args.key !== args.confirm) return refuse('proxy-key-mismatch');
|
||||
if (!validManagementKey(args.key)) return refuse('proxy-key-invalid');
|
||||
initArgs = { key: args.key, ...timing };
|
||||
} else return refuse('invalid-request');
|
||||
if (!call) return refuse('not-configured');
|
||||
return done(200, await admin.init(call, initArgs));
|
||||
}
|
||||
case '/api/proxy/admin/unit/install': return done(200, await admin.unitAction('install'));
|
||||
case '/api/proxy/admin/unit/start': return done(200, await admin.unitAction('start'));
|
||||
case '/api/proxy/admin/unit/stop': return done(200, await admin.unitAction('stop'));
|
||||
case '/api/proxy/admin/unit/restart': return done(200, await admin.unitAction('restart'));
|
||||
case '/api/proxy/admin/fence/clear':
|
||||
if (!call) return refuse('not-configured');
|
||||
return done(200, await admin.fenceClear(call));
|
||||
case '/api/proxy/admin/init-lock/clear': return done(200, await admin.initLockClear());
|
||||
}
|
||||
} catch (error) {
|
||||
const code = ['human-required', 'not-configured', 'invalid-request'].includes(error?.code) ? error.code : proxyCode(error, 'outcome-unknown');
|
||||
return refuse(code);
|
||||
}
|
||||
return done(404, { error: 'not found' });
|
||||
}
|
||||
@@ -15,7 +15,7 @@
|
||||
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
||||
const clock = () => new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
let config = null, boot = null, reconnecting = false, planned = false;
|
||||
let restartState = 'idle', restartReceipt = '', busReceipt = '', busBusy = false;
|
||||
let restartState = 'idle', restartReceipt = '', busReceipt = '', busBusy = false, busAction = 'restart';
|
||||
|
||||
const readConfig = () => nativeFetch('/api/config', { cache: 'no-store', redirect: 'manual' })
|
||||
.then(r => (r.type === 'opaqueredirect' ? null : r.ok ? r.json() : null));
|
||||
@@ -119,11 +119,17 @@
|
||||
else if (e.target.id === 'console-restart-go' && restartState === 'confirm') restartConsole();
|
||||
});
|
||||
|
||||
// The org bus restart control, offered on the organization's own pages to its owner.
|
||||
// The org bus restart control, offered on the organization's own pages to its owner. With
|
||||
// { stop: true } (the organization header) a Stop bus button sits beside it: one press, like
|
||||
// Restart bus, sharing its receipt and busy state. glyph is the caller's own icon markup, kept
|
||||
// inside the restart button so it stays on the button whatever width the receipt takes.
|
||||
const busAllowed = () => !!window.MosaicOrg && config?.orgSetup === true;
|
||||
function busButton(label = 'Restart bus') {
|
||||
function busButton(label = 'Restart bus', { stop = false, glyph = '' } = {}) {
|
||||
if (!busAllowed()) return '';
|
||||
return `<span class="bus-restart"><button type="button" class="btn" data-bus-restart data-label="${esc(label)}"${busBusy ? ' disabled' : ''}>${esc(busBusy ? 'Restarting…' : label)}</button> <span role="status" data-bus-restart-receipt>${esc(busReceipt)}</span></span>`;
|
||||
const off = busBusy ? ' disabled' : '';
|
||||
const restart = `<button type="button" class="btn" data-bus-restart data-label="${esc(label)}"${stop ? ' data-stop' : ''}${off}>${glyph}${esc(busBusy && busAction === 'restart' ? 'Restarting…' : label)}</button>`;
|
||||
const buttons = stop ? `<span class="bus-buttons">${restart} <button type="button" class="btn" data-bus-stop${off}>${busBusy && busAction === 'stop' ? 'Stopping…' : 'Stop bus'}</button></span>` : restart;
|
||||
return `<span class="bus-restart">${buttons} <span role="status" data-bus-restart-receipt>${esc(busReceipt)}</span></span>`;
|
||||
}
|
||||
async function attachBus(box, label) {
|
||||
await ready;
|
||||
@@ -133,14 +139,16 @@
|
||||
box.insertAdjacentHTML('beforeend', ` ${busButton(label)}`);
|
||||
}
|
||||
const paintBus = () => document.querySelectorAll('.bus-restart').forEach(el => {
|
||||
const label = el.querySelector('[data-bus-restart]')?.dataset.label;
|
||||
el.outerHTML = busButton(label);
|
||||
const button = el.querySelector('[data-bus-restart]');
|
||||
el.outerHTML = busButton(button?.dataset.label, { stop: button?.hasAttribute('data-stop'), glyph: button?.querySelector('svg')?.outerHTML || '' });
|
||||
});
|
||||
document.addEventListener('click', async e => {
|
||||
const button = e.target.closest?.('[data-bus-restart]');
|
||||
const button = e.target.closest?.('[data-bus-restart],[data-bus-stop]');
|
||||
if (!button || button.disabled || busBusy || !busAllowed()) return;
|
||||
const id = window.MosaicOrg.id;
|
||||
busAction = button.hasAttribute('data-bus-stop') ? 'stop' : 'restart';
|
||||
busBusy = true; busReceipt = ''; paintBus();
|
||||
if (busAction === 'stop') return stopBus(id);
|
||||
try {
|
||||
const response = await nativeFetch(`/api/orgs/${encodeURIComponent(id)}/restart/bus`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}', cache: 'no-store', redirect: 'manual' });
|
||||
if (response.type === 'opaqueredirect') throw new Error('Sign in again, then retry.');
|
||||
@@ -170,5 +178,23 @@
|
||||
} finally { busBusy = false; paintBus(); }
|
||||
});
|
||||
|
||||
// Stop bus: the bus host stops cleanly and leaves no lock behind. An already stopped bus answers
|
||||
// done with state stopped. The page reads the list and its send flags again, as after a restart.
|
||||
async function stopBus(id) {
|
||||
try {
|
||||
const response = await nativeFetch(`/api/orgs/${encodeURIComponent(id)}/stop/bus`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}', cache: 'no-store', redirect: 'manual' });
|
||||
if (response.type === 'opaqueredirect') throw new Error('Sign in again, then retry.');
|
||||
const body = await response.json();
|
||||
if (!response.ok || body.outcome !== 'done' || body.step !== 'bus-stop' || body.state !== 'stopped') throw new Error(body.message || 'The stop was refused. Nothing was retried.');
|
||||
busReceipt = body.stopped ? `Bus stopped at ${clock()}. Restart bus starts it again.` : `The bus was already stopped at ${clock()}. Nothing changed.`;
|
||||
$('bus-restart-done')?.remove();
|
||||
await Promise.resolve(window.MosaicOrg.refreshList?.()).catch(() => {});
|
||||
const fresh = await window.fetch('/api/config', { cache: 'no-store', redirect: 'manual' }).then(r => (r.ok ? r.json() : null)).catch(() => null);
|
||||
if (fresh) { config = fresh; document.dispatchEvent(new CustomEvent('mosaic-config', { detail: fresh })); }
|
||||
} catch (error) {
|
||||
busReceipt = error instanceof SyntaxError || error?.name === 'TypeError' ? 'Outcome unknown. Check the bus with mosaic bus status before retrying.' : error.message;
|
||||
} finally { busBusy = false; paintBus(); }
|
||||
}
|
||||
|
||||
window.MosaicConsole = Object.freeze({ ready, settingsMarkup, busButton, attachBus, get busRestart() { return busAllowed(); }, get config() { return config; } });
|
||||
})();
|
||||
@@ -120,7 +120,31 @@ if (document.documentElement.hasAttribute('data-settings-proxy-document') && !lo
|
||||
// No browser storage, request/body logging, key reveal, or automatic write retry.
|
||||
(() => {
|
||||
'use strict';
|
||||
const markup = '<button id="proxy-refresh" type="button">Refresh</button><p id="proxy-message" role="status" aria-live="polite">Reading accounts…</p>\n<p id="proxy-readonly" class="muted" hidden>This Console is read-only. Sign-in and account changes are unavailable.</p>\n<section class="panel" aria-labelledby="proxy-accounts-title"><h2 id="proxy-accounts-title">Accounts</h2>\n<p>Higher priority is used first. Account names are stable identifiers; credential values are never displayed.</p>\n<div class="table-wrap" tabindex="0" role="region" aria-label="Accounts"><table><thead><tr><th>Account</th><th>Provider</th><th>Status</th><th>Priority</th><th>Models</th></tr></thead><tbody id="proxy-accounts"></tbody></table></div></section>\n<section class="panel" aria-labelledby="proxy-signin-title"><h2 id="proxy-signin-title">Sign in to an account</h2>\n<form id="proxy-login-start"><fieldset data-write disabled><legend>Subscription account</legend><label>Provider <select name="provider"><option value="claude">Claude</option><option value="codex">Codex</option></select></label><button type="submit">Start sign-in</button></fieldset></form>\n<p id="proxy-login-message" role="status" aria-live="polite"></p>\n<p><a id="proxy-authorize" target="_blank" rel="noopener noreferrer" hidden>Open account sign-in in a new tab</a></p>\n<form id="proxy-login-submit"><fieldset data-write disabled><legend>Finish sign-in</legend><p>If the sign-in tab does not return, copy its full address and paste it here.</p><label>Callback address <input name="redirect_url" type="url" autocomplete="off" spellcheck="false" maxlength="8192" required></label><button type="submit">Submit address</button><button id="proxy-login-cancel" type="button">Cancel sign-in</button></fieldset></form></section>\n<section class="panel" aria-labelledby="proxy-apikey-title"><h2 id="proxy-apikey-title">Add an API key</h2>\n<form id="proxy-apikey"><fieldset data-write disabled><legend>z.ai</legend><label>API key <input name="key" type="password" autocomplete="off" spellcheck="false" maxlength="4096" required></label><p>The key is sent once and cleared from this field. It will never be shown again.</p><button type="submit">Add key</button></fieldset><p id="proxy-apikey-message" role="status"></p></form></section>\n<section class="panel" aria-labelledby="proxy-keys-title"><h2 id="proxy-keys-title">Client keys</h2><p>Only key IDs are shown. Hire and fire manage agent keys.</p>\n<div class="table-wrap" tabindex="0" role="region" aria-label="Client keys"><table><thead><tr><th>Key ID</th><th>Managed by</th><th>Agent</th><th>Issued</th><th>State</th></tr></thead><tbody id="proxy-keys"></tbody></table></div></section>\n';
|
||||
// #1873: setup from nothing to a working proxy. Shown only to the instance owner (config.proxyAdmin).
|
||||
const setupMarkup = '<section id="proxy-setup" class="panel" aria-labelledby="proxy-setup-title" hidden><h2 id="proxy-setup-title">Proxy setup</h2>'
|
||||
+ '<p>Each step is checked on the host. Files are named by label; keys are never shown.</p>'
|
||||
+ '<dl id="proxy-setup-state" class="proxy-setup-state"></dl>'
|
||||
+ '<div id="proxy-setup-next" class="proxy-setup-next" role="status" aria-live="polite"></div>'
|
||||
+ '<p id="proxy-setup-message" role="status" aria-live="polite"></p>'
|
||||
+ '<p id="proxy-setup-bus"></p>'
|
||||
+ '<details id="proxy-setup-init-box"><summary>Initialize the proxy</summary><form id="proxy-setup-init"><fieldset data-setup-write disabled><legend>Management key</legend>'
|
||||
+ '<label><input type="radio" name="keySource" value="generate" checked> Generate a key on the host (recommended). It is stored where init stores keys and never shown.</label>'
|
||||
+ '<label id="proxy-setup-paste-choice" hidden><input type="radio" name="keySource" value="paste"> Use my own key</label>'
|
||||
+ '<p id="proxy-setup-paste-note" class="muted" hidden>Your own key can be pasted only over HTTPS or on this machine (loopback). This page uses the generated key.</p>'
|
||||
+ '<div id="proxy-setup-paste" hidden><label>Management key <input name="key" type="password" autocomplete="off" spellcheck="false" maxlength="4096"></label>'
|
||||
+ '<label>Confirm key <input name="confirm" type="password" autocomplete="off" spellcheck="false" maxlength="4096"></label></div></fieldset>'
|
||||
+ '<fieldset data-setup-write disabled><legend>Settings (leave empty for the defaults)</legend><div class="filters">'
|
||||
+ '<label>Port <input name="port" type="number" min="1024" max="65535" step="1" inputmode="numeric"></label>'
|
||||
+ '<label>Key rotation grace (minutes) <input name="rotateGraceMinutes" type="number" min="1" max="1440" step="1" inputmode="numeric"></label>'
|
||||
+ '<label>First output timeout (seconds) <input name="firstOutputSeconds" type="number" min="1" max="3600" step="1" inputmode="numeric"></label>'
|
||||
+ '</div><button type="submit">Initialize</button></fieldset></form></details>'
|
||||
+ '<div class="proxy-setup-unit"><h3>Proxy service</h3><p class="muted">Install renders the unit the same way as <code>mosaic proxy unit install</code>.</p>'
|
||||
+ '<div class="proxy-setup-buttons"><button type="button" data-unit="install" data-setup-write disabled>Install</button><button type="button" data-unit="start" data-setup-write disabled>Start</button><button type="button" data-unit="restart" data-setup-write disabled>Restart</button><button type="button" data-unit="stop" data-setup-write disabled>Stop</button><button type="button" id="proxy-setup-render">Show unit file</button></div>'
|
||||
+ '<pre id="proxy-setup-unit-file" hidden></pre></div>'
|
||||
+ '<div class="proxy-setup-orgs"><h3>Organizations</h3><div class="table-wrap" tabindex="0" role="region" aria-label="Organizations using the proxy"><table><thead><tr><th>Organization</th><th>Proxy</th><th>Catalogue</th><th>Models</th></tr></thead><tbody id="proxy-setup-orgs"></tbody></table></div>'
|
||||
+ '<p id="proxy-setup-own" class="muted"></p></div>'
|
||||
+ '<div class="proxy-setup-receipts"><h3>Receipts</h3><ol id="proxy-setup-receipts"><li class="muted">No setup actions on this page yet.</li></ol></div></section>\n';
|
||||
const markup = setupMarkup + '<button id="proxy-refresh" type="button">Refresh</button><p id="proxy-message" role="status" aria-live="polite">Reading accounts…</p>\n<p id="proxy-readonly" class="muted" hidden>This Console is read-only. Sign-in and account changes are unavailable.</p>\n<section class="panel" aria-labelledby="proxy-accounts-title"><h2 id="proxy-accounts-title">Accounts</h2>\n<p>Higher priority is used first. Account names are stable identifiers; credential values are never displayed.</p>\n<div class="table-wrap" tabindex="0" role="region" aria-label="Accounts"><table><thead><tr><th>Account</th><th>Provider</th><th>Status</th><th>Priority</th><th>Models</th></tr></thead><tbody id="proxy-accounts"></tbody></table></div></section>\n<section class="panel" aria-labelledby="proxy-signin-title"><h2 id="proxy-signin-title">Sign in to an account</h2>\n<form id="proxy-login-start"><fieldset data-write disabled><legend>Subscription account</legend><label>Provider <select name="provider"><option value="claude">Claude</option><option value="codex">Codex</option></select></label><button type="submit">Start sign-in</button></fieldset></form>\n<p id="proxy-login-message" role="status" aria-live="polite"></p>\n<p><a id="proxy-authorize" target="_blank" rel="noopener noreferrer" hidden>Open account sign-in in a new tab</a></p>\n<form id="proxy-login-submit"><fieldset data-write disabled><legend>Finish sign-in</legend><p>If the sign-in tab does not return, copy its full address and paste it here.</p><label>Callback address <input name="redirect_url" type="url" autocomplete="off" spellcheck="false" maxlength="8192" required></label><button type="submit">Submit address</button><button id="proxy-login-cancel" type="button">Cancel sign-in</button></fieldset></form></section>\n<section class="panel" aria-labelledby="proxy-apikey-title"><h2 id="proxy-apikey-title">Add an API key</h2>\n<form id="proxy-apikey"><fieldset data-write disabled><legend>z.ai</legend><label>API key <input name="key" type="password" autocomplete="off" spellcheck="false" maxlength="4096" required></label><p>The key is sent once and cleared from this field. It will never be shown again.</p><button type="submit">Add key</button></fieldset><p id="proxy-apikey-message" role="status"></p></form></section>\n<section class="panel" aria-labelledby="proxy-keys-title"><h2 id="proxy-keys-title">Client keys</h2><p>Only key IDs are shown. Hire and fire manage agent keys.</p>\n<div class="table-wrap" tabindex="0" role="region" aria-label="Client keys"><table><thead><tr><th>Key ID</th><th>Managed by</th><th>Agent</th><th>Issued</th><th>State</th></tr></thead><tbody id="proxy-keys"></tbody></table></div></section>\n';
|
||||
function mount() {
|
||||
const $ = id => document.getElementById(id);
|
||||
let canWrite = false, login = null, poll = null;
|
||||
@@ -138,6 +162,7 @@ if (document.documentElement.hasAttribute('data-settings-proxy-document') && !lo
|
||||
if (!response.ok) {
|
||||
if (response.status === 401) throw new Error('Sign in to the Console again.');
|
||||
if (value.error === 'not-configured') throw new Error('The model proxy is not configured for this instance.');
|
||||
if (value.error === 'proxy-down') throw Object.assign(new Error('The model proxy is not answering.'), { code: 'proxy-down' });
|
||||
if (value.error === 'login-state-unknown') throw new Error('This sign-in has ended. Start a new sign-in.');
|
||||
if (value.error === 'apikey-invalid') throw new Error('Enter a nonempty API key, with no spaces or line breaks.');
|
||||
throw new Error('The operation could not be confirmed. Nothing was retried. Refresh before trying again.');
|
||||
@@ -239,9 +264,187 @@ if (document.documentElement.hasAttribute('data-settings-proxy-document') && !lo
|
||||
catch (error) { message(error.message);receipt('proxy-apikey-message',error.message,'error'); }
|
||||
finally { busy.delete('apikey');formBusy(form,false); }
|
||||
});
|
||||
const cleanup = () => { clearTimeout(poll); login = null; const redirect=$('proxy-login-submit')?.elements.redirect_url, key=$('proxy-apikey')?.elements.key; if(redirect)redirect.value='';if(key)key.value=''; };
|
||||
// #1873 setup controller. Every action is a POST the server checks again (owner, Origin,
|
||||
// Fetch Metadata); this code only offers the next step. Pasted keys stay in their fields
|
||||
// until submit, which clears them before the request is answered.
|
||||
const setup = (() => {
|
||||
const box = $('proxy-setup'), form = $('proxy-setup-init');
|
||||
let state = null, timer = null, enabled = false, sendOk = false, seenFetch = null, loading = false;
|
||||
const pasteAllowed = location.protocol === 'https:' || ['localhost', '[::1]'].includes(location.hostname) || /^127\./.test(location.hostname);
|
||||
const STEPS = { start: 'Starting', 'download-archive': 'Downloading the release archive', 'verify-archive': 'Checking the archive hash', 'download-panel': 'Downloading the panel', 'verify-panel': 'Checking the panel hash', 'extract-binary': 'Extracting the executable', 'verify-binary': 'Checking the executable hash', install: 'Installing', done: 'Done' };
|
||||
const REASONS = {
|
||||
'human-required': 'Only the instance owner can set up the proxy.',
|
||||
'read-only': 'This Console is read-only.',
|
||||
'proxy-admin-busy': 'Another setup action is running. Wait for it to finish.',
|
||||
'proxy-key-transport': 'A pasted key travels only over HTTPS or loopback. Use the generated key.',
|
||||
'proxy-key-mismatch': 'The two key fields differ. Type the same key in both.',
|
||||
'proxy-key-invalid': 'The key must be 1 to 4096 printable characters with no spaces.',
|
||||
'proxy-config-invalid': 'The port must be 1024 to 65535 and not 8317.',
|
||||
'proxy-rotate-grace-invalid': 'Key rotation grace must be 1 to 1440 minutes.',
|
||||
'proxy-first-output-invalid': 'First output timeout must be 1 to 3600 seconds.',
|
||||
'proxy-already-initialized': 'The proxy is already initialized. Init never overwrites it.',
|
||||
'proxy-port-in-use': 'That port is in use. Choose another or leave it empty.',
|
||||
'proxy-init-busy': 'An init lock is held. See Init lock above.',
|
||||
'proxy-install-missing': 'The pinned release is not fetched yet.',
|
||||
'proxy-install-invalid': 'The fetched release failed its hash check. Fetch again.',
|
||||
'proxy-restart-required': 'Restart the proxy from this page first.',
|
||||
'proxy-restart-unconfirmed': 'The restart could not be confirmed on the host. Restart again.',
|
||||
'proxy-init-lock-held': 'The init lock cannot be cleared yet.',
|
||||
'proxy-init-lock-absent': 'There is no init lock.',
|
||||
'proxy-unit-exists': 'A different proxy unit file is installed. Check it with mosaic proxy unit render.',
|
||||
'proxy-unit-missing': 'The proxy service is not installed.',
|
||||
'proxy-unit-unhealthy': 'The proxy service started but does not answer on its port.',
|
||||
'proxy-unit-failed': 'systemd refused the action.',
|
||||
'not-configured': 'Proxy setup is not available on this Console.',
|
||||
'outcome-unknown': 'The outcome is unknown. Check the status before retrying.',
|
||||
};
|
||||
const explain = code => `${REASONS[code] ?? 'The action was refused.'} (${code})`;
|
||||
async function admin(path, args) {
|
||||
const response = await fetch('/api/proxy/admin/' + path, { cache: 'no-store', credentials: 'same-origin', redirect: 'manual',
|
||||
method: args === undefined ? 'GET' : 'POST',
|
||||
...(args === undefined ? {} : { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(args) }) });
|
||||
if (response.type === 'opaqueredirect' || response.status === 401) throw new Error('Sign in to the Console again.');
|
||||
let value = null; try { value = await response.json(); } catch { /* refused below */ }
|
||||
if (!response.ok) { const code = typeof value?.error === 'string' && /^[a-z -]+$/.test(value.error) ? value.error : 'outcome-unknown'; const error = new Error(explain(code)); error.code = code; throw error; }
|
||||
return value;
|
||||
}
|
||||
const say = (text, kind) => receipt('proxy-setup-message', text, kind);
|
||||
const human = value => value === null || value === undefined ? '—' : typeof value === 'object' ? JSON.stringify(value) : String(value);
|
||||
function addReceipt(record) {
|
||||
if (!record || typeof record !== 'object') return;
|
||||
const list = $('proxy-setup-receipts'); list.querySelector('li.muted')?.remove();
|
||||
const li = document.createElement('li'), op = document.createElement('strong'); op.textContent = record.op ?? 'action';
|
||||
const details = Object.entries(record).filter(([k]) => k !== 'op' && k !== 'time').map(([k, v]) => `${k}: ${human(v)}`).join(', ');
|
||||
li.append(op, ` ${window.MosaicLocalTime(record.time)}${details ? ' — ' + details : ''}`);
|
||||
list.prepend(li);
|
||||
}
|
||||
// #1873: receipts survive a reload: status carries the last few from proxy-admin.jsonl.
|
||||
function paintReceipts() {
|
||||
if (!Array.isArray(state.receipts) || !state.receipts.length) return;
|
||||
$('proxy-setup-receipts').replaceChildren(); state.receipts.forEach(addReceipt);
|
||||
}
|
||||
function row(dl, term, text) { const dt = document.createElement('dt'), dd = document.createElement('dd'); dt.textContent = term; dd.textContent = text; dl.append(dt, dd); }
|
||||
function paintState() {
|
||||
const { release, init, unit, fence, initLock } = state, dl = $('proxy-setup-state'); dl.replaceChildren();
|
||||
row(dl, 'Release', !release.fetched ? `Not fetched (pinned ${release.version}, panel ${release.panelVersion})` : release.verified ? `Fetched ${release.version}, panel ${release.panelVersion}, hashes verified` : `Fetched ${release.version}, hashes not verified (${release.problem})`);
|
||||
const files = init.files.filter(f => f.present).map(f => f.label).join(', ') || 'none';
|
||||
row(dl, 'Initialized', init.initialized ? `Yes, port ${init.settings?.port ?? '—'}` : init.partial ? `Partly (${init.problem ?? 'incomplete'}). Present: ${files}` : init.problem ? `No (${init.problem})` : 'No');
|
||||
row(dl, 'Service', `${unit.unit}: ${unit.installed ? 'installed' : 'not installed'}, ${unit.active}${unit.pid ? `, pid ${unit.pid}` : ''}${unit.port ? `, port ${unit.port} ${unit.listening ? 'answering' : 'not answering'}` : ''}${unit.error ? ` (${unit.error})` : ''}`);
|
||||
row(dl, 'Fence', !fence.set ? 'Clear' : fence.invalid ? 'Set (unreadable fence file)' : `Set after ${fence.op ?? 'a key write'}${fence.org ? ` for ${fence.org}` : ''}${fence.time ? ` at ${window.MosaicLocalTime(fence.time)}` : ''}`);
|
||||
row(dl, 'Init lock', !initLock.present ? 'None' : initLock.clearable ? 'Stale: the bus is stopped and no proxy files exist' : `Held: ${initLock.blockers.join('; ')}`);
|
||||
if (state.restart?.confirmed) row(dl, 'Last restart', `Confirmed at ${window.MosaicLocalTime(state.restart.observedAt)}, new pid ${state.restart.pid}`);
|
||||
}
|
||||
function paintNext() {
|
||||
const next = state.next?.[0], box = $('proxy-setup-next');
|
||||
const steps = {
|
||||
fetch: ['Fetch the pinned release', `Next: fetch CLIProxyAPI ${state.release.version}. Every file is checked against its pinned SHA-256 before it is installed.`, fetchRelease],
|
||||
'init-lock-clear': ['Clear the stale init lock', 'Next: an init lock is left from an init that did not finish. The bus is stopped and no proxy files exist, so it can be cleared.', clearLock],
|
||||
'init-lock-wait': ['Check again', `An init lock is held and cannot be cleared: ${state.initLock.blockers.join('; ')}. Stop the bus and remove partial state as the model-proxy README says, then check again.`, load],
|
||||
'init-repair': ['Check again', 'Setup is partly done. Init never overwrites existing proxy files; resolve them as the model-proxy README says, then check again.', load],
|
||||
init: ['Initialize the proxy', 'Next: initialize. The bus generates the management key unless you paste your own.', () => { $('proxy-setup-init-box').open = true; form.elements.port.focus(); }],
|
||||
'unit-install': ['Install the proxy service', `Next: install ${state.unit.unit}.`, () => unitAction('install')],
|
||||
'unit-start': ['Start the proxy service', `Next: start ${state.unit.unit}.`, () => unitAction('start')],
|
||||
'unit-restart': ['Restart the proxy service', state.fence.set ? (fence => fence.invalid || !fence.time ? 'A key write had an uncertain outcome, so key writes are fenced. The fence file has no readable time, so a restart from this page cannot be proven against it: restart the proxy service, confirm it answers, then run mosaic proxy fence clear --proxy-restarted.' : 'A key write had an uncertain outcome, so key writes are fenced. Restart the proxy; after this page confirms the new process, clear the fence.')(state.fence) : 'The service is running but does not answer on its port. Restart it.', () => unitAction('restart')],
|
||||
'fence-clear': ['Clear the fence', 'The proxy was restarted from this page and the new process answers on its port. Clear the fence with that evidence.', clearFence],
|
||||
accounts: ['Add an account', 'The proxy is running. Sign in to an account or add an API key below.', () => $('proxy-signin-title')?.scrollIntoView({ block: 'start' })],
|
||||
};
|
||||
const [label, text, action] = steps[next] ?? ['Check again', 'Status read.', load];
|
||||
const p = document.createElement('p'); p.textContent = text;
|
||||
const button = document.createElement('button'); button.type = 'button'; button.className = 'btn primary'; button.id = 'proxy-setup-go'; button.dataset.next = next ?? '';
|
||||
button.textContent = state.busy ? 'Working…' : label;
|
||||
const write = !['init-lock-wait', 'init-repair', 'accounts', 'init'].includes(next);
|
||||
button.disabled = Boolean(state.busy) || (write && !sendOk);
|
||||
button.addEventListener('click', () => { if (!button.disabled) action(); });
|
||||
box.replaceChildren(p, button);
|
||||
}
|
||||
function paintControls() {
|
||||
const { release, init, unit, initLock, busy } = state;
|
||||
const canInit = sendOk && !busy && release.fetched && release.verified && !init.initialized && !init.partial && !initLock.present;
|
||||
form.querySelectorAll('[data-setup-write]').forEach(el => { el.disabled = !canInit; });
|
||||
$('proxy-setup-paste-choice').hidden = !pasteAllowed; $('proxy-setup-paste-note').hidden = pasteAllowed;
|
||||
const allow = { install: !unit.installed, start: unit.installed && unit.active !== 'active', restart: unit.installed && unit.active === 'active', stop: unit.installed && unit.active === 'active' };
|
||||
box.querySelectorAll('[data-unit]').forEach(button => { button.disabled = !sendOk || Boolean(busy) || !init.initialized || !allow[button.dataset.unit]; });
|
||||
const orgs = $('proxy-setup-orgs');
|
||||
if (!Array.isArray(state.organizations)) orgs.replaceChildren(Object.assign(document.createElement('tr'), { innerHTML: '<td colspan="4">Organizations could not be read.</td>' }));
|
||||
else if (!state.organizations.length) orgs.replaceChildren(Object.assign(document.createElement('tr'), { innerHTML: '<td colspan="4">No organizations.</td>' }));
|
||||
else orgs.replaceChildren(...state.organizations.map(org => { const tr = document.createElement('tr'); tr.append(cell(org.id), cell(org.proxy === 'shared' ? 'Shared (this instance)' : org.proxy), cell(org.catalogue?.status ?? org.error ?? 'unknown'), cell(Number.isInteger(org.catalogue?.models) ? String(org.catalogue.models) : '—')); return tr; }));
|
||||
$('proxy-setup-own').textContent = state.ownProxy?.available ? 'An organization can run its own proxy.' : `Own proxy per organization: ${state.ownProxy?.state ?? 'not built'}. ${state.ownProxy?.reason ?? ''}`;
|
||||
}
|
||||
function paint() {
|
||||
paintState(); paintNext(); paintControls(); paintReceipts();
|
||||
// An initialized proxy keeps the init form folded after a reload too.
|
||||
if (state.init.initialized) $('proxy-setup-init-box').open = false;
|
||||
const job = state.fetch;
|
||||
if (job.state === 'running') say(`Fetching: ${STEPS[job.step] ?? job.step ?? 'starting'}…`);
|
||||
else if (job.state === 'failed' && seenFetch !== 'failed') { say(`Fetch failed. ${explain(job.error)}`, 'error'); seenFetch = 'failed'; }
|
||||
else if (job.state === 'done' && job.receipt && seenFetch !== job.receipt.time) { seenFetch = job.receipt.time; if (!state.receipts?.some(r => r.op === 'fetch' && r.time === job.receipt.time)) addReceipt(job.receipt); say(`Fetched ${job.receipt.version}; archive, panel and executable hashes verified.`, 'success'); }
|
||||
clearTimeout(timer); timer = null;
|
||||
if (enabled && (job.state === 'running' || state.busy)) timer = setTimeout(load, 1000);
|
||||
}
|
||||
async function load() {
|
||||
if (!enabled || loading) return; loading = true;
|
||||
try { state = await admin('status'); paint(); if (state.init.initialized) await accountsReady(); }
|
||||
catch (error) { say(error.message, 'error'); }
|
||||
finally { loading = false; }
|
||||
}
|
||||
async function act(label, action) {
|
||||
if (!sendOk || state?.busy) return;
|
||||
state = { ...state, busy: label }; paintNext(); paintControls(); say('Working…');
|
||||
try { await action(); } catch (error) { say(error.message, 'error'); } finally { if (state) state = { ...state, busy: null }; await load(); }
|
||||
}
|
||||
const fetchRelease = () => act('fetch', async () => { await admin('fetch', {}); seenFetch = null; say('Fetch started.'); });
|
||||
const clearLock = () => act('init-lock-clear', async () => { const result = await admin('init-lock/clear', {}); addReceipt(result.receipt); say('Stale init lock cleared.', 'success'); });
|
||||
const unitAction = op => act(`unit-${op}`, async () => {
|
||||
const result = await admin(`unit/${op}`, {}); addReceipt(result.receipt);
|
||||
if (op === 'restart' && result.evidence?.confirmed !== true) say('The service restarted but the new process could not be confirmed. The fence stays set.', 'error');
|
||||
else say({ install: 'Proxy service installed.', start: 'Proxy service started and answering.', stop: 'Proxy service stopped.', restart: 'Proxy service restarted; the new process answers.' }[op], 'success');
|
||||
});
|
||||
const clearFence = () => act('fence-clear', async () => { const result = await admin('fence/clear', {}); addReceipt(result.receipt); say(result.cleared ? 'Fence cleared.' : 'The fence was not set.', 'success'); });
|
||||
form.elements.keySource.forEach?.(radio => radio.addEventListener('change', () => { $('proxy-setup-paste').hidden = form.elements.keySource.value !== 'paste'; }));
|
||||
form.addEventListener('submit', event => {
|
||||
event.preventDefault();
|
||||
const f = form.elements, args = {};
|
||||
const pasted = pasteAllowed && f.keySource.value === 'paste', key = f.key.value, confirm = f.confirm.value;
|
||||
f.key.value = ''; f.confirm.value = '';
|
||||
if (!sendOk || state?.busy) return;
|
||||
for (const name of ['port', 'rotateGraceMinutes', 'firstOutputSeconds']) if (f[name].value !== '') args[name] = Number(f[name].value);
|
||||
if (pasted) {
|
||||
if (!key) return say('Type the key in both fields, or choose the generated key.', 'error');
|
||||
if (key !== confirm) return say(explain('proxy-key-mismatch'), 'error');
|
||||
args.key = key; args.confirm = confirm;
|
||||
} else args.generate = true;
|
||||
act('init', async () => {
|
||||
const result = await admin('init', args); addReceipt(result.receipt);
|
||||
$('proxy-setup-init-box').open = false;
|
||||
say(`Initialized on port ${result.settings.port}${args.generate ? ' with a generated key' : ''}. Install and start the service next. Other organizations' buses read the proxy settings when they start: restart a bus before its agents use the proxy.`, 'success');
|
||||
window.MosaicConsole?.attachBus?.($('proxy-setup-bus'), 'Restart this organization\'s bus');
|
||||
});
|
||||
});
|
||||
$('proxy-setup-render').addEventListener('click', async () => {
|
||||
const pre = $('proxy-setup-unit-file');
|
||||
if (!pre.hidden) { pre.hidden = true; return; }
|
||||
try { const result = await admin('unit/render'); pre.textContent = result.text; pre.hidden = false; } catch (error) { say(error.message, 'error'); }
|
||||
});
|
||||
box.querySelectorAll('[data-unit]').forEach(button => button.addEventListener('click', () => { if (!button.disabled) unitAction(button.dataset.unit); }));
|
||||
return {
|
||||
start(config) { enabled = true; sendOk = config.proxyAdminSend === true; box.hidden = false; return load(); },
|
||||
stop() { enabled = false; clearTimeout(timer); timer = null; const f = form.elements; f.key.value = ''; f.confirm.value = ''; },
|
||||
};
|
||||
})();
|
||||
// Panels the not-configured state hides come back once the proxy answers.
|
||||
let accountsShown = true;
|
||||
async function accountsReady() {
|
||||
if (accountsShown) return;
|
||||
try { await refresh(); } catch { return; }
|
||||
accountsShown = true; $('proxy-message').classList.remove('empty');
|
||||
document.querySelectorAll('.settings-model-proxy .panel:not(#proxy-setup), #proxy-refresh').forEach(el => { el.hidden = false; });
|
||||
if (!canWrite) document.querySelectorAll('[aria-labelledby=proxy-signin-title],[aria-labelledby=proxy-apikey-title]').forEach(el => { el.hidden = true; });
|
||||
$('proxy-readonly').hidden = canWrite;
|
||||
message('Accounts and client keys loaded.');
|
||||
}
|
||||
const cleanup = () => { setup.stop(); clearTimeout(poll); login = null; const redirect=$('proxy-login-submit')?.elements.redirect_url, key=$('proxy-apikey')?.elements.key; if(redirect)redirect.value='';if(key)key.value=''; };
|
||||
window.addEventListener('pagehide', cleanup, {once:true});
|
||||
let devOwner = false;
|
||||
let devOwner = false, proxyAdmin = false;
|
||||
fetch('/api/config', { cache: 'no-store' }).then(r => r.json()).then(config => {
|
||||
// F3: an organization's settings writes go with settingsSend (the org Console has no global send).
|
||||
canWrite = (config.send === true || config.settingsSend === true) && config.tester !== true && config.user !== true;
|
||||
@@ -249,8 +452,18 @@ if (document.documentElement.hasAttribute('data-settings-proxy-document') && !lo
|
||||
document.querySelectorAll('[data-write]').forEach(fieldset => { fieldset.disabled = !canWrite; });
|
||||
$('proxy-readonly').hidden = canWrite;
|
||||
if (!canWrite) document.querySelectorAll('[aria-labelledby=proxy-signin-title],[aria-labelledby=proxy-apikey-title]').forEach(el=>el.hidden=true);
|
||||
proxyAdmin = config.proxyAdmin === true;
|
||||
if (proxyAdmin) setup.start(config);
|
||||
return refresh();
|
||||
}).then(() => message('Accounts and client keys loaded.')).catch(error => { message(error.message); if(error.message==='The model proxy is not configured for this instance.') { $('proxy-message').classList.add('empty'); $('proxy-readonly').hidden=true; document.querySelectorAll('.settings-model-proxy .panel, #proxy-refresh').forEach(el=>el.hidden=true);
|
||||
}).then(() => message('Accounts and client keys loaded.')).catch(error => { message(error.message);
|
||||
// #1873: on the setup page a stopped proxy hides the account panels like an unconfigured one,
|
||||
// and the setup load brings them back once the service answers.
|
||||
// Any failed read hides them, not only proxy-down: a proxy that is initialized but not yet answering
|
||||
// can fail with another code, and a stale error over an empty table would stay after Start.
|
||||
if (proxyAdmin && error.message !== 'The model proxy is not configured for this instance.') { accountsShown = false; $('proxy-message').classList.add('empty'); $('proxy-readonly').hidden=true; document.querySelectorAll('.settings-model-proxy .panel:not(#proxy-setup), #proxy-refresh').forEach(el=>el.hidden=true); message(error.code === 'proxy-down' ? 'The model proxy is not running yet. Follow the next step under Proxy setup.' : `Accounts could not be read${error.code ? ` (${error.code})` : ''}. Follow the next step under Proxy setup; the account panels return once the proxy answers.`); return; }
|
||||
if(error.message==='The model proxy is not configured for this instance.') { accountsShown = false; $('proxy-message').classList.add('empty'); $('proxy-readonly').hidden=true; document.querySelectorAll('.settings-model-proxy .panel:not(#proxy-setup), #proxy-refresh').forEach(el=>el.hidden=true);
|
||||
// #1873: the owner sets the proxy up on this page (Proxy setup above); no terminal note.
|
||||
if (proxyAdmin) { message('The model proxy is not set up yet. Follow the next step under Proxy setup.'); return; }
|
||||
// F3: on a dev instance, say plainly why this page cannot set the proxy up and what does.
|
||||
// Setup takes a management key typed twice at a terminal and writes the proxy's credentials;
|
||||
// this page never handles that key. Account sign-in (a provider's own OAuth) then runs here.
|
||||
|
||||
@@ -103,6 +103,13 @@ table.s1-table td code{font-size:12.5px;white-space:nowrap}
|
||||
.agent-restart{display:inline-flex;flex-wrap:wrap;gap:6px 10px;align-items:center;min-width:0}
|
||||
.agent-restart [role=status]:empty{display:none}
|
||||
#proxy-dev-setup pre{overflow-x:auto;max-width:100%}
|
||||
/* #1873 proxy setup */
|
||||
.proxy-setup-state{display:grid;grid-template-columns:max-content minmax(0,1fr);gap:6px 16px;margin:0 0 12px}.proxy-setup-state dt{color:var(--muted);font-weight:600}.proxy-setup-state dd{margin:0;overflow-wrap:anywhere}
|
||||
.proxy-setup-next{display:flex;flex-wrap:wrap;align-items:center;gap:8px 16px;margin:0 0 12px}.proxy-setup-next p{margin:0;flex:1 1 280px}
|
||||
.proxy-setup-buttons{display:flex;flex-wrap:wrap;gap:8px}.proxy-setup-buttons button{min-height:44px}
|
||||
#proxy-setup pre{overflow-x:auto;max-width:100%;font-size:12px}#proxy-setup h3{margin:16px 0 4px}#proxy-setup details{margin:0 0 12px}#proxy-setup summary{min-height:44px;display:flex;align-items:center;cursor:pointer}
|
||||
#proxy-setup-receipts{padding-left:20px;overflow-wrap:anywhere;font-size:13px}
|
||||
@media(max-width:479px){.proxy-setup-state{grid-template-columns:1fr}.proxy-setup-state dd{margin-bottom:6px}}
|
||||
/* Stream S4: which organization this is, in words. Colour (the theme) is never the only cue; the strip appears whenever the theme is not the built-in one. */
|
||||
.org-cue{margin:0 0 12px;padding:6px 10px;border:1px solid var(--line);border-left:4px solid var(--action);border-radius:var(--r-table);background:var(--raised);color:var(--text);font-size:13px;overflow-wrap:anywhere}.skel-rows{display:grid;gap:10px;padding:14px 12px;border:1px solid var(--line);border-radius:var(--r-table);background:var(--surface)}
|
||||
.skel{display:block;height:12px;border-radius:4px;background:var(--raised);animation:skel 1.4s ease-in-out infinite}
|
||||
@@ -431,16 +438,16 @@ html[data-organizations] .ws-org-row .ws-dot{font-size:12.5px;font-weight:500}
|
||||
html[data-organizations] .ws-org-row .ws-dot.s-offline,html[data-organizations] .ws-org-row .ws-dot.s-error{color:var(--text)}
|
||||
html[data-organizations] .ws-org-row .ws-dot.s-offline b{color:var(--muted)}
|
||||
html[data-organizations] .ws-org-row .ws-dot.s-error b{color:var(--danger)}
|
||||
html[data-organizations] .ws-btn,html[data-organizations] .ws-busctl [data-bus-restart]{display:inline-flex;align-items:center;gap:6px;min-height:0;border:1px solid var(--line);background:var(--surface);border-radius:var(--r);padding:4px 10px;font:600 13px/1.4 var(--font);color:var(--strong);text-decoration:none;white-space:nowrap;cursor:pointer;box-shadow:none}
|
||||
html[data-organizations] .ws-btn:hover:not(:disabled),html[data-organizations] .ws-busctl [data-bus-restart]:hover:not(:disabled){background:var(--raised)}
|
||||
html[data-organizations] .ws-btn:disabled,html[data-organizations] .ws-busctl [data-bus-restart]:disabled{color:var(--muted);cursor:not-allowed;opacity:1}
|
||||
html[data-organizations] .ws-btn,html[data-organizations] .ws-busctl [data-bus-restart],html[data-organizations] .ws-busctl [data-bus-stop]{display:inline-flex;align-items:center;gap:6px;min-height:0;border:1px solid var(--line);background:var(--surface);border-radius:var(--r);padding:4px 10px;font:600 13px/1.4 var(--font);color:var(--strong);text-decoration:none;white-space:nowrap;cursor:pointer;box-shadow:none}
|
||||
html[data-organizations] .ws-btn:hover:not(:disabled),html[data-organizations] .ws-busctl [data-bus-restart]:hover:not(:disabled),html[data-organizations] .ws-busctl [data-bus-stop]:hover:not(:disabled){background:var(--raised)}
|
||||
html[data-organizations] .ws-btn:disabled,html[data-organizations] .ws-busctl [data-bus-restart]:disabled,html[data-organizations] .ws-busctl [data-bus-stop]:disabled{color:var(--muted);cursor:not-allowed;opacity:1}
|
||||
html[data-organizations] .ws-btn svg.i{width:15px;height:15px}
|
||||
/* The live bus restart is console-restart.js's own button; its icon sits beside it, outside the
|
||||
part that file repaints. */
|
||||
html[data-organizations] .ws-busctl{position:relative;display:inline-flex;flex-direction:column;align-items:flex-end}
|
||||
/* The live bus restart is console-restart.js's own button; it carries its icon through repaints,
|
||||
so the icon stays on the button however wide the receipt below it is. */
|
||||
html[data-organizations] .ws-busctl{display:inline-flex;flex-direction:column;align-items:flex-end}
|
||||
html[data-organizations] .ws-busctl .bus-restart{display:inline-flex;flex-direction:column;align-items:flex-end;gap:4px}
|
||||
html[data-organizations] .ws-busctl [data-bus-restart]{padding-left:31px}
|
||||
html[data-organizations] .ws-busctl .ws-busicon{position:absolute;left:11px;top:7px;width:15px;height:15px;pointer-events:none;color:var(--strong);z-index:1}
|
||||
html[data-organizations] .ws-busctl .bus-buttons{display:inline-flex;flex-wrap:wrap;gap:6px;justify-content:flex-end}
|
||||
html[data-organizations] .ws-busctl [data-bus-restart] svg.i{width:15px;height:15px}
|
||||
html[data-organizations] .ws-busctl [data-bus-restart-receipt]{font-size:12px;color:var(--muted);max-width:28em;text-align:right}
|
||||
html[data-organizations] .ws-busctl [data-bus-restart-receipt]:empty{display:none}
|
||||
html[data-organizations] .ws-chips{display:flex;flex-wrap:wrap;align-items:center;gap:6px}
|
||||
@@ -689,6 +696,7 @@ html[data-organizations] #copy-status:empty{display:none}
|
||||
html[data-organizations] .ws-orghead .ws-right{margin-left:0}
|
||||
html[data-organizations] .ws-busctl,html[data-organizations] .ws-busctl .bus-restart{align-items:flex-start}
|
||||
html[data-organizations] .ws-busctl [data-bus-restart-receipt]{text-align:left}
|
||||
html[data-organizations] .ws-busctl .bus-buttons{justify-content:flex-start}
|
||||
html[data-organizations] .ws-typechips .ws-label,html[data-organizations] .ws-docrow .ws-label{min-width:0}
|
||||
html[data-organizations] .ws-levels{grid-template-columns:minmax(0,1fr)!important}
|
||||
html[data-organizations] .ws-levels>div{border-right:0}
|
||||
|
||||
@@ -219,9 +219,10 @@
|
||||
|
||||
// Header: the bus restart is console-restart.js's button where the Console allows it, otherwise
|
||||
// the same control, present and disabled, with the reason.
|
||||
const restart = pane ? '' : window.MosaicConsole?.busButton?.('Restart bus') || '';
|
||||
const restart = pane ? '' : window.MosaicConsole?.busButton?.('Restart bus', { stop: true, glyph: icon('restart') }) || '';
|
||||
const offTitle = pane ? "Restart the bus from this organization's full view" : "Restarting the bus is the owner's trailed action, on a Console started with send and the action trail";
|
||||
const bus = restart ? `<span class="ws-busctl" data-dashboard-bus>${icon('restart', 'ws-busicon')}${restart}</span>` : `<span data-dashboard-bus><button type="button" class="ws-btn" data-bus-restart-off data-focus-key="bus-restart-off" disabled title="${esc(offTitle)}">${icon('restart')}Restart bus</button></span>`;
|
||||
const stopTitle = pane ? "Stop the bus from this organization's full view" : "Stopping the bus is the owner's trailed action, on a Console started with send and the action trail";
|
||||
const bus = restart ? `<span class="ws-busctl" data-dashboard-bus>${restart}</span>` : `<span data-dashboard-bus><button type="button" class="ws-btn" data-bus-restart-off data-focus-key="bus-restart-off" disabled title="${esc(offTitle)}">${icon('restart')}Restart bus</button> <button type="button" class="ws-btn" data-bus-stop-off data-focus-key="bus-stop-off" disabled title="${esc(stopTitle)}">Stop bus</button></span>`;
|
||||
const reload = `<button type="button" class="ws-btn" data-org-reload data-focus-key="org-reload" disabled aria-describedby="${esc(why)}" title="Reload arrives with the cross-org update">${icon('doc')}Reload organization file</button>`;
|
||||
const [bcls, bglyph] = state(org);
|
||||
const meta = `<p class="ws-meta"><span>Kind not recorded</span><span>id <code>${esc(id)}</code></span><span>Tracker: ${tracker ? esc(titleCase(tracker)) : 'not set'}</span><span>${dot(bcls, bglyph, org.busHealthy ? `bus running, read ${clock()}` : `bus unavailable, read ${clock()}`, true)}</span><span id="${esc(why)}">Restart is a trailed human action; reload arrives with the cross-org update.</span></p>`;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { AUTONOMY_ROUTE, autonomyHTTP } from './autonomy-http.mjs';
|
||||
import { proxyHTTP } from './proxy-http.mjs';
|
||||
import { proxyAdminHTTP } from './proxy-admin.mjs';
|
||||
import { createServer } from 'node:http';
|
||||
import { bindTesterSocket, TESTER_ORIGIN } from './tester-socket.mjs';
|
||||
import { readFileSync, openSync, fstatSync, closeSync, constants } from 'node:fs';
|
||||
@@ -795,7 +796,7 @@ async function libraryStatusRead(res, read, search) {
|
||||
// `log` takes one line per send attempt that got past sign-in; never the body. `fieldsFile` is for
|
||||
// tests; the field table is otherwise the committed packages/tasks/fields.json.
|
||||
// `agentWrite` (row 194, D7) is `(verb, args) => Promise` for the Agents section's verbs and instance.request.
|
||||
export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null, sso = null, tester = false, testerSocket = null, testerAuthHeader = null, proof = null, testerProofHeader = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, libraryStatus = null, libraryRevert = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, proxyCall = null, processing = null, decisionWrite = null, requestRead = null, requestWrite = null, systemTypesWrite = null, userOptions = null, federation = null, organizations = null, preselect = null, routes = [], orgAdmin = null, devAllowHosts = [], log = line => console.log(line), fieldsFile = undefined, restart = null, trail = null, boot = randomUUID(), workspaceTimeoutMs = undefined, agentLevels = null } = {}) {
|
||||
export async function startServer({ host = '127.0.0.1', port = 7330, board = DEFAULT_BOARD, timeout = 20000, bus = null, reads = null, preview = null, sso = null, tester = false, testerSocket = null, testerAuthHeader = null, proof = null, testerProofHeader = null, send = null, settingsWrite = null, projectFiles = null, projectFile = null, projectSave = null, libraryStatus = null, libraryRevert = null, chat = null, nameWrite = null, assistantStop = null, instancesWrite = null, routesWrite = null, agentWrite = null, proxyCall = null, proxyAdmin = null, processing = null, decisionWrite = null, requestRead = null, requestWrite = null, systemTypesWrite = null, userOptions = null, federation = null, organizations = null, preselect = null, routes = [], orgAdmin = null, devAllowHosts = [], log = line => console.log(line), fieldsFile = undefined, restart = null, trail = null, boot = randomUUID(), workspaceTimeoutMs = undefined, agentLevels = null } = {}) {
|
||||
// org-lan-bind: the dev owner on the LAN is an explicit opt-in. `devAllowHosts` names the Host
|
||||
// names (without a port) a remote browser may use; only the dev owner takes them, and only with
|
||||
// them may it bind a non-loopback IPv4 address. SSO, preview and tester listeners stay loopback.
|
||||
@@ -828,7 +829,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
for (const registerRoutes of routes) registerRoutes(registered);
|
||||
registered.add('GET', '/api/themes', (req, res) => themesHTTP(req, res, { log }), { publicRead: true });
|
||||
const adminRoute = (req, res, context) => orgAdminHTTP(req, res, { ...context, admin: orgAdmin ??= defaultOrgAdmin(), log, trail });
|
||||
for (const path of ['/api/org-template', '/api/org-presets', '/api/org-validate', '/api/org-admin', /^\/api\/orgs\/[^/]+\/(?:admin|setup|delete|archive|restore|restart)(?:\/(?:queue|bus|project\/[^/]+|records\/[^/]+))?$/]) {
|
||||
for (const path of ['/api/org-template', '/api/org-presets', '/api/org-validate', '/api/org-admin', /^\/api\/orgs\/[^/]+\/(?:admin|setup|delete|archive|restore|restart|stop)(?:\/(?:queue|bus|project\/[^/]+|records\/[^/]+))?$/]) {
|
||||
for (const method of ['GET', 'POST']) registered.add(method, path, adminRoute);
|
||||
}
|
||||
registered.add('POST', '/api/orgs', adminRoute);
|
||||
@@ -957,6 +958,8 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
bus = reads = send = settingsWrite = projectFiles = projectFile = projectSave = libraryStatus = libraryRevert = chat = nameWrite = assistantStop = instancesWrite = routesWrite = agentWrite = proxyCall = processing = decisionWrite = requestRead = requestWrite = systemTypesWrite = autonomyWrite = upstream = null;
|
||||
}
|
||||
if (session?.user !== undefined && !session.rootOwner && !instanceAction) {
|
||||
// #1873: proxy setup answers a grant holder the way the bus answers an agent.
|
||||
if (path.startsWith('/api/proxy/admin/')) return json(res, 403, { error: 'human-required' });
|
||||
if (!organizations && !userRoute(path, req.method)) return json(res, 403, { error: 'owner-only', message: 'this part of the Console is the business owner\'s' });
|
||||
if (!organizations && userOptions === null) return json(res, 503, { error: 'not-configured', message: 'the Console has no user transport' });
|
||||
if (!organizations) ({ bus, send, settingsWrite, projectFiles, projectFile, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, processing } = userOptions(session));
|
||||
@@ -1022,6 +1025,11 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
res.writeHead(303, { location: '/#/settings/model-proxy', 'cache-control': 'no-store' });
|
||||
return res.end();
|
||||
}
|
||||
// #1873: proxy setup. Instance-wide host actions (fetch, unit) and the init and fence verbs on
|
||||
// the selected bus; only the instance owner's own session reaches them, never an org owner.
|
||||
if (path.startsWith('/api/proxy/admin/')) return proxyAdminHTTP(req, res, { path, admin: proxyAdmin, call: proxyCall, named, tester, granted,
|
||||
owner: !tester && !granted && named && sso !== null && currentSession?.rootOwner === true, send: owner.send !== null,
|
||||
origin: sso ? (localOrigin ?? sso.publicOrigin) : `https://${preview.host}`, user: signer?.user, log });
|
||||
if (path.startsWith('/api/proxy/')) return proxyHTTP(req, res, { path, call: proxyCall, named, tester, granted,
|
||||
send, origin: sso ? (localOrigin ?? sso.publicOrigin) : `https://${preview.host}`, user: signer?.user, log });
|
||||
}
|
||||
@@ -1211,7 +1219,7 @@ export async function startServer({ host = '127.0.0.1', port = 7330, board = DEF
|
||||
// `send`: the page shows the composer only where a send can pass (the public name, --send on).
|
||||
// `edit`: the editor shows Save only where a save can pass, the same test as the route's.
|
||||
// `user` and `federation` (FED R6) only when there is one, so the owner's config is unchanged.
|
||||
if (path === '/api/config') return json(res, 200, { board: upstream, ...(organizations && preselect ? { preselect } : {}), ...(tester ? { tester: true } : {}), ...(sso ? { sso: true } : {}), ...(sso?.devOwner ? { devOwner: true } : {}), ...(granted ? { user: true, chatGrants } : {}), ...(!tester && !granted && named && sso !== null && requestRead ? { requests: true, requestWrites: Boolean(send && requestWrite), workspaceFiles: true, projectRemotes: true, projectRemoteWrites: Boolean(send && requestWrite) } : {}), ...(hub ? { federation: hub } : {}), ...(organizations ? { orgSetup: !tester && !granted && named && sso !== null && owner.send !== null } : {}), send: !organizations && !tester && Boolean(send) && named, ...(organizations && orgId ? { chatSend: !tester && Boolean(send) && named && orgBusHealthy, settingsSend: !tester && !granted && Boolean(send) && named && orgBusHealthy } : {}), boot, consoleRestart: !tester && named && currentSession?.instanceRole === 'operator' && owner.send !== null && restart !== null && trail !== null, decide: (!organizations || (orgId !== null && orgBusHealthy)) && !tester && !granted && Boolean(send) && Boolean(decisionWrite) && named && sso !== null, edit: !organizations && !tester && !granted && Boolean(send) && Boolean(projectSave) && named, northStarApprove: (!organizations || (orgId !== null && orgBusHealthy)) && !tester && !granted && Boolean(send) && Boolean(projectSave) && Boolean(projectFile) && named });
|
||||
if (path === '/api/config') return json(res, 200, { board: upstream, ...(organizations && preselect ? { preselect } : {}), ...(tester ? { tester: true } : {}), ...(sso ? { sso: true } : {}), ...(sso?.devOwner ? { devOwner: true } : {}), ...(granted ? { user: true, chatGrants } : {}), ...(!tester && !granted && named && sso !== null && requestRead ? { requests: true, requestWrites: Boolean(send && requestWrite), workspaceFiles: true, projectRemotes: true, projectRemoteWrites: Boolean(send && requestWrite) } : {}), ...(hub ? { federation: hub } : {}), ...(organizations ? { orgSetup: !tester && !granted && named && sso !== null && owner.send !== null } : {}), ...(proxyAdmin && !tester && !granted && named && sso !== null && currentSession?.rootOwner === true ? { proxyAdmin: true, proxyAdminSend: owner.send !== null } : {}), send: !organizations && !tester && Boolean(send) && named, ...(organizations && orgId ? { chatSend: !tester && Boolean(send) && named && orgBusHealthy, settingsSend: !tester && !granted && Boolean(send) && named && orgBusHealthy } : {}), boot, consoleRestart: !tester && named && currentSession?.instanceRole === 'operator' && owner.send !== null && restart !== null && trail !== null, decide: (!organizations || (orgId !== null && orgBusHealthy)) && !tester && !granted && Boolean(send) && Boolean(decisionWrite) && named && sso !== null, edit: !organizations && !tester && !granted && Boolean(send) && Boolean(projectSave) && named, northStarApprove: (!organizations || (orgId !== null && orgBusHealthy)) && !tester && !granted && Boolean(send) && Boolean(projectSave) && Boolean(projectFile) && named });
|
||||
if (path === '/healthz') return json(res, 200, { ok: true, ...(sso ? { sso: sso.health() } : {}) });
|
||||
if (path === '/favicon.ico') { res.writeHead(204); return res.end(); }
|
||||
const file = files.get(path);
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
// Stop bus (#1873, bus-stop): the Console's POST /api/orgs/ID/stop/bus stops one organization's bus
|
||||
// host the way `mosaic bus stop` does, with SIGTERM and a clean close, so no host.lock or writer.lock
|
||||
// is left and an offline schema migration can open the store straight after. A real scratch instance
|
||||
// (bootstrap.sh), a real bus host and a real dev-owner Console on port 0.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { request } from 'node:http';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { humanEnv } from '../../cli/tests/helpers.mjs';
|
||||
import { busStatus, busStop } from '../../cli/src/org-bus.mjs';
|
||||
import { consoleStart, consoleStop } from '../../cli/src/org-console.mjs';
|
||||
import { createOrg, templateRender } from '../../organization/src/org-service.mjs';
|
||||
|
||||
const REPO = new URL('../../../', import.meta.url).pathname;
|
||||
const MOSAIC = join(REPO, 'scripts/mosaic');
|
||||
const MIGRATE = join(REPO, 'packages/bus/src/migrate-schema.mjs');
|
||||
const DEV = { provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner' };
|
||||
const FORBIDDEN = [7332, 7340, 7341, 33223, 17450, 17443, 44307, 7350];
|
||||
|
||||
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, text }); });
|
||||
});
|
||||
req.on('error', reject); req.end('{}');
|
||||
});
|
||||
}
|
||||
// Every file under the bus state directories, with its content hash.
|
||||
function snapshot(root) {
|
||||
const out = {};
|
||||
for (const dir of ['bus-host', 'bus']) {
|
||||
const base = join(root, dir);
|
||||
if (!existsSync(base)) continue;
|
||||
for (const name of readdirSync(base).sort()) {
|
||||
const p = join(base, name), st = statSync(p);
|
||||
out[`${dir}/${name}`] = st.isFile() ? createHash('sha256').update(readFileSync(p)).digest('hex') : st.isDirectory() ? 'dir' : 'other';
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
test('Stop bus stops a live bus cleanly: no locks, an offline migration runs, a second press answers stopped, setup starts it again', { timeout: 180000 }, async t => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'bs-'));
|
||||
const env = humanEnv({ HOME: home, MOSAIC_CONFIG: join(home, '.config/mosaic-bstop/config.json') });
|
||||
const setup = spawnSync(join(REPO, 'scripts/bootstrap.sh'), ['--instance', 'bstop'], { env, encoding: 'utf8' });
|
||||
assert.equal(setup.status, 0, setup.stderr);
|
||||
const ctx = { configDir: join(home, '.config/mosaic-bstop'), dataRoot: join(home, '.mosaic-bstop'), instance: 'bstop', env };
|
||||
t.after(async () => {
|
||||
try { await busStop(ctx, 'alpha'); } catch {}
|
||||
try { await consoleStop(ctx, { warn: () => {} }); } 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 = await busStatus(ctx, 'alpha');
|
||||
assert.equal(first.healthy, true);
|
||||
const root = first.root;
|
||||
const hostLock = join(root, 'bus-host', 'host.lock'), writerLock = join(root, 'bus', 'writer.lock');
|
||||
// A live bus holds both locks, so their absence afterwards is the stop's doing.
|
||||
assert.ok(existsSync(hostLock), 'a live bus holds host.lock'); assert.ok(existsSync(writerLock), 'a live bus holds writer.lock');
|
||||
const run = await consoleStart(ctx, { devLocalOwner: true, detach: true, port: 'auto', warn: () => {} });
|
||||
const port = Number(new URL(run.url).port);
|
||||
assert.ok(port > 0 && !FORBIDDEN.includes(port), String(port));
|
||||
|
||||
const stop = await post(port, '/api/orgs/alpha/stop/bus');
|
||||
assert.equal(stop.status, 200, stop.text);
|
||||
assert.equal(stop.body.step, 'bus-stop'); assert.equal(stop.body.state, 'stopped'); assert.equal(stop.body.stopped, true);
|
||||
assert.equal(stop.body.receipt.pid, first.pid);
|
||||
assert.ok(!stop.text.includes(home), 'the receipt names no host path');
|
||||
assert.ok(!existsSync(`/proc/${first.pid}`) || !readFileSync(`/proc/${first.pid}/cmdline`, 'utf8').includes('--internal-host'), 'the host process is gone');
|
||||
assert.ok(!existsSync(hostLock), 'no host.lock left'); assert.ok(!existsSync(writerLock), 'no writer.lock left');
|
||||
const status = await busStatus(ctx, 'alpha');
|
||||
assert.equal(status.healthy, false); assert.equal(status.pid ?? null, null);
|
||||
|
||||
// The offline schema migration opens the store straight after (it refuses writer-locked otherwise).
|
||||
const migrate = spawnSync(process.execPath, [MIGRATE, root], { env: { PATH: process.env.PATH }, encoding: 'utf8' });
|
||||
assert.equal(migrate.status, 0, migrate.stderr);
|
||||
assert.match(migrate.stdout, /bus schema \S+ ready/);
|
||||
assert.ok(!existsSync(writerLock), 'the migration released the store');
|
||||
|
||||
// A second press finds it stopped and changes nothing.
|
||||
const quiet = snapshot(root);
|
||||
const again = await post(port, '/api/orgs/alpha/stop/bus');
|
||||
assert.equal(again.status, 200, again.text);
|
||||
assert.deepEqual([again.body.step, again.body.state, again.body.stopped], ['bus-stop', 'stopped', false]);
|
||||
assert.deepEqual(snapshot(root), quiet);
|
||||
|
||||
// A host lock this Console cannot prove (a live pid that is not the recorded process) refuses,
|
||||
// and every bus file stays as it was.
|
||||
writeFileSync(hostLock, JSON.stringify({ pid: process.pid, startTime: '1', business: 'alpha', startedAt: new Date().toISOString() }));
|
||||
const unproven = snapshot(root);
|
||||
const refused = await post(port, '/api/orgs/alpha/stop/bus');
|
||||
assert.equal(refused.status, 409, refused.text); assert.equal(refused.body.error, 'bus-refused');
|
||||
assert.match(refused.body.message, /^stale bus host lock\. Inspect it with mosaic bus status alpha/);
|
||||
assert.deepEqual(snapshot(root), unproven);
|
||||
rmSync(hostLock);
|
||||
|
||||
// The setup step starts it again.
|
||||
const up = await post(port, '/api/orgs/alpha/setup/bus');
|
||||
assert.equal(up.status, 201, up.text); assert.equal(up.body.step, 'bus');
|
||||
const back = await busStatus(ctx, 'alpha');
|
||||
assert.equal(back.healthy, true); assert.notEqual(back.pid, first.pid);
|
||||
assert.ok(existsSync(hostLock)); assert.ok(existsSync(writerLock));
|
||||
// And stops again from the page.
|
||||
const last = await post(port, '/api/orgs/alpha/stop/bus');
|
||||
assert.equal(last.status, 200, last.text); assert.equal(last.body.stopped, true);
|
||||
assert.ok(!existsSync(hostLock)); assert.ok(!existsSync(writerLock));
|
||||
});
|
||||
@@ -39,6 +39,7 @@ function fakes() {
|
||||
busStart: async (ctx, id, opts) => { calls.push(['busStart', id, opts]); running = id; return { org: id, pid: 4242, socket: '/run/s', root: `/data/org-runtime/${id}` }; },
|
||||
busStopForDelete: async (ctx, id) => { calls.push(['busStopForDelete', id]); running = null; return { stopped: false, reason: 'no host runs' }; },
|
||||
busRestart: async (ctx, id) => { calls.push(['busRestart', id]); running = id; return { org: id, pid: 4343, socket: '/run/s', root: `/data/org-runtime/${id}` }; },
|
||||
busStop: async (ctx, id) => { calls.push(['busStop', id]); const was = running === id; running = null; return was ? { stopped: true, business: id, pid: 4242 } : { stopped: false, reason: 'no host runs' }; },
|
||||
};
|
||||
const admin = createOrgAdmin({ service, registry, bus, context: async () => ({ configDir: '/cfg', dataRoot: '/data', instance: 'test' }) });
|
||||
return { admin, orgs, calls, fail, service };
|
||||
@@ -371,6 +372,18 @@ test('toCanonical keeps library projects free of a root', () => {
|
||||
assert.deepEqual(out.projects, { lib: { kind: 'library' }, work: { root: '/__mosaic_org__/acme/projects/work' } });
|
||||
});
|
||||
|
||||
// #1873 CB1: copy bindings are server-held. An update keeps the stored ones whatever the browser
|
||||
// sent, and drops any the browser invented when none are stored.
|
||||
test('toCanonical keeps stored copyBindings and drops browser-sent ones on an update', () => {
|
||||
const stored = { id: 'acme', projects: { work: {} }, copyBindings: { rev: { state: 'pending', generation: 1 } } };
|
||||
const kept = toCanonical('acme', { projects: { work: {} }, copyBindings: { rev: { state: 'bound', generation: 9 } } }, stored);
|
||||
assert.deepEqual(kept.copyBindings, stored.copyBindings);
|
||||
const dropped = toCanonical('acme', { projects: { work: {} }, copyBindings: { rev: { state: 'bound' } } }, { id: 'acme', projects: { work: {} } });
|
||||
assert.equal(Object.hasOwn(dropped, 'copyBindings'), false);
|
||||
const removed = toCanonical('acme', { projects: { work: {} } }, stored);
|
||||
assert.deepEqual(removed.copyBindings, stored.copyBindings);
|
||||
});
|
||||
|
||||
test('the Settings page ships the organization assets', async t => {
|
||||
const c = await console_(t);
|
||||
const index = await c.call('GET', '/');
|
||||
@@ -674,3 +687,197 @@ test('D2: a press during the Console start-up revive waits for it and acts on th
|
||||
assert.equal(r.status, 200, r.raw); assert.equal(r.body.restarted, true);
|
||||
assert.deepEqual(c.calls.filter(x => x[0] === 'busRestart'), [['busRestart', 'acme']]);
|
||||
});
|
||||
|
||||
// Stop bus: the owner stops one organization's bus from its page. Same owner, origin, --send and
|
||||
// trail rules as Restart bus; a stopped bus answers state stopped and nothing changes.
|
||||
const STOP = '/api/orgs/acme/stop/bus';
|
||||
const STOP_DONE = { outcome: 'done', step: 'bus-stop', stopped: true, state: 'stopped', receipt: { org: 'acme', stopped: true, business: 'acme', pid: 4242 }, verb: 'mosaic bus stop acme' };
|
||||
|
||||
test('stop: the owner stops a running bus, a second press answers stopped, and setup starts it again', async t => {
|
||||
const tr = trailIn(t);
|
||||
const c = await console_(t, { trail: tr.trail });
|
||||
assert.equal((await c.call('POST', '/api/orgs', { id: 'acme', doc: browserDoc('acme') })).status, 201);
|
||||
assert.equal((await c.call('POST', '/api/orgs/acme/setup/bus', {})).status, 201);
|
||||
let r = await c.call('POST', STOP, {});
|
||||
assert.equal(r.status, 200, r.raw);
|
||||
assert.deepEqual(r.body, STOP_DONE);
|
||||
r = await c.call('POST', STOP, {});
|
||||
assert.equal(r.status, 200, r.raw);
|
||||
assert.deepEqual(r.body, { outcome: 'done', step: 'bus-stop', stopped: false, state: 'stopped', receipt: { org: 'acme', stopped: false }, verb: 'mosaic bus stop acme' });
|
||||
// The second press changed nothing: busStop ran once.
|
||||
assert.deepEqual(c.calls.filter(x => x[0] === 'busStop'), [['busStop', 'acme']]);
|
||||
const up = await c.call('POST', '/api/orgs/acme/setup/bus', {});
|
||||
assert.equal(up.status, 201, up.raw); assert.equal(up.body.step, 'bus');
|
||||
assert.equal(c.calls.filter(x => x[0] === 'busStart').length, 2);
|
||||
assert.deepEqual(tr.lines(), [
|
||||
{ by: DEV, action: 'bus.stop', org: 'acme', outcome: 'requested', running: true },
|
||||
{ by: DEV, action: 'bus.stop', org: 'acme', outcome: 'done' },
|
||||
{ by: DEV, action: 'bus.stop', org: 'acme', outcome: 'requested', running: false },
|
||||
{ by: DEV, action: 'bus.stop', org: 'acme', outcome: 'done' },
|
||||
]);
|
||||
});
|
||||
|
||||
test('stop: another origin or site, no owner or --send, a body, GET or no organization is refused like Restart bus', async t => {
|
||||
const tr = trailIn(t);
|
||||
const c = await console_(t, { trail: tr.trail });
|
||||
await c.call('POST', '/api/orgs', { id: 'acme', doc: browserDoc('acme') });
|
||||
await c.call('POST', '/api/orgs/acme/setup/bus', {});
|
||||
for (const headers of [{ origin: null }, { origin: 'http://evil.example' }, { origin: `http://localhost:${c.port + 1}` }, { 'sec-fetch-site': null }, { 'sec-fetch-site': 'cross-site' }, { 'sec-fetch-site': 'same-site' }, { 'x-mosaic-tester': '1' }]) {
|
||||
const [stop, restart] = [await c.call('POST', STOP, {}, headers), await c.call('POST', '/api/orgs/acme/restart/bus', {}, headers)];
|
||||
assert.equal(stop.status, 403, JSON.stringify(headers));
|
||||
assert.deepEqual([stop.status, stop.body?.error], [restart.status, restart.body?.error], JSON.stringify(headers));
|
||||
}
|
||||
assert.equal((await c.call('POST', STOP, {}, { 'content-type': 'text/plain' })).status, 415);
|
||||
assert.equal((await c.call('POST', STOP, { now: true })).status, 400);
|
||||
assert.equal((await c.call('GET', STOP)).status, 404);
|
||||
assert.equal((await c.call('POST', '/api/orgs/acme/stop/queue', {})).status, 404);
|
||||
assert.equal((await c.call('POST', '/api/orgs/acme/stop', {})).status, 404);
|
||||
const unknown = await c.call('POST', '/api/orgs/ghost/stop/bus', {});
|
||||
assert.equal(unknown.status, 404); assert.equal(unknown.body.error, 'org-unavailable');
|
||||
const readOnly = await console_(t, { trail: tr.trail, send: null });
|
||||
const ro = await readOnly.call('POST', STOP, {});
|
||||
assert.equal(ro.status, 403); assert.equal(ro.body.error, 'read-only');
|
||||
const anon = await console_(t, { trail: tr.trail, sso: null, send: null });
|
||||
const a = await anon.call('POST', STOP, {});
|
||||
assert.equal(a.status, 403); assert.equal(a.body.error, 'owner-only');
|
||||
assert.equal(c.calls.filter(x => ['busStop', 'busRestart'].includes(x[0])).length, 0);
|
||||
assert.deepEqual(tr.lines(), []);
|
||||
});
|
||||
|
||||
test('stop: on a deployed (signed-in) Console a granted user is refused like Restart bus, and the owner stops the bus', async t => {
|
||||
const { createSsoSession } = await import('./helpers/sso-session.mjs');
|
||||
const { consoleAccess } = await import('../src/access.mjs');
|
||||
const tr = trailIn(t);
|
||||
const access = { accessVersion: 1, revision: 1, users: [{ id: 'alice', home: 'local', subjects: [{ provider: 'writer', sub: 'alice-sub' }] }],
|
||||
grants: [{ id: 'g-1', user: 'alice', business: 'acme', projects: ['*'], instances: ['assistant'], level: 'collaborator' }] };
|
||||
const s = await createSsoSession(t, { users: consoleAccess({ load: () => access, business: () => 'acme' }) });
|
||||
const f = fakes();
|
||||
await f.service.createOrg({}, 'acme', template('acme'));
|
||||
const bus = await f.admin.bus();
|
||||
await bus.busStart({}, 'acme', {});
|
||||
const server = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso: s.sso, send: async () => ({}), orgAdmin: f.admin, log: () => {}, trail: tr.trail });
|
||||
t.after(() => close(server));
|
||||
const port = server.address().port;
|
||||
const post = (path, cookie, headers = {}) => new Promise((resolve, reject) => {
|
||||
const all = { ...s.writeHeaders(cookie), 'content-length': 2, ...headers };
|
||||
const req = request({ host: '127.0.0.1', port, method: 'POST', path, headers: all }, res => { let raw = ''; res.on('data', x => raw += x); res.on('end', () => { let json = null; try { json = JSON.parse(raw); } catch {} resolve({ status: res.statusCode, body: json, raw }); }); });
|
||||
req.on('error', reject); req.end('{}');
|
||||
});
|
||||
const alice = await s.signIn(port, { sub: 'alice-sub' });
|
||||
for (const cookie of [null, alice]) {
|
||||
const [stop, restart] = [await post(STOP, cookie), await post('/api/orgs/acme/restart/bus', cookie)];
|
||||
assert.ok(stop.status === 401 || stop.status === 403 || stop.status === 404, stop.raw);
|
||||
assert.deepEqual([stop.status, stop.body?.error], [restart.status, restart.body?.error]);
|
||||
}
|
||||
assert.equal(f.calls.filter(x => ['busStop', 'busRestart'].includes(x[0])).length, 0);
|
||||
assert.deepEqual(tr.lines(), []);
|
||||
// The signed-in owner from another origin is refused too; from the Console's own origin the bus stops.
|
||||
const owner = await s.signIn(port);
|
||||
const cross = await post(STOP, owner, { origin: 'https://evil.example' });
|
||||
assert.equal(cross.status, 403);
|
||||
const ok = await post(STOP, owner);
|
||||
assert.equal(ok.status, 200, ok.raw); assert.equal(ok.body.state, 'stopped'); assert.equal(ok.body.stopped, true);
|
||||
assert.deepEqual(tr.lines().map(e => [e.by.provider, e.by.sub, e.action, e.outcome]), [['writer', 'owner-sub', 'bus.stop', 'requested'], ['writer', 'owner-sub', 'bus.stop', 'done']]);
|
||||
});
|
||||
|
||||
test('stop: a signed-in operator who is not the owner is refused before any bus call', async t => {
|
||||
const { Readable } = await import('node:stream');
|
||||
const tr = trailIn(t);
|
||||
const f = fakes();
|
||||
await f.service.createOrg({}, 'acme', template('acme'));
|
||||
for (const path of [STOP, '/api/orgs/acme/restart/bus']) {
|
||||
const req = Object.assign(Readable.from([Buffer.from('{}')]), { method: 'POST', headersDistinct: { origin: ['http://console.example'], 'sec-fetch-site': ['same-origin'], 'content-type': ['application/json'] } });
|
||||
const res = { writeHead(status) { this.status = status; }, end(body) { this.body = JSON.parse(body); } };
|
||||
await orgAdminHTTP(req, res, { path, admin: f.admin, session: { provider: 'fixture', iss: 'https://issuer.example', sub: 'op', rootOwner: false, instanceRole: 'operator' }, owner: false, send: true, origin: 'http://console.example', trail: tr.trail });
|
||||
assert.equal(res.status, 403, path); assert.equal(res.body.error, 'owner-only', path);
|
||||
}
|
||||
assert.equal(f.calls.filter(x => x[0].startsWith('bus')).length, 0);
|
||||
assert.deepEqual(tr.lines(), []);
|
||||
});
|
||||
|
||||
test('stop: no trail, or a trail that cannot be written, stops nothing', async t => {
|
||||
const c = await console_(t);
|
||||
await c.call('POST', '/api/orgs', { id: 'acme', doc: browserDoc('acme') });
|
||||
await c.call('POST', '/api/orgs/acme/setup/bus', {});
|
||||
const r = await c.call('POST', STOP, {});
|
||||
assert.equal(r.status, 503); assert.equal(r.body.error, 'trail-unavailable');
|
||||
const broken = await console_(t, { trail: () => { throw new Error('disk full'); } });
|
||||
await broken.call('POST', '/api/orgs', { id: 'acme', doc: browserDoc('acme') });
|
||||
await broken.call('POST', '/api/orgs/acme/setup/bus', {});
|
||||
const b = await broken.call('POST', STOP, {});
|
||||
assert.equal(b.status, 500); assert.equal(b.body.error, 'trail-unavailable');
|
||||
for (const x of [c, broken]) assert.equal(x.calls.filter(y => y[0] === 'busStop').length, 0);
|
||||
});
|
||||
|
||||
test('stop: a host that cannot be proven refuses with nothing changed, on the dev Console too', async t => {
|
||||
const tr = trailIn(t);
|
||||
const c = await console_(t, { trail: tr.trail });
|
||||
await c.call('POST', '/api/orgs', { id: 'acme', doc: browserDoc('acme') });
|
||||
const bus = await c.admin.bus();
|
||||
bus.busRevive = async () => assert.fail('Stop bus reached busRevive');
|
||||
for (const reason of ['stale bus host lock', 'bus host identity mismatch', 'bus host lock does not match host state', 'stale bus writer lock']) {
|
||||
bus.busStatus = async () => { throw cliError(reason); };
|
||||
const r = await c.call('POST', STOP, {});
|
||||
assert.equal(r.status, 409, reason); assert.equal(r.body.error, 'bus-refused');
|
||||
assert.equal(r.body.message, `${reason}. Inspect it with mosaic bus status acme; mosaic bus recover acme clears a host that died.`);
|
||||
}
|
||||
assert.equal(c.calls.filter(x => x[0] === 'busStop').length, 0);
|
||||
assert.deepEqual(tr.lines(), Array(4).fill({ by: DEV, action: 'bus.stop', org: 'acme', outcome: 'refused', running: null }));
|
||||
});
|
||||
|
||||
test('stop: a stop that is refused, times out or leaves state behind is a visible failure', async t => {
|
||||
const tr = trailIn(t);
|
||||
const c = await console_(t, { trail: tr.trail });
|
||||
await c.call('POST', '/api/orgs', { id: 'acme', doc: browserDoc('acme') });
|
||||
await c.call('POST', '/api/orgs/acme/setup/bus', {});
|
||||
const bus = await c.admin.bus();
|
||||
const live = async () => ({ org: 'acme', healthy: true, pid: 4242, root: '/data/org-runtime/acme' });
|
||||
bus.busStatus = live;
|
||||
bus.busStop = async () => { throw cliError('pid 4242 is not a bus host; refusing to signal it'); };
|
||||
let r = await c.call('POST', STOP, {});
|
||||
assert.equal(r.status, 409); assert.equal(r.body.error, 'bus-refused');
|
||||
assert.equal(r.body.message, 'pid 4242 is not a bus host; refusing to signal it. Inspect it with mosaic bus status acme.');
|
||||
bus.busStop = async () => { throw Object.assign(new Error('bus host pid 4242 did not stop within 60 s'), { name: 'CliError', exitCode: 1 }); };
|
||||
r = await c.call('POST', STOP, {});
|
||||
assert.equal(r.status, 409); assert.equal(r.body.error, 'bus-failed');
|
||||
// An unexpected error may come after the signal: the outcome is unknown and the trail says failed.
|
||||
bus.busStop = async () => { throw new Error('EPERM'); };
|
||||
r = await c.call('POST', STOP, {});
|
||||
assert.equal(r.status, 502, r.raw); assert.equal(r.body.error, 'outcome-unknown');
|
||||
// The host was signalled and exited, but a lock is left, or the host is still recorded.
|
||||
let after;
|
||||
bus.busStop = async () => { bus.busStatus = after; return { stopped: true, business: 'acme', pid: 4242 }; };
|
||||
after = async () => { throw cliError('stale bus writer lock'); };
|
||||
r = await c.call('POST', STOP, {});
|
||||
assert.equal(r.status, 409); assert.equal(r.body.error, 'bus-failed');
|
||||
assert.equal(r.body.message, 'the bus host was stopped, but stale bus writer lock. Inspect it with mosaic bus status acme; mosaic bus recover acme clears a host that died.');
|
||||
bus.busStatus = live; after = live;
|
||||
r = await c.call('POST', STOP, {});
|
||||
assert.equal(r.status, 409); assert.equal(r.body.error, 'bus-failed');
|
||||
assert.match(r.body.message, /^the bus host was stopped, but a bus host still runs\./);
|
||||
assert.deepEqual(tr.lines().map(e => e.outcome), ['requested', 'refused', 'requested', 'failed', 'requested', 'failed', 'requested', 'failed', 'requested', 'failed']);
|
||||
// The host exited on its own between the read and the stop: done, but this press stopped nothing.
|
||||
bus.busStatus = live;
|
||||
bus.busStop = async () => { bus.busStatus = async () => ({ org: 'acme', healthy: false, pid: null }); return { stopped: false, reason: 'no host runs' }; };
|
||||
r = await c.call('POST', STOP, {});
|
||||
assert.equal(r.status, 200, r.raw);
|
||||
assert.deepEqual([r.body.outcome, r.body.state, r.body.stopped, r.body.receipt.stopped], ['done', 'stopped', false, false]);
|
||||
});
|
||||
|
||||
test('stop: an archived organization, or a Console whose bus control has no stop, refuses', async t => {
|
||||
const tr = trailIn(t);
|
||||
const c = await console_(t, { trail: tr.trail });
|
||||
await c.call('POST', '/api/orgs', { id: 'acme', doc: browserDoc('acme') });
|
||||
const bus = await c.admin.bus();
|
||||
const busStop = bus.busStop; delete bus.busStop;
|
||||
const n = await c.call('POST', STOP, {});
|
||||
assert.equal(n.status, 503); assert.equal(n.body.error, 'not-configured');
|
||||
bus.busStop = busStop;
|
||||
bus.busStatus = async () => { throw new OrgError('org-archived', 'organization acme is archived'); };
|
||||
const a = await c.call('POST', STOP, {});
|
||||
const ra = await c.call('POST', '/api/orgs/acme/restart/bus', {});
|
||||
assert.deepEqual([a.status, a.body.error], [422, 'org-archived'], a.raw);
|
||||
assert.deepEqual([ra.status, ra.body.error], [422, 'org-archived'], ra.raw);
|
||||
assert.equal(c.calls.filter(x => ['busStop', 'busRestart'].includes(x[0])).length, 0);
|
||||
assert.deepEqual(tr.lines(), []);
|
||||
});
|
||||
@@ -0,0 +1,205 @@
|
||||
// #1873 Proxy setup routes over the real HTTP boundary: only the instance owner reaches them, a
|
||||
// pasted key crosses only loopback or HTTPS, and no key reaches a response or a log line.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { request } from 'node:http';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { createDevOwner } from '../src/dev-owner.mjs';
|
||||
import { PROXY_ADMIN_PATHS, keyTransportOk, orgCatalogue } from '../src/proxy-admin.mjs';
|
||||
import { consoleAccess } from '../src/access.mjs';
|
||||
import { createSsoSession } from './helpers/sso-session.mjs';
|
||||
import { REFUSALS } from '../../bus/src/refusals.mjs';
|
||||
|
||||
const SENTINEL = 'pa-http-sentinel-key-0123456789abcdef';
|
||||
const ROUTES = [
|
||||
['status', 'GET'], ['unit/render', 'GET'], ['fetch', 'POST', {}], ['init', 'POST', { generate: true }],
|
||||
['unit/install', 'POST', {}], ['unit/start', 'POST', {}], ['unit/stop', 'POST', {}], ['unit/restart', 'POST', {}],
|
||||
['fence/clear', 'POST', {}], ['init-lock/clear', 'POST', {}],
|
||||
];
|
||||
|
||||
function fakeAdmin() {
|
||||
const calls = [];
|
||||
const note = name => async (...args) => { calls.push([name, ...args.filter(a => typeof a !== 'function')]); return { ok: name }; };
|
||||
return {
|
||||
calls,
|
||||
unit: 'mosaic-proxy.service',
|
||||
status: note('status'), render: note('render'), unitAction: note('unitAction'), initLockClear: note('initLockClear'),
|
||||
fetchStart: () => { calls.push(['fetchStart']); return { state: 'running' }; },
|
||||
init: async (call, args) => { calls.push(['init', args]); await call('proxy.init', args); return { settings: { port: 18432 }, receipt: { op: 'init' } }; },
|
||||
fenceClear: async call => { calls.push(['fenceClear']); return call('proxy.fence.clear', { evidence: {} }); },
|
||||
};
|
||||
}
|
||||
const send = (port, path, { method = 'GET', headers = {}, body } = {}) => new Promise((resolve, reject) => {
|
||||
const req = request({ host: '127.0.0.1', port, path, method, headers }, res => {
|
||||
let text = ''; res.on('data', c => { text += c; }); res.on('end', () => resolve({ status: res.statusCode, text, json: (() => { try { return JSON.parse(text); } catch { return null; } })() }));
|
||||
});
|
||||
req.on('error', reject); req.end(body === undefined ? undefined : typeof body === 'string' ? body : JSON.stringify(body));
|
||||
});
|
||||
|
||||
async function devServer(t, options = {}) {
|
||||
const admin = fakeAdmin(), busCalls = [], logs = [];
|
||||
const s = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso: createDevOwner(), send: async () => ({}), proxyAdmin: admin,
|
||||
proxyCall: async (verb, args) => { busCalls.push([verb, args]); return { cleared: true }; }, log: line => logs.push(line), ...options });
|
||||
t.after(() => new Promise(r => s.close(r)));
|
||||
const port = s.address().port;
|
||||
const hostFor = name => `${name}:${port}`;
|
||||
const headers = (name = '127.0.0.1') => ({ host: hostFor(name), origin: `http://${hostFor(name)}`, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json' });
|
||||
return { s, port, admin, busCalls, logs, headers };
|
||||
}
|
||||
const hit = (f, route, extra = {}) => send(f.port, `/api/proxy/admin/${route[0]}`, { method: route[1], headers: { ...f.headers(), ...extra }, body: route[2] });
|
||||
|
||||
test('the route table is exactly the brief: status, render, fetch, init, four unit verbs, fence clear, lock clear', () => {
|
||||
assert.deepEqual(PROXY_ADMIN_PATHS, ROUTES.map(([p]) => `/api/proxy/admin/${p}`));
|
||||
});
|
||||
|
||||
test('the dev local owner reaches every route; init and fence clear go to the bus; the page config offers setup', async t => {
|
||||
const f = await devServer(t);
|
||||
for (const route of ROUTES) {
|
||||
const r = await hit(f, route);
|
||||
assert.equal(r.status, route[0] === 'fetch' ? 202 : 200, route[0]);
|
||||
}
|
||||
assert.deepEqual(f.admin.calls.map(c => c[0]), ['status', 'render', 'fetchStart', 'init', 'unitAction', 'unitAction', 'unitAction', 'unitAction', 'fenceClear', 'initLockClear']);
|
||||
assert.deepEqual(f.admin.calls.filter(c => c[0] === 'unitAction').map(c => c[1]), ['install', 'start', 'stop', 'restart']);
|
||||
assert.deepEqual(f.busCalls.map(c => c[0]), ['proxy.init', 'proxy.fence.clear']);
|
||||
assert.deepEqual(f.busCalls[0][1], { generate: true });
|
||||
const config = await send(f.port, '/api/config', { headers: { host: `127.0.0.1:${f.port}` } });
|
||||
assert.equal(config.json.proxyAdmin, true);
|
||||
assert.equal(config.json.proxyAdminSend, true);
|
||||
for (const line of f.logs.filter(l => l.includes('/api/proxy/admin/'))) assert.match(line, /^\S+ (GET|POST) \/api\/proxy\/admin\/[a-z/-]+ \d{3} user=/);
|
||||
});
|
||||
|
||||
test('without a proxy admin the config offers no setup and the routes answer not-configured', async t => {
|
||||
const f = await devServer(t, { proxyAdmin: null });
|
||||
const config = await send(f.port, '/api/config', { headers: { host: `127.0.0.1:${f.port}` } });
|
||||
assert.equal(Object.hasOwn(config.json, 'proxyAdmin'), false);
|
||||
assert.deepEqual((await hit(f, ROUTES[0])).json, { error: 'not-configured' });
|
||||
});
|
||||
|
||||
test('writes refuse a foreign Origin, missing Fetch Metadata, a read-only Console and a wrong method', async t => {
|
||||
const f = await devServer(t);
|
||||
for (const route of ROUTES.filter(r => r[1] === 'POST')) {
|
||||
assert.equal((await hit(f, route, { origin: 'http://evil.example' })).status, 403, `${route[0]} cross-origin`);
|
||||
assert.equal((await hit(f, route, { 'sec-fetch-site': 'cross-site' })).status, 403, `${route[0]} cross-site`);
|
||||
const noMeta = { ...f.headers() }; delete noMeta['sec-fetch-site'];
|
||||
assert.equal((await send(f.port, `/api/proxy/admin/${route[0]}`, { method: 'POST', headers: noMeta, body: route[2] })).status, 403, `${route[0]} no fetch metadata`);
|
||||
assert.equal((await send(f.port, `/api/proxy/admin/${route[0]}`, { method: 'GET', headers: f.headers() })).status, 405, `${route[0]} GET`);
|
||||
}
|
||||
assert.deepEqual(f.admin.calls, []);
|
||||
const readOnly = await devServer(t, { send: null });
|
||||
for (const route of ROUTES.filter(r => r[1] === 'POST')) assert.deepEqual((await hit(readOnly, route)).json, { error: 'read-only' }, route[0]);
|
||||
assert.deepEqual(readOnly.admin.calls, []);
|
||||
assert.equal((await hit(f, ['nope', 'GET'])).status, 404);
|
||||
});
|
||||
|
||||
test('a pasted key: refused over plain-HTTP LAN before it is judged, accepted on loopback; mismatch and bad keys refuse', async t => {
|
||||
const f = await devServer(t, { devAllowHosts: ['lan.example'] });
|
||||
const lan = { ...f.headers('lan.example') }; delete lan['sec-fetch-site'];
|
||||
const paste = { key: SENTINEL, confirm: SENTINEL };
|
||||
const refused = await send(f.port, '/api/proxy/admin/init', { method: 'POST', headers: lan, body: paste });
|
||||
assert.equal(refused.status, 403); assert.deepEqual(refused.json, { error: 'proxy-key-transport' });
|
||||
const refusedMismatch = await send(f.port, '/api/proxy/admin/init', { method: 'POST', headers: lan, body: { key: SENTINEL, confirm: 'other' } });
|
||||
assert.deepEqual(refusedMismatch.json, { error: 'proxy-key-transport' }, 'transport is checked before the key');
|
||||
const generated = await send(f.port, '/api/proxy/admin/init', { method: 'POST', headers: lan, body: { generate: true } });
|
||||
assert.equal(generated.status, 200, 'a LAN page still gets the generated key');
|
||||
assert.deepEqual(f.busCalls.map(c => c[1]), [{ generate: true }]);
|
||||
assert.deepEqual((await hit(f, ['init', 'POST', { key: SENTINEL, confirm: 'different' }])).json, { error: 'proxy-key-mismatch' });
|
||||
assert.deepEqual((await hit(f, ['init', 'POST', { key: 'a\nb', confirm: 'a\nb' }])).json, { error: 'proxy-key-invalid' });
|
||||
for (const body of [{ key: SENTINEL, confirm: SENTINEL, generate: true }, { generate: true, extra: 1 }, { generate: false }, {}])
|
||||
assert.deepEqual((await hit(f, ['init', 'POST', body])).json, { error: 'invalid-request' }, JSON.stringify(body));
|
||||
assert.deepEqual((await hit(f, ['init', 'POST', { generate: true, port: 8317 }])).json, { error: 'proxy-config-invalid' });
|
||||
assert.deepEqual((await hit(f, ['init', 'POST', { generate: true, rotateGraceMinutes: 0 }])).json, { error: 'proxy-rotate-grace-invalid' });
|
||||
assert.deepEqual((await hit(f, ['init', 'POST', { generate: true, firstOutputSeconds: 99999 }])).json, { error: 'proxy-first-output-invalid' });
|
||||
const ok = await hit(f, ['init', 'POST', { ...paste, port: 18432, rotateGraceMinutes: 5, firstOutputSeconds: 60 }]);
|
||||
assert.equal(ok.status, 200);
|
||||
assert.deepEqual(f.busCalls.at(-1), ['proxy.init', { key: SENTINEL, port: 18432, rotateGraceMinutes: 5, firstOutputSeconds: 60 }]);
|
||||
assert.equal(f.logs.join('\n').includes(SENTINEL), false, 'no key in a log line');
|
||||
assert.equal(JSON.stringify(f.admin.calls.map(c => c[0])).includes(SENTINEL), false);
|
||||
});
|
||||
|
||||
test('keyTransportOk: HTTPS anywhere, HTTP only on a loopback name from a loopback peer', () => {
|
||||
assert.equal(keyTransportOk('https://console.example', '10.0.0.5'), true);
|
||||
assert.equal(keyTransportOk('http://127.0.0.1:7330', '127.0.0.1'), true);
|
||||
assert.equal(keyTransportOk('http://localhost:7330', '::ffff:127.0.0.1'), true);
|
||||
assert.equal(keyTransportOk('http://127.0.0.1:7330', '10.0.0.5'), false, 'a forwarded peer');
|
||||
assert.equal(keyTransportOk('http://lan.example:7330', '127.0.0.1'), false);
|
||||
assert.equal(keyTransportOk('http://10.0.0.2:7330', '10.0.0.5'), false);
|
||||
assert.equal(keyTransportOk('not a url', '127.0.0.1'), false);
|
||||
});
|
||||
|
||||
test('a refusal from the controller or bus reaches the page as its code only', async t => {
|
||||
const f = await devServer(t);
|
||||
const leak = Object.assign(new Error(`${SENTINEL} /home/owner/.config/mosaic-dev/proxy.json upstream body`), { code: 'proxy-unit-failed' });
|
||||
f.admin.unitAction = async () => { throw leak; };
|
||||
f.admin.status = async () => { throw new Error(SENTINEL); };
|
||||
const unit = await hit(f, ['unit/start', 'POST', {}]);
|
||||
assert.deepEqual(unit.json, { error: 'proxy-unit-failed' });
|
||||
const status = await hit(f, ROUTES[0]);
|
||||
assert.deepEqual(status.json, { error: 'outcome-unknown' });
|
||||
for (const text of [unit.text, status.text, f.logs.join('\n')]) { assert.equal(text.includes(SENTINEL), false); assert.equal(text.includes('/home/'), false); }
|
||||
const big = await send(f.port, '/api/proxy/admin/init', { method: 'POST', headers: f.headers(), body: `{"key":"${SENTINEL}${'x'.repeat(17000)}"}` });
|
||||
assert.deepEqual(big.json, { error: 'invalid-request' });
|
||||
const broken = await send(f.port, '/api/proxy/admin/init', { method: 'POST', headers: f.headers(), body: `{"key":"${SENTINEL}` });
|
||||
assert.deepEqual(broken.json, { error: 'invalid-request' });
|
||||
assert.equal(f.logs.join('\n').includes(SENTINEL), false);
|
||||
});
|
||||
|
||||
test('an SSO root owner reaches the routes and may paste over HTTPS; a granted user gets human-required on every route', async t => {
|
||||
const access = { accessVersion: 1, revision: 1,
|
||||
users: [{ id: 'alice', home: 'local', subjects: [{ provider: 'writer', sub: 'alice-sub' }] }],
|
||||
grants: [{ id: 'g-1', user: 'alice', business: 'demo', projects: ['*'], instances: ['*'], level: 'collaborator' }] };
|
||||
const auth = await createSsoSession(t, { host: 'console.example', users: consoleAccess({ load: () => access, business: () => 'demo' }) });
|
||||
const admin = fakeAdmin(), busCalls = [];
|
||||
let bound = 0;
|
||||
const s = await startServer({ port: 0, sso: auth.sso, send: async () => ({}), proxyAdmin: admin, proxyCall: async (verb, args) => { busCalls.push([verb, args]); return {}; },
|
||||
userOptions: () => { bound++; return {}; }, log: () => {} });
|
||||
t.after(() => new Promise(r => s.close(r)));
|
||||
const port = s.address().port;
|
||||
const owner = auth.writeHeaders(await auth.signIn(port));
|
||||
assert.equal((await send(port, '/api/proxy/admin/status', { headers: owner })).status, 200);
|
||||
assert.equal((await send(port, '/api/config', { headers: owner })).json.proxyAdmin, true);
|
||||
const pasted = await send(port, '/api/proxy/admin/init', { method: 'POST', headers: owner, body: { key: SENTINEL, confirm: SENTINEL } });
|
||||
assert.equal(pasted.status, 200, 'HTTPS origin carries a pasted key');
|
||||
assert.equal(pasted.text.includes(SENTINEL), false);
|
||||
const before = admin.calls.length;
|
||||
const alice = auth.writeHeaders(await auth.signIn(port, { sub: 'alice-sub' }));
|
||||
for (const [route, method, body] of ROUTES) {
|
||||
const r = await send(port, `/api/proxy/admin/${route}`, { method, headers: alice, body });
|
||||
assert.equal(r.status, 403, route);
|
||||
assert.deepEqual(r.json, { error: 'human-required' }, route);
|
||||
}
|
||||
assert.equal(bound, 0, 'refused before a user transport is bound');
|
||||
assert.equal(Object.hasOwn((await send(port, '/api/config', { headers: alice })).json, 'proxyAdmin'), false);
|
||||
assert.equal(admin.calls.length, before);
|
||||
});
|
||||
|
||||
test('the tester listener gets human-required on every route, read or write', async t => {
|
||||
const { startTesterSocket } = await import('../src/tester-socket.mjs');
|
||||
const { testerRequest } = await import('../../cli/src/tester-http.mjs');
|
||||
const root = mkdtempSync(join(tmpdir(), 'pa-tester-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const admin = fakeAdmin();
|
||||
let calls = 0;
|
||||
const s = await startTesterSocket({ dataRoot: root, proxyAdmin: admin, proxyCall: async () => { calls++; return {}; }, log: () => {} });
|
||||
t.after(() => new Promise(r => s.close(r)));
|
||||
for (const [route, method, body] of ROUTES) {
|
||||
const r = await testerRequest({ dataRoot: root, path: `/api/proxy/admin/${route}`, method, body });
|
||||
assert.equal(r.status, 403, route);
|
||||
}
|
||||
assert.equal(calls, 0);
|
||||
assert.deepEqual(admin.calls, []);
|
||||
});
|
||||
|
||||
test('every refusal code a setup route can answer has a refusal-table entry with a next step', () => {
|
||||
for (const code of ['human-required', 'proxy-key-transport', 'proxy-key-mismatch', 'proxy-key-invalid', 'proxy-config-invalid', 'proxy-rotate-grace-invalid', 'proxy-first-output-invalid', 'proxy-admin-busy', 'proxy-restart-required', 'proxy-restart-unconfirmed', 'proxy-init-lock-held', 'proxy-init-lock-absent', 'proxy-unit-missing', 'proxy-unit-unhealthy', 'proxy-unit-failed', 'not-configured', 'outcome-unknown', 'invalid-request'])
|
||||
assert.equal(typeof REFUSALS[code]?.nextStep, 'string', code);
|
||||
});
|
||||
|
||||
test('an organization whose bus does not answer shows no model count, not a made-up 0', async () => {
|
||||
assert.deepEqual(await orgCatalogue(async () => { throw new Error('down'); }), { status: 'bus-unavailable', models: null });
|
||||
assert.deepEqual(await orgCatalogue(async () => ({ catalogue: { status: 'disabled' } })), { status: 'disabled', models: null });
|
||||
assert.deepEqual(await orgCatalogue(async () => ({ catalogue: { status: 'enabled', models: [{ id: 'a' }, { id: 'b' }] } })), { status: 'enabled', models: 2 });
|
||||
assert.deepEqual(await orgCatalogue(async () => ({ catalogue: { status: 'enabled', models: [] } })), { status: 'enabled', models: 0 });
|
||||
});
|
||||
@@ -0,0 +1,146 @@
|
||||
// #1873 the Proxy setup panel in a real browser: the next step is one button, init sends a
|
||||
// generated-key request unless the owner pastes a key on loopback, and pasted fields are cleared.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { w15Fixture } from './w15-fixture.mjs';
|
||||
|
||||
const T = '2026-11-11T12:00:00.000Z';
|
||||
const base = {
|
||||
release: { fetched: false, verified: false, version: '8.0.42', panelVersion: '1.26.7' },
|
||||
init: { initialized: false, partial: false, files: [{ label: 'proxy settings', present: false }, { label: 'management key', present: false }] },
|
||||
unit: { unit: 'mosaic-proxy.service', installed: false, active: 'inactive' },
|
||||
fence: { set: false }, initLock: { present: false, clearable: false, blockers: [] },
|
||||
fetch: { state: 'idle' }, restart: null, busy: null,
|
||||
organizations: [{ id: 'acme', proxy: 'shared', catalogue: null }],
|
||||
ownProxy: { available: false, state: 'not built', reason: 'The configuration has one proxy per instance.' },
|
||||
next: ['fetch'],
|
||||
};
|
||||
const fetched = { ...base, release: { ...base.release, fetched: true, verified: true }, next: ['init'] };
|
||||
const fenced = { ...base, release: fetched.release, init: { initialized: true, partial: false, settings: { port: 18432 }, files: [] },
|
||||
unit: { unit: 'mosaic-proxy.service', installed: true, active: 'active', pid: 4242, port: 18432, listening: true },
|
||||
fence: { set: true, op: 'apikey.add', org: 'acme', time: T }, restart: { confirmed: true, pid: 4242, observedAt: T }, next: ['fence-clear'] };
|
||||
const unconfigured = { 'GET /api/proxy/accounts': { status: 503, body: { error: 'not-configured' } }, 'GET /api/proxy/keys': { status: 503, body: { error: 'not-configured' } } };
|
||||
const scenario = (status, config = {}) => ({ '/api/config': { send: true, devOwner: true, proxyAdmin: true, proxyAdminSend: true, ...config }, '/api/proxy/admin/status': status, ...unconfigured });
|
||||
const writes = b => b.evaluate('window.__writes.filter(w=>w.path.startsWith("/api/proxy/admin/")).map(w=>({path:w.path,body:JSON.parse(w.body)}))');
|
||||
|
||||
test('a fresh instance shows the fetch step as one button, the not-built own proxy, and no terminal note', { timeout: 90000 }, async t => {
|
||||
const { f, b, wait } = await w15Fixture(t, { scenario: { ...scenario(base), 'POST /api/proxy/admin/fetch': { status: 202, body: { state: 'running' } } } });
|
||||
await b.navigate(f.base + '/#/settings/model-proxy');
|
||||
await wait('document.querySelector("#proxy-setup-go")?.dataset.next==="fetch"');
|
||||
assert.equal(await b.evaluate('document.querySelector("#proxy-setup").hidden'), false);
|
||||
assert.equal(await b.evaluate('document.querySelector("#proxy-setup-go").textContent'), 'Fetch the pinned release');
|
||||
assert.match(await b.evaluate('document.querySelector("#proxy-setup-next p").textContent'), /CLIProxyAPI 8\.0\.42/);
|
||||
assert.match(await b.evaluate('document.querySelector("#proxy-setup-own").textContent'), /not built/);
|
||||
assert.equal(await b.evaluate('document.querySelector("#proxy-setup-orgs td").textContent'), 'acme');
|
||||
await wait('document.querySelector("#proxy-message")?.textContent.includes("Follow the next step under Proxy setup")');
|
||||
assert.equal(await b.evaluate('!!document.querySelector("#proxy-dev-setup")'), false, 'the page replaces the terminal note');
|
||||
assert.equal(await b.evaluate('document.querySelectorAll("#proxy-setup-init [data-setup-write]:not([disabled])").length'), 0, 'init waits for a verified release');
|
||||
await b.evaluate('document.querySelector("#proxy-setup-go").click()');
|
||||
await wait('window.__writes.some(w=>w.path==="/api/proxy/admin/fetch")');
|
||||
assert.deepEqual(await writes(b), [{ path: '/api/proxy/admin/fetch', body: {} }]);
|
||||
assert.deepEqual(await b.evaluate('window.__unexpected'), []);
|
||||
});
|
||||
|
||||
test('without send the next-step button is disabled and nothing is posted', { timeout: 90000 }, async t => {
|
||||
const { f, b, wait } = await w15Fixture(t, { scenario: scenario(base, { send: false, proxyAdminSend: false }) });
|
||||
await b.navigate(f.base + '/#/settings/model-proxy');
|
||||
await wait('document.querySelector("#proxy-setup-go")?.dataset.next==="fetch"');
|
||||
assert.equal(await b.evaluate('document.querySelector("#proxy-setup-go").disabled'), true);
|
||||
await b.evaluate('document.querySelector("#proxy-setup-go").click()');
|
||||
assert.deepEqual(await writes(b), []);
|
||||
});
|
||||
|
||||
test('init sends generate by default; a pasted key on loopback is sent with its confirmation and the fields are cleared', { timeout: 90000 }, async t => {
|
||||
const { f, b, wait } = await w15Fixture(t, { scenario: { ...scenario(fetched), 'POST /api/proxy/admin/init': { body: { settings: { port: 18432 }, receipt: { op: 'init', time: T, port: 18432, generated: true } } } } });
|
||||
await b.navigate(f.base + '/#/settings/model-proxy');
|
||||
await wait('document.querySelector("#proxy-setup-go")?.dataset.next==="init"');
|
||||
await b.evaluate('document.querySelector("#proxy-setup-go").click()');
|
||||
await wait('document.querySelector("#proxy-setup-init-box").open===true');
|
||||
assert.equal(await b.evaluate('document.querySelector("#proxy-setup-paste-choice").hidden'), false, 'loopback page offers the paste choice');
|
||||
assert.equal(await b.evaluate('document.querySelector("#proxy-setup-paste").hidden'), true);
|
||||
await b.evaluate('(()=>{const f=document.querySelector("#proxy-setup-init");f.elements.port.value="18432";f.requestSubmit();})()');
|
||||
await wait('window.__writes.some(w=>w.path==="/api/proxy/admin/init")');
|
||||
await wait('document.querySelector("#proxy-setup-message").textContent.includes("with a generated key")');
|
||||
assert.deepEqual(await writes(b), [{ path: '/api/proxy/admin/init', body: { port: 18432, generate: true } }]);
|
||||
assert.match(await b.evaluate('document.querySelector("#proxy-setup-receipts").textContent'), /init/);
|
||||
await wait('!document.querySelector("#proxy-setup-init [data-setup-write]").disabled');
|
||||
await b.evaluate('(()=>{const f=document.querySelector("#proxy-setup-init");f.elements.keySource.value="paste";f.querySelector("[value=paste]").dispatchEvent(new Event("change"));f.elements.port.value="";f.elements.key.value="pa-browser-sentinel-0001";f.elements.confirm.value="pa-browser-sentinel-0002";f.requestSubmit();})()');
|
||||
await wait('document.querySelector("#proxy-setup-message").textContent.includes("proxy-key-mismatch")');
|
||||
assert.equal((await writes(b)).length, 1, 'a mismatch is refused in the page');
|
||||
assert.equal(await b.evaluate('document.querySelector("#proxy-setup-init").elements.key.value'), '', 'the key field is cleared on submit');
|
||||
await b.evaluate('(()=>{const f=document.querySelector("#proxy-setup-init");f.elements.key.value="pa-browser-sentinel-0001";f.elements.confirm.value="pa-browser-sentinel-0001";f.requestSubmit();})()');
|
||||
await wait('window.__writes.filter(w=>w.path==="/api/proxy/admin/init").length===2');
|
||||
assert.deepEqual((await writes(b))[1], { path: '/api/proxy/admin/init', body: { key: 'pa-browser-sentinel-0001', confirm: 'pa-browser-sentinel-0001' } });
|
||||
assert.equal(await b.evaluate('[...document.querySelectorAll("#proxy-setup-init input[type=password]")].every(i=>i.value==="")'), true);
|
||||
assert.equal(await b.evaluate('document.body.innerText.includes("pa-browser-sentinel")'), false);
|
||||
});
|
||||
|
||||
test('after a confirmed restart the next step clears the fence and shows the receipt', { timeout: 90000 }, async t => {
|
||||
const { f, b, wait } = await w15Fixture(t, { scenario: { ...scenario(fenced), 'POST /api/proxy/admin/fence/clear': { body: { cleared: true, receipt: { op: 'fence.clear', time: T, pid: 4242 } } } } });
|
||||
await b.navigate(f.base + '/#/settings/model-proxy');
|
||||
await wait('document.querySelector("#proxy-setup-go")?.dataset.next==="fence-clear"');
|
||||
assert.match(await b.evaluate('document.querySelector("#proxy-setup-state").textContent'), /new pid 4242/);
|
||||
await b.evaluate('document.querySelector("#proxy-setup-go").click()');
|
||||
await wait('document.querySelector("#proxy-setup-message").textContent.includes("Fence cleared")');
|
||||
assert.deepEqual(await writes(b), [{ path: '/api/proxy/admin/fence/clear', body: {} }]);
|
||||
assert.match(await b.evaluate('document.querySelector("#proxy-setup-receipts").textContent'), /fence\.clear.*pid: 4242/);
|
||||
});
|
||||
|
||||
test('a stopped proxy hides the account panels with a setup note, and starting the service brings them back', { timeout: 90000 }, async t => {
|
||||
const installed = { ...base, release: fetched.release, init: { initialized: true, partial: false, settings: { port: 18432 }, files: [] },
|
||||
unit: { unit: 'mosaic-proxy.service', installed: true, active: 'inactive' }, next: ['unit-start'] };
|
||||
const down = { 'GET /api/proxy/accounts': { status: 503, body: { error: 'proxy-down' } }, 'GET /api/proxy/keys': { status: 503, body: { error: 'proxy-down' } } };
|
||||
const { f, b, wait } = await w15Fixture(t, { scenario: { ...scenario(installed), ...down, 'POST /api/proxy/admin/unit/start': { body: { receipt: { op: 'unit.start', time: T, pid: 4242 } } } } });
|
||||
await b.navigate(f.base + '/#/settings/model-proxy');
|
||||
await wait('document.querySelector("#proxy-setup-go")?.dataset.next==="unit-start"');
|
||||
await wait('document.querySelector("#proxy-message").textContent.includes("not running yet")');
|
||||
assert.equal(await b.evaluate('document.querySelector("[aria-labelledby=proxy-apikey-title]").hidden'), true);
|
||||
await b.evaluate(`Object.assign(window.__scenario, {
|
||||
'GET /api/proxy/accounts': { body: { accounts: [] } }, 'GET /api/proxy/keys': { body: { keys: [] } },
|
||||
'/api/proxy/admin/status': ${JSON.stringify({ ...base, release: fetched.release, init: { initialized: true, partial: false, settings: { port: 18432 }, files: [] },
|
||||
unit: { unit: 'mosaic-proxy.service', installed: true, active: 'active', pid: 4242, port: 18432, listening: true }, next: ['accounts'] })} })`);
|
||||
await b.evaluate('document.querySelector("#proxy-setup-go").click()');
|
||||
await wait('window.__writes.some(w=>w.path==="/api/proxy/admin/unit/start")');
|
||||
await wait('document.querySelector("#proxy-message").textContent.includes("Accounts and client keys loaded")');
|
||||
assert.equal(await b.evaluate('document.querySelector("[aria-labelledby=proxy-apikey-title]").hidden'), false);
|
||||
assert.equal(await b.evaluate('document.querySelector("[aria-labelledby=proxy-accounts-title]").hidden'), false);
|
||||
});
|
||||
|
||||
test('an initialized proxy whose accounts read fails with another code also hides the panels until it answers', { timeout: 90000 }, async t => {
|
||||
const installed = { ...base, release: fetched.release, init: { initialized: true, partial: false, settings: { port: 18432 }, files: [] },
|
||||
unit: { unit: 'mosaic-proxy.service', installed: true, active: 'inactive' }, next: ['unit-start'] };
|
||||
const failing = { 'GET /api/proxy/accounts': { status: 503, body: { error: 'outcome-unknown' } }, 'GET /api/proxy/keys': { status: 503, body: { error: 'outcome-unknown' } } };
|
||||
const { f, b, wait } = await w15Fixture(t, { scenario: { ...scenario(installed), ...failing, 'POST /api/proxy/admin/unit/start': { body: { receipt: { op: 'unit.start', time: T, pid: 4242 } } } } });
|
||||
await b.navigate(f.base + '/#/settings/model-proxy');
|
||||
await wait('document.querySelector("#proxy-setup-go")?.dataset.next==="unit-start"');
|
||||
await wait('document.querySelector("#proxy-message").textContent.includes("the account panels return once the proxy answers")');
|
||||
assert.equal(await b.evaluate('document.querySelector("[aria-labelledby=proxy-accounts-title]").hidden'), true);
|
||||
await b.evaluate(`Object.assign(window.__scenario, {
|
||||
'GET /api/proxy/accounts': { body: { accounts: [] } }, 'GET /api/proxy/keys': { body: { keys: [] } },
|
||||
'/api/proxy/admin/status': ${JSON.stringify({ ...base, release: fetched.release, init: { initialized: true, partial: false, settings: { port: 18432 }, files: [] },
|
||||
unit: { unit: 'mosaic-proxy.service', installed: true, active: 'active', pid: 4242, port: 18432, listening: true }, next: ['accounts'] })} })`);
|
||||
await b.evaluate('document.querySelector("#proxy-setup-go").click()');
|
||||
await wait('document.querySelector("#proxy-message").textContent.includes("Accounts and client keys loaded")');
|
||||
assert.equal(await b.evaluate('document.querySelector("[aria-labelledby=proxy-accounts-title]").hidden'), false);
|
||||
});
|
||||
|
||||
test('a fence with no readable time names the --proxy-restarted path instead of a page restart', { timeout: 90000 }, async t => {
|
||||
const timeless = { ...fenced, fence: { set: true, invalid: true }, restart: null, next: ['unit-restart'] };
|
||||
const { f, b, wait } = await w15Fixture(t, { scenario: scenario(timeless) });
|
||||
await b.navigate(f.base + '/#/settings/model-proxy');
|
||||
await wait('document.querySelector("#proxy-setup-go")?.dataset.next==="unit-restart"');
|
||||
assert.match(await b.evaluate('document.querySelector("#proxy-setup-next p").textContent'), /no readable time.*mosaic proxy fence clear --proxy-restarted/);
|
||||
});
|
||||
|
||||
test('after a reload the receipts come from status, and an initialized proxy folds the init form', { timeout: 90000 }, async t => {
|
||||
const initialized = { ...base, release: fetched.release, init: { initialized: true, partial: false, settings: { port: 18432 }, files: [] },
|
||||
receipts: [{ op: 'fetch', time: T, version: '8.0.42' }, { op: 'init', time: T, generated: true, port: 18432 }], next: ['unit-install'] };
|
||||
const { f, b, wait } = await w15Fixture(t, { scenario: { ...scenario(initialized), 'POST /api/proxy/admin/unit/install': { body: { receipt: { op: 'unit.install', time: T } } } } });
|
||||
await b.navigate(f.base + '/#/settings/model-proxy');
|
||||
await wait('document.querySelector("#proxy-setup-go")?.dataset.next==="unit-install"');
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("#proxy-setup-receipts li strong")].map(s=>s.textContent)'), ['init', 'fetch'], 'newest first, from proxy-admin.jsonl');
|
||||
await b.evaluate('document.querySelector("#proxy-setup-init-box").open = true');
|
||||
await b.evaluate('document.querySelector("#proxy-setup-go").click()');
|
||||
await wait('window.__writes.some(w=>w.path==="/api/proxy/admin/unit/install")');
|
||||
await wait('document.querySelector("#proxy-setup-init-box").open===false');
|
||||
});
|
||||
@@ -3,6 +3,10 @@ import assert from 'node:assert/strict';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
import { createDevOwner } from '../src/dev-owner.mjs';
|
||||
import { createOrgAdmin } from '../src/org-admin.mjs';
|
||||
import { actionTrail } from '../src/action-trail.mjs';
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { browser } from './browser.mjs';
|
||||
import { close } from './fixture.mjs';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
@@ -202,7 +206,7 @@ test('organization dashboard presents sections and honest unavailable controls',
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-org-reload]").disabled'),true);
|
||||
});
|
||||
|
||||
test('each header control is present, reload is disabled, and restart is refused without the owner', async t => {
|
||||
test('each header control is present, reload is disabled, and restart and stop are refused without the owner', async t => {
|
||||
const owner = await startServer({ port: 0, sso: createDevOwner(), send: async () => ({}), organizations: workspaceOrgs(), orgAdmin: refusingAdmin(), log: () => {} }); t.after(() => close(owner));
|
||||
const readOnly = await startServer({ port: 0, sso: createDevOwner(), organizations: workspaceOrgs(), orgAdmin: refusingAdmin(), log: () => {} }); t.after(() => close(readOnly));
|
||||
const signedOut = await startServer({ port: 0, organizations: workspaceOrgs(), orgAdmin: refusingAdmin(), log: () => {} }); t.after(() => close(signedOut));
|
||||
@@ -210,27 +214,69 @@ test('each header control is present, reload is disabled, and restart is refused
|
||||
const wait = waitFor(b);
|
||||
const header = server => b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/ddigital/organization`).then(() => wait('document.querySelector("[data-org-dashboard] [data-org-settings]")')).then(() => b.evaluate(`(() => {
|
||||
const h = document.querySelector('[data-org-dashboard] .ws-orghead'), live = h.querySelector('[data-bus-restart]'), off = h.querySelector('[data-bus-restart-off]'), reload = h.querySelector('[data-org-reload]');
|
||||
const stop = h.querySelector('[data-bus-stop]'), stopOff = h.querySelector('[data-bus-stop-off]');
|
||||
return { live: live ? [live.textContent, live.disabled] : null, off: off ? [off.textContent, off.disabled, !!off.title] : null,
|
||||
stop: stop ? [stop.textContent, stop.disabled, stop.closest('.bus-restart') === live.closest('.bus-restart')] : null, stopOff: stopOff ? [stopOff.textContent, stopOff.disabled, !!stopOff.title] : null,
|
||||
reload: [reload.textContent, reload.disabled, reload.title, document.getElementById(reload.getAttribute('aria-describedby'))?.textContent] };
|
||||
})()`));
|
||||
const RELOAD = ['Reload organization file', true, 'Reload arrives with the cross-org update', 'Restart is a trailed human action; reload arrives with the cross-org update.'];
|
||||
// The owner on a Console started with --send gets console-restart.js's restart button.
|
||||
assert.deepEqual(await header(owner), { live: ['Restart bus', false], off: null, reload: RELOAD });
|
||||
// Stop bus sits beside it, in the same control, and is one press like Restart bus.
|
||||
assert.deepEqual(await header(owner), { live: ['Restart bus', false], off: null, stop: ['Stop bus', false, true], stopOff: null, reload: RELOAD });
|
||||
// Without --send, or with nobody signed in, the control is present and disabled, and the route refuses.
|
||||
const post = 'fetch("/api/orgs/ddigital/restart/bus", { method: "POST", headers: { "content-type": "application/json" }, body: "{}" }).then(async r => [r.status, (await r.json()).error])';
|
||||
assert.deepEqual(await header(readOnly), { live: null, off: ['Restart bus', true, true], reload: RELOAD });
|
||||
const stopPost = post.replace('/restart/bus', '/stop/bus');
|
||||
assert.deepEqual(await header(readOnly), { live: null, off: ['Restart bus', true, true], stop: null, stopOff: ['Stop bus', true, true], reload: RELOAD });
|
||||
assert.deepEqual(await b.evaluate(post), [403, 'read-only']);
|
||||
assert.deepEqual(await b.evaluate(stopPost), [403, 'read-only']);
|
||||
// With nobody signed in the organization list is not readable, so the dashboard reads nothing and
|
||||
// shows no controls at all.
|
||||
await b.navigate(`http://127.0.0.1:${signedOut.address().port}/#/orgs/ddigital/organization`);
|
||||
await wait('document.querySelector("[data-org-unavailable]")');
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-bus-restart], [data-bus-restart-off], [data-org-reload]")'), null);
|
||||
assert.equal(await b.evaluate('document.querySelector("[data-bus-restart], [data-bus-restart-off], [data-bus-stop], [data-bus-stop-off], [data-org-reload]")'), null);
|
||||
// With nobody signed in, the server refuses every per-org route before the admin hooks run.
|
||||
assert.deepEqual(await b.evaluate(post), [404, 'org-unavailable']);
|
||||
assert.deepEqual(await b.evaluate(stopPost), [404, 'org-unavailable']);
|
||||
// There is no reload route.
|
||||
assert.equal(await b.evaluate('fetch("/api/orgs/ddigital/reload", { method: "POST", headers: { "content-type": "application/json" }, body: "{}" }).then(r => r.ok)'), false);
|
||||
});
|
||||
|
||||
test('the owner presses Stop bus once: the bus stops, a second press says it was already stopped, and the restart icon stays on its button', async t => {
|
||||
// A fake bus behind the real route: busStop is the only thing a press reaches.
|
||||
let running = true; const stops = [];
|
||||
const bus = {
|
||||
busStatus: async (ctx, id) => ({ org: id, healthy: running, pid: running ? 4242 : null }),
|
||||
busStop: async (ctx, id) => { stops.push(id); const was = running; running = false; return was ? { stopped: true, business: id, pid: 4242 } : { stopped: false, reason: 'no host runs' }; },
|
||||
};
|
||||
const orgAdmin = createOrgAdmin({ service: { showOrg: async (ctx, id) => ({ id, revision: 1, doc: {} }) }, registry: { listOrgs: async () => [] }, bus, context: async () => ({ configDir: '/cfg', dataRoot: '/data', instance: 'test' }) });
|
||||
const root = mkdtempSync(join(tmpdir(), 'bus-stop-trail-')); t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const trail = actionTrail(join(root, 'console'));
|
||||
const server = await startServer({ port: 0, sso: createDevOwner(), send: async () => ({}), organizations: workspaceOrgs({ send: true }), orgAdmin, trail, log: () => {} }); t.after(() => close(server));
|
||||
const b = await browser({ machine: false }); t.after(() => b.close());
|
||||
const wait = waitFor(b);
|
||||
await b.navigate(`http://127.0.0.1:${server.address().port}/#/orgs/ddigital/organization`);
|
||||
await wait('document.querySelector("[data-org-dashboard] [data-bus-stop]")');
|
||||
// The icon is part of the restart button, so it moves with it whatever width the receipt takes.
|
||||
const icon = () => b.evaluate(`(() => { const r = document.querySelector('[data-org-dashboard] [data-bus-restart]'), i = r.querySelector('svg'); if (!i) return null; const a = r.getBoundingClientRect(), c = i.getBoundingClientRect(); return c.left >= a.left && c.right <= a.right && c.top >= a.top && c.bottom <= a.bottom; })()`);
|
||||
assert.equal(await icon(), true);
|
||||
await b.evaluate('document.querySelector("[data-org-dashboard] [data-bus-stop]").click()');
|
||||
await wait('/^Bus stopped at \\d/.test(document.querySelector("[data-org-dashboard] [data-bus-restart-receipt]")?.textContent || "")');
|
||||
assert.deepEqual(stops, ['ddigital']);
|
||||
assert.equal(await icon(), true, 'the repaint keeps the icon on the restart button');
|
||||
assert.deepEqual(await b.evaluate('[...document.querySelectorAll("[data-org-dashboard] .bus-buttons button")].map(x => [x.textContent, x.disabled])'), [['Restart bus', false], ['Stop bus', false]]);
|
||||
await b.evaluate('document.querySelector("[data-org-dashboard] [data-bus-stop]").click()');
|
||||
await wait('/^The bus was already stopped at \\d/.test(document.querySelector("[data-org-dashboard] [data-bus-restart-receipt]")?.textContent || "")');
|
||||
// The second press reached the route, which found no host and stopped nothing.
|
||||
assert.deepEqual(stops, ['ddigital']);
|
||||
assert.equal(await icon(), true);
|
||||
// Each press is the owner's trailed action.
|
||||
const lines = readFileSync(join(root, 'console', 'actions.jsonl'), 'utf8').split('\n').filter(Boolean).map(l => { const { at, by, ...e } = JSON.parse(l); return e; });
|
||||
assert.deepEqual(lines, [
|
||||
{ action: 'bus.stop', org: 'ddigital', outcome: 'requested', running: true }, { action: 'bus.stop', org: 'ddigital', outcome: 'done' },
|
||||
{ action: 'bus.stop', org: 'ddigital', outcome: 'requested', running: false }, { action: 'bus.stop', org: 'ddigital', outcome: 'done' },
|
||||
]);
|
||||
});
|
||||
|
||||
// Tab from the first sidebar link through the dashboard and return each stop's region and box.
|
||||
async function tabStops(b, start) {
|
||||
await b.evaluate(`(${start}).focus()`);
|
||||
@@ -268,8 +314,9 @@ test('keyboard focus follows visual order and survives a data refresh', async t
|
||||
assertVisualOrder(wide.filter(s => s.region !== 'other'), regions, '1440');
|
||||
const keys = wide.map(s => s.key);
|
||||
for (const key of ['org:forge', 'proj:ddigital:spring-launch', 'doc:spring-launch:docs/north-star.md', 'docs-all', 'agents-all', 'queue-open', 'set:Identity', 'set:Archive or delete']) assert.ok(keys.includes(key), `Tab never reaches ${key}`);
|
||||
// The disabled reload is not a tab stop; the restart button is.
|
||||
// The disabled reload is not a tab stop; the restart button is, and Stop bus follows it.
|
||||
assert.ok(!keys.includes('org-reload')); assert.ok(keys.includes('Restart bus'));
|
||||
assert.equal(keys.indexOf('Stop bus'), keys.indexOf('Restart bus') + 1);
|
||||
await b.viewport(390, 844);
|
||||
await wait('document.querySelector("[data-org-dashboard]")');
|
||||
const narrow = (await tabStops(b, 'document.querySelector("[data-org-dashboard] .ws-crumb a")')).filter(s => s.region !== 'other');
|
||||
|
||||
Reference in new issue
Block a user