Compare commits
55
Commits
org-defscan
..
sp3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1e65ff3f8b | ||
|
|
9cfb5f64fe | ||
|
|
2af12340ae | ||
|
|
c0a60ed3ae | ||
|
|
dd878cefcf | ||
|
|
2404729232 | ||
|
|
2912482cd0 | ||
|
|
bf65ea9802 | ||
|
|
2ad4b30ebd | ||
|
|
0e3ffd2133 | ||
|
|
f0f721c83e | ||
|
|
c44e4e28fd | ||
|
|
f5df067304 | ||
|
|
0b8fe202bc | ||
|
|
5f8079a972 | ||
|
|
666f4d6864 | ||
|
|
226e28d1e6 | ||
|
|
550bd084d0 | ||
|
|
8827905ba0 | ||
|
|
a559957085 | ||
|
|
6d0e9f89be | ||
|
|
d5aa963893 | ||
|
|
75f6d5533a | ||
|
|
f0e11cb342 | ||
|
|
35c125fc1f | ||
|
|
294149c20e | ||
|
|
6f18f3d015 | ||
|
|
a5f227e9fc | ||
|
|
ce066123b0 | ||
|
|
5dbd7e72d3 | ||
|
|
d23673cd3e | ||
|
|
ebe2af86de | ||
|
|
1947a056fb | ||
|
|
7ffe932250 | ||
|
|
900daf5213 | ||
|
|
f9dae1ebad | ||
|
|
f063158875 | ||
|
|
ff20efb819 | ||
|
|
cfe7535533 | ||
|
|
b1fc96f4ae | ||
|
|
967025f749 | ||
|
|
70504ddddb | ||
|
|
c3293ef4ef | ||
|
|
88a94ddd34 | ||
|
|
2d4b07e516 | ||
|
|
e3232b814f | ||
|
|
0ef6326084 | ||
|
|
ce4fe048bb | ||
|
|
63e2a574af | ||
|
|
d9023e28db | ||
|
|
c3fe6b77ad | ||
|
|
809b1b0100 | ||
|
|
4a9d9d4b89 | ||
|
|
87db40e0cd | ||
|
|
f3f33a6171 |
No files matched your search
+32
-2
@@ -309,10 +309,19 @@ 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>
|
||||
scripts/mosaic org role-templates <org> [--json]
|
||||
scripts/mosaic org hire|change <org> --name <name> --template <template> --harness <harness> --model <model> [--thinking <level>] [--level org|project|root] [--project <project>] --reason <text> [--json]
|
||||
scripts/mosaic org fire <org> --name <name> [--level org|project|root] [--project <project>] --reason <text> [--json]
|
||||
scripts/mosaic org autonomy <org> [--json]
|
||||
scripts/mosaic org autonomy <org> set --revision <rev> [--cross-role|--merge|--policy|--authority in|near|out] [--notices on|off] [--reverse-window-hours 1-168] [--authority-resolver <name>|""] [--json]
|
||||
scripts/mosaic org autonomy <org> review cross-role|merge|policy|authority [--json]
|
||||
scripts/mosaic tasks create --title <text> --requirement <REQ-X-N> [--project <name>] [--state <state>] [--assignees <role,...>] [--priority 0-5] [--due <iso>|none] --business <id> [--json]
|
||||
scripts/mosaic tasks edit task:<n> --expect <digest> [--title ...] [--state ...] [--assignees ...] [--priority ...] [--due ...] --business <id> [--json]
|
||||
scripts/mosaic tasks archive task:<n> --expect <digest> --business <id> [--json]
|
||||
scripts/mosaic talk <role> <text> [--wait SECONDS] [--business <id>]
|
||||
scripts/mosaic project create <name> [--repo <repo>] | list [--json]
|
||||
scripts/mosaic threads [--project <project>] [--json] [--tester]
|
||||
@@ -333,7 +342,11 @@ 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` (otherwise the Console answers `not-supervised`); it 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 hire form and the autonomy panel from a terminal (E2, #1873, P1-3). `mosaic org role-templates|hire|fire|change ORG` and `mosaic org autonomy ORG [set|review]` send the page's own request to the running Console's org route (`/api/orgs/ORG/decisions/hire/templates|start`, `/api/orgs/ORG/autonomy[/review]`), as `org reload` does, so the Console applies the same owner, `--send`, origin, body and live-session checks and the same bus verbs run (`hire.templates.console`, `hire.start.console`, `autonomy.get.console`, `autonomy.set.console`, `autonomy.review.console`). `hire` and `change` read the templates first and send the chosen template's name and sha256, as the form does. `autonomy set` needs the 64-hex `--revision` from `mosaic org autonomy ORG` and sends every setting the panel sends, with only the named ones changed; a stale revision is refused `revision-conflict`; an empty `--authority-resolver ""` clears the resolver, as emptying the panel's field does. These verbs need a named instance (`MOSAIC_CONFIG` set to `~/.config/mosaic-NAME/config.json`) whose Console runs with `--dev-local-owner`; the default instance is refused with exit 3. A Console refusal exits 3 with the Console's code and message; `outcome-unknown` exits 1; an unknown template exits 2. Every one of these verbs, reads included, refuses inside an agent run. `mosaic tasks create|edit|archive` are the task board's three writes: the arguments pass the board's own check (`workspace-tasks.mjs parseTaskWrite`) and go to the bus as the person (`tasks.create|edit|archive`, human transport), so the bus runs its human, field, digest and archive checks. A stale `--expect` is refused `task-conflict`; read the digest with `mosaic tasks --json`.
|
||||
|
||||
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, and `mosaic org restore`, which starts it (`org restore --no-bus` starts nothing and is not refused), and `mosaic org role-templates`, `hire`, `fire`, `change` and `autonomy` (E2). 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 +1358,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
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
# System-type toolsets at launch
|
||||
|
||||
Superseded 2026-11-12 by `docs/plans/2026-11-12_starter-packs-design.md` under Jason's starter-packs ruling; do not build this design.
|
||||
|
||||
Status: design only. Nothing here is built and no `roles/` or contract file is
|
||||
changed. Prepared 2026-11-10 against `refactor` `8630b8dfc` for phase P2 of
|
||||
#1873 (unified workspace). PRD requirement: REQ-SYS-2, "An agent working in a
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,91 @@
|
||||
// AU trusted host seam. No worker can choose a path, snapshot or inverse operation.
|
||||
import { mkdirSync, symlinkSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { prepareRevert, finishLand, dropLand } from '../../seat/src/working.mjs';
|
||||
import { isMosaicStack, runGate, gateEnv } from '../../seat/src/landing.mjs';
|
||||
const refuse=code=>{throw Object.assign(new Error(code),{code});};
|
||||
// countsAccount(name) answers what the named harness module declares (runtime harnessLevels);
|
||||
// the broker's authority budget check reads it here instead of naming a harness.
|
||||
export function autonomyHost({writer,dataRoot,business,hires,reload,processing,countsAccount=()=>null,env=process.env,gate={classify:isMosaicStack,run:runGate}}) {
|
||||
return {
|
||||
countsAccount,
|
||||
get:id=>{const r=writer.read(id);return {revision:r.revision,autonomy:r.document.autonomy??{}};},
|
||||
set:(id,args,actor)=>{
|
||||
const old=writer.read(id);if(old.revision!==args.revision)refuse('revision-conflict');
|
||||
const result=writer.write(id,{...old.document,autonomy:args.autonomy},{revision:args.revision,actor,detail:{verb:'autonomy.set'}});
|
||||
const live=reload(id);return {...result,restart:live===false};
|
||||
},
|
||||
reversible:cls=>['cross-role','merge'].includes(cls) || cls==='authority' && typeof hires?.reverse==='function',
|
||||
async reverse({business:id,decision,decisionClass,raised,effects,actor,beforeWrite,beforePublish,fire,stop,complete,relaunch,record}) {
|
||||
if(decisionClass==='authority') {
|
||||
if(!hires?.reverse)refuse('reversal-unavailable');
|
||||
let stopped;
|
||||
const result=await hires.reverse(id,decision,{actor,beforeWrite,beforePublish,fire,stop:async p=>{const r=await stop(p);stopped=p;return r;},relaunch,record});
|
||||
// The writer admitted publication within the window. Reload and relaunch
|
||||
// complete that committed inverse; crossing the deadline here cannot undo it.
|
||||
const receipt={committed:true,transaction:result.transaction??null,completion:stopped?'recorded':'not-needed',relaunch:result.relaunch?'pending':'not-needed'};
|
||||
try {if(stopped)complete?.(stopped,result);} catch {receipt.completion='failed';receipt.restart=true;}
|
||||
try {
|
||||
if(reload(id)===false)receipt.restart=true;
|
||||
if(result.relaunch && !receipt.restart){await relaunch(result.relaunch);receipt.relaunch='requested';}
|
||||
} catch {receipt.restart=true;if(result.relaunch)receipt.relaunch='failed';}
|
||||
return receipt;
|
||||
}
|
||||
const used=effects.filter(e=>e.operation!=='decision.raise' && !String(e.operation??'').startsWith('autonomy.'));
|
||||
// An unused approval has changed no task or policy. Reversing it revokes that permission.
|
||||
if(!used.length) {beforeWrite();return {permissionRevoked:true};}
|
||||
if(decisionClass!=='merge')refuse('reversal-unavailable');
|
||||
const landing=used.find(e=>e.action==='git.merge.protected' && typeof e.commit==='string');
|
||||
if(!landing)refuse('reversal-unavailable');
|
||||
const b=business(id),p=b.projects[landing.project];if(!p)refuse('project-not-found');
|
||||
const name=`reverse-${randomBytes(6).toString('hex')}`,dir=join(dataRoot,'autonomy-reversals',decision,name);
|
||||
mkdirSync(dir,{recursive:true,mode:0o700});
|
||||
const where={dataRoot,business:id,project:landing.project,env};
|
||||
let prepared;
|
||||
return processing.work(async scope=>{
|
||||
try {
|
||||
if(!await gate.classify(p.root,env))refuse('gate-missing');
|
||||
prepared=await prepareRevert({...where,commit:landing.commit,dir,name,decision});
|
||||
symlinkSync(join(p.root,'node_modules'),join(prepared.worktree,'node_modules'));
|
||||
const result=await gate.run({worktree:prepared.worktree,logs:dir,env:gateEnv(env),signal:scope.signal});
|
||||
scope.guard();if(!result.ok)refuse('gate-failed');beforeWrite();
|
||||
const receipt=await finishLand({...where,land:prepared,record:receipt=>{scope.guard();beforeWrite();record({...receipt,reverted:landing.commit});}});
|
||||
return {...receipt,reverted:landing.commit};
|
||||
} finally {if(prepared)await dropLand({...where,worktree:prepared.worktree});}
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// The runtime adapter is shared with integration tests, including key revocation order.
|
||||
export function authorityHost(service,{modelKeys,reload,proxyOrganization}) {
|
||||
return {
|
||||
...service,
|
||||
reverse:async(id,decision,caller)=>{
|
||||
const proposal=service.read(decision);
|
||||
if(!modelKeys || (proposal.level==='org' && proposal.op==='hire'))return service.reverse(id,decision,caller);
|
||||
const instance=`${id}/${proposal.level==='project'?`${proposal.name}.${proposal.project}`:proposal.name}`;
|
||||
let prepared=false;
|
||||
return service.reverse(id,decision,{
|
||||
...caller,
|
||||
beforeWrite:()=>{caller.beforeWrite();if(prepared){modelKeys.completeRevoke(instance);prepared=false;}caller.beforeWrite();},
|
||||
stop:async(p,guard)=>{await modelKeys.prepareRevoke(instance,guard);prepared=true;return caller.stop(p);},
|
||||
});
|
||||
},
|
||||
apply:async(id,decision,caller)=>{
|
||||
const proposal=service.read(decision);
|
||||
const publish=async()=>{
|
||||
let approved=caller;
|
||||
if(modelKeys && proposal.op==='fire' && proposal.level!=='org') {
|
||||
const instance=`${id}/${proposal.level==='project'?`${proposal.name}.${proposal.project}`:proposal.name}`;
|
||||
await modelKeys.prepareRevoke(instance,()=>caller.beforeWrite?.());
|
||||
approved={...caller,beforeWrite:()=>{caller.beforeWrite?.();modelKeys.completeRevoke(instance);caller.beforeWrite?.();}};
|
||||
}
|
||||
const result=await service.apply(id,decision,approved);reload(id);return result;
|
||||
};
|
||||
if(proxyOrganization && proposal.level==='org' && proposal.op==='hire')return proxyOrganization.hire(id,proposal.name,()=>service.check(id,decision,caller),publish);
|
||||
return publish();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
// AU reads only immutable evidence. These operations are reserved broker writes.
|
||||
export function autonomyEvent(q, business, decision, operations) {
|
||||
const placeholders=operations.map(()=>'?').join(',');
|
||||
const row=q.get(`SELECT * FROM events WHERE business=? AND kind='action.allowed' AND json_extract(body,'$.decision')=? AND json_extract(body,'$.operation') IN (${placeholders}) ORDER BY seq DESC LIMIT 1`,business,decision,...operations);
|
||||
return row?{...row,body:JSON.parse(row.body)}:null;
|
||||
}
|
||||
export function decisionResolution(q, business, decision) {
|
||||
const terminal=q.get("SELECT * FROM decision_events WHERE decision=? AND op IN ('withdrawn','expired') ORDER BY seq DESC LIMIT 1",decision);
|
||||
if(terminal)return terminal;
|
||||
const overlay=autonomyEvent(q,business,decision,['autonomy.resolved','autonomy.declined']);
|
||||
if(overlay)return overlay.body.operation==='autonomy.declined'?null:{...overlay.body.resolution,seq:overlay.seq};
|
||||
return q.get("SELECT * FROM decision_events WHERE decision=? AND op IN ('resolved','withdrawn','expired') ORDER BY seq DESC LIMIT 1",decision);
|
||||
}
|
||||
+414
-48
@@ -1,3 +1,5 @@
|
||||
import { autonomyEvent, decisionResolution } from './autonomy-state.mjs';
|
||||
import { autonomyClass, authorityBudgetCovered, checkAutonomy, decisionAutonomy, resolverSubject } from '../../organization/src/autonomy.mjs';
|
||||
import { ownRails } from '../../organization/src/rails.mjs';
|
||||
import { refusalGuidance } from './refusals.mjs';
|
||||
import { PROXY_PAGE_VERBS, PROXY_PAGE_CODES } from './proxy-page-vocabulary.mjs';
|
||||
@@ -54,6 +56,7 @@ export const ACTIONS = Object.freeze([
|
||||
'prd.approve',
|
||||
'decision.resolve.technical',
|
||||
'decision.resolve.delivery',
|
||||
'decision.resolve.authority',
|
||||
// Row 285 (C2-08b, #1790): routed under PRD 1.16 REQ-WAIT-3 and REQ-LIB-6, below.
|
||||
'library.write',
|
||||
]);
|
||||
@@ -83,6 +86,7 @@ const ROUTED = new Set([
|
||||
'prd.approve',
|
||||
'decision.resolve.technical',
|
||||
'decision.resolve.delivery',
|
||||
'decision.resolve.authority',
|
||||
// PRD 1.7 REQ-COS-3 (row 194, #1692): the chief's hire and fire, within the bounds the broker checks.
|
||||
'role.hire',
|
||||
'role.fire',
|
||||
@@ -174,7 +178,7 @@ export const USER_VERBS = Object.freeze(['project.list', 'thread.create', 'threa
|
||||
'thread.share', 'thread.unshare', 'thread.shares']);
|
||||
// Row 252 (#1753, design sections 2-3): the only verbs a Console owner cap reaches. Every other verb
|
||||
// refuses it human-required before its own checks, and these refuse every other cap.
|
||||
export const CONSOLE_VERBS = Object.freeze(['system-types.console', 'project.remote.get.console', 'project.remote.set.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'hire.preview.console', 'hire.resolve.console', 'hire.start.console', 'hire.templates.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
|
||||
export const CONSOLE_VERBS = Object.freeze(['system-types.console', 'project.remote.get.console', 'project.remote.set.console', 'autonomy.get.console', 'autonomy.set.console', 'autonomy.review.console', 'decision.confirm.console', 'decision.reverse.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'hire.preview.console', 'hire.resolve.console', 'hire.start.console', 'hire.templates.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
|
||||
// Task 3 (#1839): and the owner's intake controls. The reads and writes are the same lifecycle methods the PM's
|
||||
// unsuffixed verbs reach, with the owner's verified (provider, iss, sub) as the only caller; the PM-only
|
||||
// request.question and the PM's own reads keep their unsuffixed names and refuse a console cap.
|
||||
@@ -525,6 +529,7 @@ const name = (x) => text(x, 200) && !/[\r\n]/.test(x);
|
||||
|
||||
// The checks a business document passes at boot and at every reload.
|
||||
function checkBusiness(name, b) {
|
||||
checkAutonomy(b.autonomy);
|
||||
// Row 177: a human named `tester` would share the tester's threads.
|
||||
if (!id(name) || b.id !== name || !id(b.human) || b.human === 'tester' || !object(b.roles) || !object(b.arbiters))
|
||||
fail('invalid-business');
|
||||
@@ -597,9 +602,11 @@ export class Broker {
|
||||
#builtinBinding;
|
||||
#organization;
|
||||
#hires;
|
||||
#autonomyHost;
|
||||
#pluginService;
|
||||
#credentialVerbs;
|
||||
#proxyInit;
|
||||
#proxyFenceClear;
|
||||
#proxyPage;
|
||||
#project;
|
||||
#library;
|
||||
@@ -635,6 +642,9 @@ export class Broker {
|
||||
// Row 236 (#436b): giteaGrant and giteaKeys from gitea-grant.mjs, or null, and the remote halves of
|
||||
// section 5's restart rows and of revokes a restart cut short, for reconcileGitea.
|
||||
#giteaGrant;
|
||||
#copyGrant;
|
||||
// #1873 CB3: copy-launch.mjs's gate, or null. It answers for a copy the boot left needing a binding.
|
||||
#copyLaunch;
|
||||
#giteaKeys;
|
||||
#giteaUndo = [];
|
||||
// Row 281 (#1786, d6 section 3b): each tracked Gitea promise and the business/agent it acts for, or
|
||||
@@ -660,7 +670,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, 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, copyGrant = null, copyLaunch = 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');
|
||||
@@ -684,14 +694,18 @@ export class Broker {
|
||||
this.#credentialStatus = credentialStatus;
|
||||
this.#organization = organization;
|
||||
this.#hires = hires;
|
||||
this.#autonomyHost = autonomy;
|
||||
this.#pluginService = pluginService;
|
||||
// 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;
|
||||
this.#giteaGrant = giteaGrant;
|
||||
this.#copyGrant = copyGrant;
|
||||
this.#copyLaunch = copyLaunch;
|
||||
this.#giteaKeys = giteaKeys;
|
||||
this.#access = access;
|
||||
this.#accessStamp = accessStamp;
|
||||
@@ -1555,15 +1569,32 @@ export class Broker {
|
||||
// #1873 A3: a root or project copy the boot left unbound or refused isn't in `roles`. Its refusal
|
||||
// names the agent and the missing services or the reason, never a value (organization
|
||||
// agent-copies.mjs copyRefusal; inlined because this module also loads in the sandbox client).
|
||||
#copyRefused(b, instance) {
|
||||
#copyRefused(b, instance, session = null) {
|
||||
const copy = b.copies && Object.hasOwn(b.copies, instance) ? b.copies[instance] : null;
|
||||
if (b.roles[instance]?.unbound?.length) throw new BusError('credential-unbound', `${instance} has no binding for ${b.roles[instance].unbound.join(', ')}`);
|
||||
if(copy?.level==='root') {
|
||||
const refusal=this.#hires?.rootRefusal ? this.#hires.rootRefusal(copy.name) : this.#hires?.rootActive && !this.#hires.rootActive(copy.name) ? 'instance-fired' : null;
|
||||
if(refusal)fail(refusal);
|
||||
}
|
||||
// #1873 CB3: a copy bound at boot holds only while the gate's launch authority does, and a run
|
||||
// only while it is the authority bindLaunch admitted for it. Without a gate it is unavailable.
|
||||
if (b.roles[instance]?.copyBound?.length) {
|
||||
if (!this.#copyLaunch) fail('credential-unavailable');
|
||||
const { authority } = this.#copyLaunch.authority(b.id, instance);
|
||||
if (session && session.copyAuthority !== authority) fail('copy-authority-changed');
|
||||
}
|
||||
if (!copy || copy.state === 'instance') return;
|
||||
if (copy.state === 'unbound') throw new BusError('credential-unbound', `${instance} has no binding in ${b.id} for ${copy.missing.join(', ')}`);
|
||||
if (copy.state === 'unbound') {
|
||||
// A binding approved after boot is restart-required, a failed boot check keeps its code.
|
||||
if (this.#copyLaunch) {
|
||||
try {
|
||||
this.#copyLaunch.authority(b.id, instance);
|
||||
} catch (e) {
|
||||
if (!(e instanceof BusError) || e.code !== 'credential-unbound') throw e;
|
||||
}
|
||||
}
|
||||
throw new BusError('credential-unbound', `${instance} has no binding in ${b.id} for ${copy.missing.join(', ')}`);
|
||||
}
|
||||
throw new BusError(copy.project === null ? 'copy-refused' : 'project-refused', `${instance} can't launch in ${b.id}: ${copy.reason}`);
|
||||
}
|
||||
// #1873 A3: why `instance` can't work in code project `repo`, as a BusError, or null when it can
|
||||
@@ -1849,7 +1880,7 @@ export class Broker {
|
||||
if (s.tester) fail('tester-refused');
|
||||
if (s.reader) fail('read-only');
|
||||
if (s.human) fail('agent-required');
|
||||
this.#copyRefused(this.#business(s.business),s.role);
|
||||
this.#copyRefused(this.#business(s.business),s.role,s);
|
||||
const h = this.#own(s);
|
||||
if (h?.op !== 'claim' || (s.thread ? h.generation !== this.#runGeneration(s) : h.holder_run !== s.run))
|
||||
fail(this.#launcherReleased.has(s.business + ':' + s.run) ? 'launcher-released'
|
||||
@@ -2110,7 +2141,7 @@ export class Broker {
|
||||
#launchProof(record) {
|
||||
keys(
|
||||
record,
|
||||
['business', 'role', 'run', 'harness', 'address', 'pid', 'startTime', 'thread', 'session', 'provider', 'model', 'account', 'transcript'],
|
||||
['business', 'role', 'run', 'harness', 'address', 'pid', 'startTime', 'thread', 'session', 'provider', 'model', 'account', 'transcript', 'copyAuthority', 'revision'],
|
||||
['business', 'role', 'run', 'harness'],
|
||||
);
|
||||
const b = this.#business(identifier(record.business));
|
||||
@@ -2175,6 +2206,14 @@ export class Broker {
|
||||
if (!old && !this.#harnessOn(record.business, record.harness)) fail('invalid-harness');
|
||||
if (!old && record.thread !== undefined && this.#archived(record.business, this.#threadRow(session, record.thread).project)) fail('project-archived');
|
||||
if (!old && record.thread !== undefined && this.#threadLost(record.business, record.thread)) fail('thread-revoked');
|
||||
// #1873 CB3: a bound copy's run carries the launch authority its prepare and final admission
|
||||
// checked, and the organization file revision (A3) the launch resolved; both must still hold
|
||||
// now, and the session keeps the authority for every later use.
|
||||
if (launchRole.copyBound?.length) {
|
||||
if (!this.#copyLaunch) fail('credential-unavailable');
|
||||
if (typeof record.copyAuthority !== 'string') fail('copy-authority-changed');
|
||||
this.#copyLaunch.admit(record.business, record.role, record.copyAuthority, record.revision ?? null);
|
||||
} else if (record.copyAuthority !== undefined || record.revision !== undefined) fail('invalid-request');
|
||||
if (!old) this.#store.transaction(() => this.#event(session, 'session.launched', body, record.run));
|
||||
this.#runs.set(key, session);
|
||||
return this.#cap(session);
|
||||
@@ -3341,24 +3380,26 @@ export class Broker {
|
||||
const job = (async () => {
|
||||
try {
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
await run();
|
||||
return await run();
|
||||
} finally {
|
||||
this.#revoking.delete(key);
|
||||
}
|
||||
})();
|
||||
this.#grantJobs.add(job);
|
||||
job.catch(() => {}).finally(() => this.#grantJobs.delete(job));
|
||||
return job;
|
||||
}
|
||||
// Section 2, the fire's job: step G, then step B and step C. A step G refusal is section 4's record. A
|
||||
// step B or C failure is fire.failed (d6, C1). Step C records the authority the verb checked, with no
|
||||
// holder check, since the chief's run may have ended since.
|
||||
async #fireJob(sc, args, authority, write, job, set) {
|
||||
async #fireJob(sc, args, authority, write, job, set,propagate=false) {
|
||||
const name = args.name;
|
||||
let out;
|
||||
try {
|
||||
out = await this.#stepG(sc.business, name, set, { job, reason: args.reason, by: sc.role, flag: { fire: true } });
|
||||
} catch (e) {
|
||||
this.#store.transaction(() => this.#grantRefused(sc.business, name, 'instance.fire', job, e.grantRefusal, sc.role));
|
||||
if(propagate)throw e;
|
||||
return;
|
||||
}
|
||||
let step = 'B';
|
||||
@@ -3370,6 +3411,7 @@ export class Broker {
|
||||
else this.#recordAuthority(sc, 'role.fire', authority, { reason: args.reason, transaction: result.transaction ?? null });
|
||||
this.#event(sc, 'action.allowed', { action: 'fire.done', transaction: result.transaction ?? null, job, grants: out.grants, grantsLeft: out.grantsLeft }, name);
|
||||
});
|
||||
return result;
|
||||
} catch (e) {
|
||||
const code = e instanceof BusError ? e.code : 'outcome-unknown';
|
||||
const list = out.grants.map((g) => g.grant).join(', ') || 'none';
|
||||
@@ -3382,6 +3424,7 @@ export class Broker {
|
||||
`Fire of ${name}: job ${job} revoked its grants (${list}) but stopped at step ${step} (${code}).${tokens} The instance is still firing. Run the fire again once ${code} is cleared.`,
|
||||
);
|
||||
});
|
||||
if(propagate)throw e;
|
||||
}
|
||||
}
|
||||
// Section 2, the delete's job: step G, then grants.revoked.done and the end message.
|
||||
@@ -4799,10 +4842,32 @@ export class Broker {
|
||||
};
|
||||
}
|
||||
#closed(id) {
|
||||
return this.#store.get(
|
||||
`SELECT * FROM decision_events WHERE decision=? AND op IN ${CLOSED} ORDER BY seq DESC LIMIT 1`,
|
||||
id,
|
||||
);
|
||||
const d=this.#store.get('SELECT business FROM decisions WHERE id=?',id);
|
||||
return d ? decisionResolution(this.#store,d.business,id) : null;
|
||||
}
|
||||
#autonomyLevel(d) {
|
||||
if(d.action==='prd.approve' || d.route_to==='human' || d.class==='within-role')return 'out of the loop';
|
||||
const current=decisionAutonomy(autonomyClass(d.action),this.#business(d.business).autonomy);
|
||||
const raised=this.#raised(d.business,d.id)?.autonomyLevel??'out of the loop';
|
||||
const levels=['in the loop','near the loop','out of the loop'];
|
||||
return levels[Math.min(levels.indexOf(current),levels.indexOf(raised))];
|
||||
}
|
||||
#autonomyOwner(s) {
|
||||
if(!s.console || !s.human || s.tester || s.reader || s.hireMembership || s.human!==this.#business(s.business).human)fail('human-required');
|
||||
}
|
||||
#resolutionEvent(s,d,choice) {
|
||||
const resolution={decision:d.id,op:'resolved',by:s.run,choice,via:'broker',at:this.#now()};
|
||||
if(this.#autonomyLevel(d)==='in the loop') {
|
||||
this.#event(s,'action.allowed',{operation:'autonomy.pending',decision:d.id,resolution},d.id);
|
||||
return {id:d.id,op:'pending',choice};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
#autonomyNotice(s,d,resolution) {
|
||||
if(this.#autonomyLevel(d)!=='near the loop' || resolution.choice!==this.#raised(d.business,d.id)?.approvalChoice)return;
|
||||
const hours=this.#business(d.business).autonomy?.reverseWindowHours??24;
|
||||
this.#event(s,'action.allowed',{operation:'autonomy.notice',decision:d.id,decisionClass:autonomyClass(d.action),resolution,
|
||||
reverseUntil:new Date(Date.parse(resolution.at)+hours*3600000).toISOString()},d.id);
|
||||
}
|
||||
// Row 58 (#1547): the 'tasks' rows, each with the task's priority and requirement. Priority
|
||||
// is the current row's; a tombstone has none. The requirement is the first task.created for
|
||||
@@ -5023,6 +5088,8 @@ export class Broker {
|
||||
const json = JSON.stringify(fields);
|
||||
if (Buffer.byteLength(json) > STORE_TASK_BYTES) fail('invalid-request');
|
||||
this.#secretCheck(fields);
|
||||
const near=kind==='action.allowed' && body?.decision && this.#autonomyLevel(this.#decision(s,body.decision))==='near the loop';
|
||||
if(near && current)this.#event(s,'action.allowed',{operation:'autonomy.before',decision:body.decision,target:ref,fields:current.fields},ref);
|
||||
const event = this.#event(s, kind, { ...body, task_ref: ref, tracker: 'store', op, verb }, ref);
|
||||
this.#store.run(
|
||||
'INSERT INTO store_tasks(at,business,task,revision,op,verb,by_role,by_run,by_human,event,fields) VALUES(?,?,?,?,?,?,?,?,?,?,?)',
|
||||
@@ -5038,7 +5105,9 @@ export class Broker {
|
||||
event,
|
||||
json,
|
||||
);
|
||||
return op === 'delete' ? { task_ref: ref, deleted: true, event } : this.#storeCurrent(s.business, ref);
|
||||
const result=op === 'delete' ? { task_ref: ref, deleted: true, event } : this.#storeCurrent(s.business, ref);
|
||||
if(near && current)this.#event(s,'action.allowed',{operation:'autonomy.after',decision:body.decision,target:ref,digest:result.digest},ref);
|
||||
return result;
|
||||
}
|
||||
// U4a: the store module's one write path for a role, as authorize() and recordTask() are the
|
||||
// Vikunja adapter's. One transaction holds the holder check, the authority (a decision is spent here
|
||||
@@ -5122,7 +5191,9 @@ export class Broker {
|
||||
context.target !== target
|
||||
)
|
||||
fail('decision-mismatch');
|
||||
if(!r && autonomyEvent(this.#store,s.business,d.id,['autonomy.pending','autonomy.declined','autonomy.confirmed'])?.body.operation==='autonomy.pending')fail('confirmation-required');
|
||||
if (r?.op !== 'resolved' || r.choice !== context.approvalChoice) fail('decision-not-approved');
|
||||
this.#effectiveDecision(d, r);
|
||||
if (action === 'role.revoke') this.#protectedEnd(s, target, r);
|
||||
// A thread instance has no role claim, so both sides are null for it (row 155, #1649): the raise
|
||||
// records `?? null` and the current holder reads undefined.
|
||||
@@ -5147,14 +5218,25 @@ export class Broker {
|
||||
);
|
||||
return evidence ? JSON.parse(evidence.body) : null;
|
||||
}
|
||||
#effectiveDecision(d, r) {
|
||||
if (d.route_to === 'human' || d.class==='within-role' || r.via !== 'broker') return;
|
||||
const b = this.#business(d.business);
|
||||
if (d.raised_by_role === d.route_to || resolverSubject(b, d.route_to, this.#raised(d.business,d.id)?.target)) fail('not-resolver');
|
||||
this.#checkResolver({business:d.business,role:d.route_to},d);
|
||||
if(autonomyEvent(this.#store,d.business,d.id,['autonomy.reverse.consume']))fail('decision-reversed');
|
||||
if(this.#autonomyLevel(d)==='in the loop') {
|
||||
const confirmed=autonomyEvent(this.#store,d.business,d.id,['autonomy.confirmed']);
|
||||
if(!confirmed || confirmed.body.resolution.at!==r.at || confirmed.body.resolution.by!==r.by)fail('confirmation-required');
|
||||
}
|
||||
}
|
||||
#checkResolver(s, d) {
|
||||
const b = this.#business(s.business), role = b.roles[s.role];
|
||||
const domain = d.action === 'prd.approve' ? 'technical' : DECISION_DOMAINS[d.action] ?? this.#raised(s.business, d.id)?.domain;
|
||||
const domain = ['role.hire','role.fire','role.change'].includes(d.action) ? 'authority' : d.action === 'prd.approve' ? 'technical' : DECISION_DOMAINS[d.action] ?? this.#raised(s.business, d.id)?.domain;
|
||||
// Pre-RR evidence has no domain. Assignments may have changed since raise,
|
||||
// so require both possible rights rather than infer one from today's file.
|
||||
const domains = domain === undefined ? ['technical', 'delivery'] : [domain];
|
||||
for (const kind of domains) {
|
||||
if (!['technical', 'delivery'].includes(kind)) fail('not-resolver');
|
||||
if (!['technical', 'delivery','authority'].includes(kind)) fail('not-resolver');
|
||||
const right = `decision.resolve.${kind}`;
|
||||
if (!role?.authority?.withinRole.includes(right)) {
|
||||
throw new BusError('not-resolver', `instance ${s.role}, role ${role?.definition ?? s.role}, lacks ${right}`);
|
||||
@@ -5165,7 +5247,7 @@ export class Broker {
|
||||
// consumed by an executor.
|
||||
#spent(business, decision) {
|
||||
return !!this.#store.get(
|
||||
"SELECT 1 FROM events WHERE business=? AND kind='action.allowed' AND json_extract(body,'$.decision')=? AND json_extract(body,'$.operation') IS NOT 'decision.raise' LIMIT 1",
|
||||
"SELECT 1 FROM events WHERE business=? AND kind='action.allowed' AND json_extract(body,'$.decision')=? AND json_extract(body,'$.operation') IS NOT 'decision.raise' AND COALESCE(json_extract(body,'$.operation'),'') NOT LIKE 'autonomy.%' LIMIT 1",
|
||||
business,
|
||||
decision,
|
||||
);
|
||||
@@ -5199,6 +5281,9 @@ export class Broker {
|
||||
const s = this.#session(cap);
|
||||
return this.#store.transaction(() => {
|
||||
const result = this.#consumeAuthority(s, action, context);
|
||||
// External adapters have no host-owned prior task/policy snapshot. Near-loop work
|
||||
// must use the store mutation path, which commits its inverse evidence atomically.
|
||||
if(result.decision && this.#autonomyLevel(this.#decision(s,result.decision))==='near the loop' && ['cross-role','policy'].includes(autonomyClass(action)))fail('reversal-unavailable');
|
||||
if (action === 'task.close') this.#closeVerdict(s, context.target, context.verdict);
|
||||
return result;
|
||||
});
|
||||
@@ -5430,6 +5515,20 @@ export class Broker {
|
||||
}
|
||||
// Design D5: the only key the git socket's gitea token use gets is the agent checkPublish checked
|
||||
// (#agent: a run holding its claim, never a tester, reader or human). Trusted host only.
|
||||
// #1873 CB3: who a copy credential call is for, from the capability's own live run: business,
|
||||
// role, run and the launch authority bindLaunch admitted. Never a human, tester or reader, never an
|
||||
// ended run, and only while the copy's authority is still the one the run was admitted on.
|
||||
// Trusted host only; no socket verb returns it.
|
||||
copySession(cap) {
|
||||
const s = this.#session(cap);
|
||||
if (s.human || s.tester || s.reader) fail('copy-request-refused');
|
||||
const key = s.business + ':' + s.run;
|
||||
if (this.#runs.get(key) !== s || this.#ended.has(key)) fail('run-ended');
|
||||
const b = this.#business(s.business);
|
||||
if (!b.roles[s.role]?.copyBound?.length || typeof s.copyAuthority !== 'string') fail('copy-request-refused');
|
||||
this.#copyRefused(b, s.role, s);
|
||||
return Object.freeze({ business: s.business, role: s.role, run: s.run, copyAuthority: s.copyAuthority });
|
||||
}
|
||||
publishSession(ticket) {
|
||||
const { s } = this.#publish(ticket);
|
||||
return Object.freeze({ business: s.business, role: s.role });
|
||||
@@ -5711,6 +5810,7 @@ export class Broker {
|
||||
this.#agent(s);
|
||||
if (['human.input', 'launch.revoked', 'launch.restored', ...REVIEW_KINDS].includes(kind)) fail('reserved-event');
|
||||
if (kind === 'action.allowed' && ['git.push.working', MERGE, 'processing.halt', 'processing.resume', 'processing.stop'].includes(body?.action)) fail('reserved-event');
|
||||
if (kind === 'action.allowed' && (String(body?.operation??'').startsWith('autonomy.') || body?.operation==='decision.raise' || String(body?.operation??'').startsWith('hire.'))) fail('reserved-event');
|
||||
if (kind === 'action.allowed' && grantName(body)) fail('reserved-event');
|
||||
return this.#event(s, kind, body, subject);
|
||||
});
|
||||
@@ -5917,6 +6017,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;
|
||||
@@ -5937,19 +6050,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') {
|
||||
@@ -6096,7 +6214,19 @@ export class Broker {
|
||||
return this.#withLast(cap, s, request);
|
||||
}
|
||||
if (CREDENTIAL_VERBS.includes(request.verb)) return this.#requestCredential(s, request.verb, structuredClone(args));
|
||||
if(['autonomy.get.console','autonomy.set.console','autonomy.review.console','decision.reverse.console'].includes(request.verb)) {
|
||||
this.#autonomyOwner(s);
|
||||
return this.#autonomyConsole(s,request.verb,structuredClone(args)).catch(e=>{throw this.#refused(s,e instanceof BusError?e:new BusError(e.code??'outcome-unknown'),request);});
|
||||
}
|
||||
if (s.hireMembership && !['hire.preview.console','hire.resolve.console','hire.start.console','hire.templates.console'].includes(request.verb)) fail('human-required');
|
||||
if(request.verb==='decision.resolve' && !s.human) {
|
||||
if(s.tester)fail('tester-refused');
|
||||
keys(args,['id','choice','note'],['id','choice']);identifier(args.id);
|
||||
}
|
||||
if(request.verb==='decision.resolve' && !s.human && ['role.hire','role.fire','role.change'].includes(this.#decision(s,args.id).action)) {
|
||||
const d=this.#decision(s,args.id);
|
||||
if(d.route_to!=='human')return this.#resolveAuthority(s,args).catch(e=>{throw this.#refused(s,e instanceof BusError?e:new BusError(e.code??'outcome-unknown'),request);});
|
||||
}
|
||||
if(request.verb==='hire.templates.console') {
|
||||
keys(args,[]);const caller=this.#hireCaller(s);
|
||||
if(!caller.rights.includes('org.hire'))fail('right-required');
|
||||
@@ -6283,6 +6413,11 @@ export class Broker {
|
||||
if (a.token !== undefined) string(a.token, 16384);
|
||||
if (a.date !== undefined) string(a.date, 10);
|
||||
if (a.reason !== undefined) string(a.reason, 500);
|
||||
// #1873 CB2: an issue for a copy this bus booted with goes to the copy verifier (copy-grant.mjs).
|
||||
// The boot's copies view leaves out a name the file's own roles hold; a copy that needs no
|
||||
// credential joins the roles but is still a copy. Any other unknown name answers credential-unknown.
|
||||
const booted = this.#business(s.business);
|
||||
if (verb === 'credentials.issue' && this.#copyGrant && booted.copies && Object.hasOwn(booted.copies, a.agent)) return this.#issueCopyGrant(s, a);
|
||||
if (!Object.hasOwn(this.#business(s.business).roles, a.agent)) fail('credential-unknown');
|
||||
// A running agent keeps its tokens until its runs end: delete waits for every claim to end.
|
||||
if (verb === 'credentials.delete' && (this.#holder(s.business, a.agent)?.op === 'claim' || this.#threadHolders(s.business, a.agent).length))
|
||||
@@ -6313,6 +6448,127 @@ export class Broker {
|
||||
}
|
||||
return result;
|
||||
}
|
||||
// #1873 CB2: owner only, as every binding decision (design Q1): not the tester, a reader or an
|
||||
// org.hire member. The recheck runs after the remote work and again under the writer's locks.
|
||||
#copyOwner(s) {
|
||||
if (s.tester || s.reader || !s.human) fail(s.tester ? 'tester-refused' : 'human-required');
|
||||
if (s.hireMembership ? s.hireMembership.owner !== true : s.human !== this.#business(s.business).human) fail('human-required');
|
||||
}
|
||||
// #1873 CB3: a write that changes a copy's binding or grant runs inside the gate's fence, so no
|
||||
// launch is admitted and no call goes out while it runs; an unknown outcome keeps the fence.
|
||||
#copyFence(business, instance, fn) {
|
||||
return this.#copyLaunch && typeof instance === 'string' ? this.#copyLaunch.fence(business, instance, fn) : Promise.resolve().then(fn);
|
||||
}
|
||||
#issueCopyGrant(s, a) {
|
||||
this.#copyOwner(s);
|
||||
const passed = (e) => e instanceof BusError && (e.code.startsWith('credential-') || ['copy-unavailable', 'human-required',
|
||||
'definition-holds-credential', 'definition-scan-unreadable'].includes(e.code));
|
||||
return this.#copyFence(s.business, a.agent, () => this.#copyGrant(s.business, a, { actor: s.human, owner: true, recheck: () => this.#copyOwner(s) }))
|
||||
.then((result) => {
|
||||
this.#secretCheck(result);
|
||||
if (JSON.stringify(result).includes(a.token)) fail('credential-leak');
|
||||
return result;
|
||||
}, (e) => {
|
||||
if (passed(e)) throw e;
|
||||
if (['revision-conflict', 'decision-stale'].includes(e?.code) || e?.code?.startsWith('organization-')) fail(e.code);
|
||||
if (e?.name === 'BusinessError') fail('invalid-request');
|
||||
throw new BusError('outcome-unknown');
|
||||
});
|
||||
}
|
||||
async #autonomyConsole(s,verb,a) {
|
||||
if(verb==='autonomy.get.console') {
|
||||
keys(a,[]);
|
||||
if(!this.#autonomyHost?.get)fail('not-configured');
|
||||
const current=await this.#autonomyHost.get(s.business);
|
||||
return {...current,levels:Object.fromEntries(['cross-role','merge','policy','authority'].map(k=>[k,decisionAutonomy(k,current.autonomy)]))};
|
||||
}
|
||||
if(verb==='autonomy.review.console') {
|
||||
keys(a,['decisionClass'],['decisionClass']);decisionAutonomy(a.decisionClass);
|
||||
const latest=this.#store.get("SELECT seq FROM events WHERE business=? AND kind='action.allowed' AND json_extract(body,'$.operation')='autonomy.set' ORDER BY seq DESC LIMIT 1",s.business);
|
||||
this.#store.transaction(()=>this.#event(s,'action.allowed',{operation:'autonomy.review',decisionClass:a.decisionClass,settingSeq:latest?.seq??null,subject:s.console},null));
|
||||
return {reviewed:true};
|
||||
}
|
||||
if(verb==='autonomy.set.console') {
|
||||
keys(a,['revision','autonomy'],['revision','autonomy']);
|
||||
const AUTONOMY_CLASSES=['cross-role','merge','policy','authority'];
|
||||
checkAutonomy(a.autonomy);
|
||||
if(!this.#autonomyHost?.get || !this.#autonomyHost?.set)fail('not-configured');
|
||||
const old=await this.#autonomyHost.get(s.business),b=this.#business(s.business);
|
||||
if(a.revision!==old.revision)fail('revision-conflict');
|
||||
if(AUTONOMY_CLASSES.some(cls=>decisionAutonomy(cls,a.autonomy)==='near the loop') && a.autonomy.notices!==true)fail('autonomy-precondition');
|
||||
for(const cls of AUTONOMY_CLASSES) {
|
||||
const before=decisionAutonomy(cls,old.autonomy),after=decisionAutonomy(cls,a.autonomy);
|
||||
// Pulling back is unconditional. Only a move away from the loop has prerequisites.
|
||||
// Lead ruling: policy has no effect executor. Its existing out and in levels
|
||||
// remain usable; near refuses in checkAutonomy until an inverse exists.
|
||||
if(cls==='policy')continue;
|
||||
if(cls==='authority' && after==='out of the loop' && before!==after)fail('authority-limits-unavailable');
|
||||
if(['in the loop','near the loop','out of the loop'].indexOf(after)<=['in the loop','near the loop','out of the loop'].indexOf(before))continue;
|
||||
if(a.autonomy.notices!==true || !this.#autonomyHost.reversible?.(cls,b))fail('autonomy-precondition');
|
||||
const resolvers=cls==='authority'?[a.autonomy.authorityResolver]:cls==='merge'?[b.arbiters.technical]:cls==='policy'?[b.arbiters.delivery]:Object.values(b.arbiters);
|
||||
for(const name of resolvers) {
|
||||
const role=b.roles[name],right=cls==='authority'?'decision.resolve.authority':`decision.resolve.${cls==='merge'?'technical':cls==='policy'?'delivery':name===b.arbiters.technical?'technical':'delivery'}`;
|
||||
if(!role?.authority?.withinRole.includes(right) || (cls==='authority' && name===b.launch?.by))fail('autonomy-precondition');
|
||||
}
|
||||
if(['merge','policy'].includes(cls) && !Object.values(b.roles).some(r=>r.authority?.withinRole.includes('review.verdict')))fail('autonomy-precondition');
|
||||
if(cls==='authority' && !this.#budgetCovered(b))fail('autonomy-precondition');
|
||||
if(after==='out of the loop') {
|
||||
if(before!=='near the loop')fail('autonomy-precondition');
|
||||
const review=this.#store.get("SELECT seq,body FROM events WHERE business=? AND kind='action.allowed' AND json_extract(body,'$.operation')='autonomy.review' AND json_extract(body,'$.decisionClass')=? ORDER BY seq DESC LIMIT 1",s.business,cls);
|
||||
const latest=this.#store.get("SELECT seq FROM events WHERE business=? AND kind='action.allowed' AND json_extract(body,'$.operation')='autonomy.set' ORDER BY seq DESC LIMIT 1",s.business);
|
||||
if(!review || JSON.parse(review.body).settingSeq!==latest?.seq)fail('autonomy-precondition');
|
||||
const notice=this.#store.get("SELECT seq FROM events WHERE business=? AND kind='action.allowed' AND json_extract(body,'$.operation')='autonomy.notice' AND json_extract(body,'$.decisionClass')=? AND seq>? ORDER BY seq DESC LIMIT 1",s.business,cls,latest?.seq??0);
|
||||
const reversal=this.#store.get("SELECT max(e.seq) seq FROM events e WHERE e.business=? AND e.kind='action.allowed' AND json_extract(e.body,'$.operation') LIKE 'autonomy.reverse%' AND EXISTS(SELECT 1 FROM events n WHERE n.business=e.business AND n.kind='action.allowed' AND json_extract(n.body,'$.operation')='autonomy.notice' AND json_extract(n.body,'$.decisionClass')=? AND json_extract(n.body,'$.decision')=json_extract(e.body,'$.decision'))",s.business,cls);
|
||||
if(!notice || review.seq<=Math.max(notice.seq,reversal?.seq??0))fail('autonomy-precondition');
|
||||
}
|
||||
}
|
||||
this.#store.transaction(()=>this.#event(s,'action.allowed',{operation:'autonomy.set.requested',autonomy:a.autonomy,subject:s.console},null));
|
||||
const result=await this.#autonomyHost.set(s.business,a,s.human);
|
||||
this.#autonomyOwner(s);
|
||||
this.#store.transaction(()=>this.#event(s,'action.allowed',{operation:'autonomy.set',autonomy:a.autonomy,subject:s.console,transaction:result.transaction},null));
|
||||
return result;
|
||||
}
|
||||
keys(a,['id'],['id']);identifier(a.id);
|
||||
const d=this.#decision(s,a.id),notice=autonomyEvent(this.#store,s.business,d.id,['autonomy.notice']);
|
||||
const windowOpen=()=>{
|
||||
if(!notice || Date.parse(notice.body.reverseUntil)<=Math.max(Date.now(),this.#clock))throw new BusError('reverse-window-closed','The reverse window has closed; raise an ordinary new decision for this change.');
|
||||
};
|
||||
windowOpen();
|
||||
if(autonomyEvent(this.#store,s.business,d.id,['autonomy.reverse.consume']))fail('decision-reversed');
|
||||
const before=autonomyEvent(this.#store,s.business,d.id,['autonomy.before']),after=autonomyEvent(this.#store,s.business,d.id,['autonomy.after']);
|
||||
if(!before && !this.#autonomyHost?.reverse)fail('reversal-unavailable');
|
||||
this.#store.transaction(()=>{
|
||||
windowOpen();
|
||||
this.#event(s,'action.allowed',{operation:'autonomy.reverse.consume',decision:d.id,subject:s.console},d.id);
|
||||
});
|
||||
let committedResult=null;
|
||||
try {
|
||||
if(before && after) return this.#store.transaction(()=>{
|
||||
windowOpen();
|
||||
const current=this.#storeCurrent(s.business,before.body.target);
|
||||
if(current.digest!==after.body.digest)fail('decision-stale');
|
||||
const result=this.#storeWrite(s,{current,op:'change',verb:'tasks.edit',fields:before.body.fields,by:{role:null,run:null,human:s.human},kind:'human.input',body:{kind:'autonomy.reverse',decision:d.id}});
|
||||
this.#event(s,'action.allowed',{operation:'autonomy.reverse',decision:d.id,revision:result.revision},d.id);
|
||||
return {id:d.id,reversed:true,task_ref:result.task_ref};
|
||||
});
|
||||
const effects=this.#store.all("SELECT body FROM events WHERE business=? AND kind='action.allowed' AND json_extract(body,'$.decision')=? ORDER BY seq",s.business,d.id).map(e=>JSON.parse(e.body));
|
||||
let recorded=false;
|
||||
const result=await this.#autonomyHost.reverse({business:s.business,decision:d.id,decisionClass:autonomyClass(d.action),action:d.action,raised:this.#raised(s.business,d.id),effects,actor:s.human,
|
||||
beforeWrite:()=>{this.#autonomyOwner(s);windowOpen();},beforePublish:()=>{this.#autonomyOwner(s);windowOpen();},fire:proposal=>this.#fireApproved(s,proposal,()=>{this.#autonomyOwner(s);windowOpen();},true),stop:proposal=>this.#stopCopyApproved(s,proposal),complete:(proposal,result)=>this.#store.transaction(()=>this.#event(s,'action.allowed',{action:'fire.done',decision:d.id,transaction:result.transaction??null},proposal.level==='project'?`${proposal.name}.${proposal.project}`:proposal.name)),relaunch:proposal=>{
|
||||
this.#autonomyOwner(s);
|
||||
const instance=proposal.level==='project'?`${proposal.name}.${proposal.project}`:proposal.name;
|
||||
this.#mayHuman(s,'instance.launch',instance);this.#launchable(s,instance,'not-thread-launch');
|
||||
return this.#store.transaction(()=>({request:this.#lifecycleRecord(s,'lifecycle.launch',instance,{decision:d.id})}));
|
||||
},record:result=>this.#store.transaction(()=>{if(!(autonomyClass(d.action)==='authority' && result.committed===true))windowOpen();recorded=true;this.#event(s,'action.allowed',{operation:'autonomy.reverse',decision:d.id,result},d.id);})});
|
||||
if(autonomyClass(d.action)==='authority' && result.committed===true)committedResult=result;
|
||||
if(!recorded)this.#store.transaction(()=>{if(!committedResult)windowOpen();this.#event(s,'action.allowed',{operation:'autonomy.reverse',decision:d.id,result},d.id);});
|
||||
return {id:d.id,reversed:true,...result};
|
||||
} catch(error) {
|
||||
if(committedResult)return {id:d.id,reversed:true,...committedResult,trailPending:true};
|
||||
this.#store.transaction(()=>this.#event(s,'action.allowed',{operation:'autonomy.reverse.failed',decision:d.id,code:error.code??'outcome-unknown'},d.id));
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
#hireCaller(s) {
|
||||
if (!s.console || !s.human || s.reader || s.tester) fail('human-required');
|
||||
if(s.hireMembership) return s.hireMembership;
|
||||
@@ -6328,7 +6584,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();
|
||||
@@ -6337,18 +6595,82 @@ 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';
|
||||
// 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;
|
||||
if(!b.roles[route]?.authority?.withinRole.includes('decision.resolve.authority'))fail('autonomy-precondition');
|
||||
if(route===s.role || resolverSubject(b,route,p.name))route='human';
|
||||
}
|
||||
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,'human',`${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},did);
|
||||
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});
|
||||
return {...this.#decisionView(this.#decision(s,did)),hire:p};
|
||||
}
|
||||
// The harness fact comes from the host's loaded modules; no host, no account coverage.
|
||||
#budgetCovered(b) { return authorityBudgetCovered(b,{countsAccount:this.#autonomyHost?.countsAccount}); }
|
||||
// Trusted runtime inverse only; never a socket verb.
|
||||
hireReverse(...args) {
|
||||
if(!this.#hires?.reverse)fail('reversal-unavailable');
|
||||
return this.#hires.reverse(...args);
|
||||
}
|
||||
async #resolveAuthority(s,a) {
|
||||
keys(a,['id','choice'],['id','choice']);identifier(a.id);
|
||||
const d=this.#decision(s,a.id),p=this.#raised(s.business,d.id)?.hire;
|
||||
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');
|
||||
if(this.#autonomyLevel(d)==='in the loop')fail('hire-resolution-required');
|
||||
if(this.#autonomyLevel(d)==='out of the loop')fail('authority-limits-unavailable');
|
||||
if(!this.#budgetCovered(this.#business(s.business)))fail('autonomy-precondition');
|
||||
if(!this.#business(s.business).roles[s.role]?.authority?.withinRole.includes('decision.resolve.authority'))fail('not-resolver');
|
||||
};
|
||||
validate();
|
||||
if(!['yes','no'].includes(a.choice))fail('invalid-choice');
|
||||
if(!this.#hires)fail('not-configured');
|
||||
const caller={agent:true,authorityDecision:d.id,resolver:s.role,actor:s.role,rights:['decision.resolve.authority'],digest:p.digest,now:()=>Math.max(Date.now(),this.#clock),beforeWrite:validate};
|
||||
if(a.choice==='yes')await this.#hires.check(s.business,d.id,caller);
|
||||
validate();
|
||||
this.#store.transaction(()=>{
|
||||
validate();
|
||||
this.#store.run('INSERT INTO decision_events(decision,at,op,by,choice,via) VALUES(?,?,?,?,?,?)',d.id,this.#now(),'resolved',s.run,a.choice,'broker');
|
||||
if(a.choice==='yes')this.#event(s,'action.allowed',{operation:'hire.consume',decision:d.id,digest:p.digest,hire:p},d.id);
|
||||
this.#autonomyNotice(s,d,this.#closed(d.id));
|
||||
});
|
||||
if(a.choice==='no')return {id:d.id,op:'resolved',choice:a.choice};
|
||||
// No caller-provided human identity: the trusted writer receives the resolving instance.
|
||||
const recheck=()=>{
|
||||
this.#agent(s);
|
||||
if(this.#autonomyLevel(d)==='out of the loop')fail('authority-limits-unavailable');
|
||||
if(!this.#budgetCovered(this.#business(s.business)))fail('autonomy-precondition');
|
||||
if(this.#autonomyLevel(d)==='in the loop' || !this.#business(s.business).roles[s.role]?.authority?.withinRole.includes('decision.resolve.authority') || resolverSubject(this.#business(s.business),s.role,p.name))fail('not-resolver');
|
||||
};
|
||||
recheck();
|
||||
try {
|
||||
if(p.op==='fire' && p.level!=='org')await this.#stopCopyApproved(s,p);
|
||||
recheck();
|
||||
const result=p.op==='fire' && p.level==='org'?await this.#fireApproved(s,p,recheck):await this.#hires.apply(s.business,d.id,{...caller,beforeWrite:recheck,beforePublish:recheck});
|
||||
this.#store.transaction(()=>this.#event(s,'action.allowed',{operation:'hire.apply',decision:d.id,transaction:result.transaction??null,revision:result.revision??null},d.id));
|
||||
return {...result,id:d.id,op:'resolved',choice:a.choice};
|
||||
} catch(error) {
|
||||
this.#store.transaction(()=>this.#event(s,'action.allowed',{operation:'hire.failed',decision:d.id,code:error.code??'outcome-unknown'},d.id));throw error;
|
||||
}
|
||||
}
|
||||
async #hireConsole(s,verb,a) {
|
||||
const caller={...this.#hireCaller(s),now:()=>Math.max(Date.now(),this.#clock)};
|
||||
keys(a,verb==='hire.preview.console'?['id','adjust']:['id','choice','digest','adjust'],verb==='hire.preview.console'?['id']:['id','choice','digest']);
|
||||
@@ -6360,6 +6682,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});
|
||||
@@ -6380,7 +6703,7 @@ export class Broker {
|
||||
if(!this.#hires)fail('not-configured');
|
||||
try {
|
||||
if(p.op==='fire' && p.level!=='org')await this.#stopCopyApproved(s,p);
|
||||
const result=p.op==='fire' && p.level==='org' ? await this.#fireApproved(s,p) : await this.#hires.apply(s.business,d.id,{...caller,digest:p.digest,adjust:a.adjust});
|
||||
const result=p.op==='fire' && p.level==='org' ? await this.#fireApproved(s,p) : await this.#copyFence(s.business,p.binding?.instance,()=>this.#hires.apply(s.business,d.id,{...caller,digest:p.digest,adjust:a.adjust}));
|
||||
if(p.op==='fire' && p.level!=='org')this.#store.transaction(()=>this.#event(s,'action.allowed',{action:'fire.done',decision:d.id},p.level==='project'?`${p.name}.${p.project}`:p.name));
|
||||
this.#store.transaction(()=>this.#event(s,'action.allowed',{operation:result.done===false?'hire.started':'hire.apply',decision:d.id,transaction:result.transaction??null},d.id));
|
||||
return {...result,id:d.id,op:'resolved',choice:a.choice,transaction:result.transaction??null};
|
||||
@@ -6408,25 +6731,29 @@ export class Broker {
|
||||
const b=this.#business(s.business);
|
||||
if(!b.roles[instance])return;
|
||||
if(this.#revoking.has(s.business+'/'+instance))fail('fire-in-progress');
|
||||
let running=false;
|
||||
const set=this.#store.transaction(()=>{
|
||||
const held=this.#threaded(s.business,instance)?this.#threadHolders(s.business,instance):[this.#holder(s.business,instance)].filter(h=>h?.op==='claim');
|
||||
for(const h of held)this.#claimEnd(s,h,'revoke',p.id);
|
||||
const runs=[...this.#runs.values()].filter(r=>r.business===s.business&&r.role===instance).map(r=>r.run);
|
||||
running=runs.length>0;
|
||||
const request=runs.length?randomUUID():null;
|
||||
if(request)this.#event(s,'action.allowed',{action:'lifecycle.end',request,via:'human',by:s.human,runs,reason:'Approved fire decision',kill:true},instance);
|
||||
this.#event(s,'action.allowed',{action:'fire.stopped',request,decision:p.id},instance);
|
||||
return this.#grantSet(s.business,instance);
|
||||
});
|
||||
if(set.length)await this.#stepG(s.business,instance,set,{job:p.id,reason:'Approved fire decision',by:s.human,flag:{fire:true}});
|
||||
return {running};
|
||||
}
|
||||
// The existing row-281 fire executor, now entered only after a consumed human decision.
|
||||
#fireApproved(s,p) {
|
||||
#fireApproved(s,p,beforeWrite=()=>{},waitForCommit=false) {
|
||||
const args={name:p.name,reason:'Approved fire decision'},verb='instance.fire',action='role.fire';
|
||||
const b=this.#business(s.business),authority={target:p.name};
|
||||
const refused=e=>{if(['reverse-window-closed','autonomy-precondition','not-resolver'].includes(e.code))throw e;return this.#organizationFail(e,verb);};
|
||||
const write=op=>{
|
||||
try {const result=this.#organization(s.business,op,{name:p.name,revision:p.revision},s.human,{decision:p.id,digest:p.digest});
|
||||
return result?.then?result.catch(e=>this.#organizationFail(e,verb)):result;
|
||||
} catch(e) {this.#organizationFail(e,verb);}
|
||||
try {beforeWrite();const result=this.#organization(s.business,op,{name:p.name,revision:p.revision},s.human??s.role,{decision:p.id,digest:p.digest,beforeWrite,beforePublish:beforeWrite});
|
||||
return result?.then?result.catch(refused):result;
|
||||
} catch(e) {refused(e);}
|
||||
};
|
||||
if (!Object.hasOwn(b.roles, args.name)) fail('unknown-role');
|
||||
if (END_PROTECTED.includes(b.roles[args.name].definition)) fail('organization-definition-refused');
|
||||
@@ -6470,7 +6797,8 @@ export class Broker {
|
||||
if (stopped.job) {
|
||||
const { job, set, ...rest } = stopped;
|
||||
const sc = { business: s.business, role: s.role, run: s.run, thread: s.thread, human: s.human };
|
||||
this.#startGrantJob(s.business, args.name, verb, () => this.#fireJob(sc, args, authority, write, job, set));
|
||||
const pending=this.#startGrantJob(s.business, args.name, verb, () => this.#fireJob(sc, args, authority, write, job, set,waitForCommit));
|
||||
if(waitForCommit)return pending;
|
||||
return { ...rest, job, revoking: set.map((g) => g.label), done: false };
|
||||
}
|
||||
// Step B: the delist. An instance already delisted counts as done.
|
||||
@@ -7103,12 +7431,39 @@ export class Broker {
|
||||
// Preserve the common request gates and refuse additions until the vocabulary names them.
|
||||
if (verb.startsWith('tasks.') && !PERSON_TASK_VERBS.includes(verb)) fail('unknown-verb');
|
||||
switch (verb) {
|
||||
case 'decision.confirm.console': {
|
||||
this.#autonomyOwner(s);
|
||||
keys(a,['id','confirm','reason'],['id','confirm']);identifier(a.id);
|
||||
if(typeof a.confirm!=='boolean' || (!a.confirm && !text(a.reason,500)))fail('invalid-request');
|
||||
const d=this.#decision(s,a.id);
|
||||
if(d.route_to==='human' || d.action==='prd.approve' || d.class==='within-role')fail('decision-not-eligible');
|
||||
const pending=autonomyEvent(this.#store,s.business,d.id,['autonomy.pending','autonomy.declined','autonomy.confirmed']);
|
||||
const closed=this.#closed(d.id);
|
||||
if(closed && ['withdrawn','expired'].includes(closed.op))fail('decision-closed');
|
||||
if(pending?.body.operation!=='autonomy.pending')fail('decision-not-eligible');
|
||||
const resolution=pending.body.resolution;
|
||||
if(!resolution || resolution.op!=='resolved' || this.#autonomyLevel(d)!=='in the loop')fail('decision-not-eligible');
|
||||
if(this.#spent(s.business,d.id))fail('decision-consumed');
|
||||
this.#checkResolver({business:s.business,role:d.route_to},d);
|
||||
if(d.raised_by_role===d.route_to || resolverSubject(b,d.route_to,this.#raised(s.business,d.id)?.target))fail('not-resolver');
|
||||
this.#event(s,'human.input',{kind:'autonomy.confirmation',decision:d.id,confirm:a.confirm,reason:a.reason??null,subject:s.console},d.id);
|
||||
if(!a.confirm) {
|
||||
this.#event(s,'action.allowed',{operation:'autonomy.declined',decision:d.id,reason:a.reason},d.id);
|
||||
return {id:d.id,op:'declined'};
|
||||
}
|
||||
if(!this.#store.get("SELECT 1 FROM decision_events WHERE decision=? AND op='resolved'",d.id)) {
|
||||
this.#store.run('INSERT INTO decision_events(decision,at,op,by,choice,via) VALUES(?,?,?,?,?,?)',d.id,resolution.at,'resolved',resolution.by,resolution.choice,'broker');
|
||||
}
|
||||
this.#event(s,'action.allowed',{operation:'autonomy.resolved',decision:d.id,resolution},d.id);
|
||||
this.#event(s,'action.allowed',{operation:'autonomy.confirmed',decision:d.id,resolution},d.id);
|
||||
return {id:d.id,op:'confirmed'};
|
||||
}
|
||||
case 'my_rails': {
|
||||
keys(a, []);
|
||||
this.#agent(s);
|
||||
const entry = b.roles[s.role], definition = b.definitions?.[entry.definition];
|
||||
return ownRails({ instance: s.role, authority: entry.authority, network: entry.network ?? definition?.network,
|
||||
credentials: definition?.credentials ?? [], messaging: definition?.messaging, arbiters: b.arbiters, launcher: b.launch?.by });
|
||||
credentials: definition?.credentials ?? [], messaging: definition?.messaging, arbiters: b.arbiters, launcher: b.launch?.by, autonomy: b.autonomy });
|
||||
}
|
||||
case 'role.claim': {
|
||||
keys(a, []);
|
||||
@@ -7255,14 +7610,15 @@ export class Broker {
|
||||
const approvalChoice = a.approvalChoice ?? 'yes';
|
||||
if (!choices.includes(approvalChoice)) fail('invalid-approval-choice');
|
||||
if (a.domain !== undefined && !['technical', 'delivery'].includes(a.domain)) fail('invalid-domain');
|
||||
const domain = DECISION_DOMAINS[a.action] ?? a.domain ?? 'delivery';
|
||||
if (a.action === 'prd.approve' && a.domain !== undefined && a.domain !== 'technical') throw new BusError('invalid-domain', 'prd.approve requires domain technical');
|
||||
const domain = a.action === 'prd.approve' ? 'technical' : DECISION_DOMAINS[a.action] ?? a.domain ?? 'delivery';
|
||||
// PRD 1.5 REQ-DEC-2 (row 139, #1633): a gated raise citing the project's current spec commit
|
||||
// goes to the arbiter for its domain, like a cross-role one. An uncited gated raise and any
|
||||
// hard wait go to the human. readSpec already refused a stale or unconfirmed citation.
|
||||
const arbitrated =
|
||||
cls === 'cross-role' || (cls === 'gated' && spec !== null && !this.#hardWait(s, a.action, a.target));
|
||||
const proposedRoute = arbitrated ? b.arbiters[domain] : cls === 'gated' ? 'human' : s.role;
|
||||
const route = arbitrated && proposedRoute === s.role ? 'human' : proposedRoute;
|
||||
const route = arbitrated && (proposedRoute === s.role || resolverSubject(b, proposedRoute, a.target)) ? 'human' : proposedRoute;
|
||||
// Row 177 r2 (#1675, F1): nor does it put a decision in the human's inbox. A raise routed to an
|
||||
// arbiter stays allowed; the chief's reach to the human is no new reach (README, N1).
|
||||
if (route === 'human' && this.#testerThread(s)) fail('tester-thread-human');
|
||||
@@ -7338,6 +7694,7 @@ export class Broker {
|
||||
action: a.action,
|
||||
operation: 'decision.raise',
|
||||
domain,
|
||||
...(arbitrated && a.action!=='prd.approve' && route!=='human' ? {autonomyLevel:decisionAutonomy(autonomyClass(a.action),b.autonomy)} : {}),
|
||||
task_ref: a.task_ref ?? null,
|
||||
target: a.target ?? null,
|
||||
approvalChoice,
|
||||
@@ -7403,13 +7760,21 @@ export class Broker {
|
||||
}
|
||||
} else if (d.route_to === 'human') this.#human(s);
|
||||
else {
|
||||
if(s.human && this.#autonomyLevel(d)==='in the loop')fail('not-resolver');
|
||||
this.#agent(s);
|
||||
if (s.role !== d.route_to) fail('not-resolver');
|
||||
if (verb === 'decision.resolve') this.#checkResolver(s, d);
|
||||
if (verb === 'decision.resolve') {
|
||||
if(d.class!=='within-role' && (d.raised_by_role===s.role || resolverSubject(b,s.role,this.#raised(s.business,d.id)?.target)))fail('not-resolver');
|
||||
this.#checkResolver(s, d);
|
||||
}
|
||||
}
|
||||
const op = verb === 'decision.resolve' || viaConsole ? 'resolved' : verb === 'decision.seen' ? 'seen' : 'withdrawn';
|
||||
if (op === 'resolved' && !JSON.parse(d.options).some((o) => o.key === a.choice))
|
||||
fail('invalid-choice');
|
||||
if(op==='resolved' && !s.human && d.route_to!=='human' && this.#autonomyLevel(d)==='in the loop') {
|
||||
const pending=this.#resolutionEvent(s,d,a.choice);
|
||||
if(pending)return pending;
|
||||
}
|
||||
const seq = Number(
|
||||
this.#store.run(
|
||||
'INSERT INTO decision_events(decision,at,op,by,choice,note,via) VALUES(?,?,?,?,?,?,?)',
|
||||
@@ -7422,6 +7787,7 @@ export class Broker {
|
||||
s.console ? 'console' : s.human ? 'cli' : 'broker',
|
||||
).lastInsertRowid,
|
||||
);
|
||||
if(op==='resolved' && !s.human)this.#autonomyNotice(s,d,this.#closed(d.id));
|
||||
// Row 252 (N2): the human.input comes before #approvedGrant, which needs a console answer's
|
||||
// record to start the executor. Its body names the owner subject and the close's seq.
|
||||
if (s.human)
|
||||
@@ -7846,19 +8212,19 @@ export class Broker {
|
||||
keys(a, allowed, verb === 'trail' ? ['subject'] : []);
|
||||
if (!s.human && !s.reader) this.#agent(s);
|
||||
if (verb === 'inbox')this.#expireHires(s);
|
||||
if (verb === 'inbox')
|
||||
return this.#store
|
||||
.all(
|
||||
`SELECT d.* FROM decisions d WHERE business=? AND route_to=? AND NOT EXISTS(SELECT 1 FROM decision_events x WHERE x.decision=d.id AND x.op IN ${CLOSED}) ORDER BY CASE class WHEN 'gated' THEN 0 ELSE 1 END,seq`,
|
||||
s.business,
|
||||
s.human || s.reader ? 'human' : s.role,
|
||||
)
|
||||
.map((d) => {
|
||||
// Row 252: a grant request the Console can answer carries its two answers. The Console
|
||||
// shows buttons from this, and the answer path rechecks it at the bus.
|
||||
const c = s.human || s.reader ? this.#consoleResolution(d) : null;
|
||||
return c ? { ...this.#decisionView(d), console: c } : this.#decisionView(d);
|
||||
});
|
||||
if (verb === 'inbox') {
|
||||
const owner=!!(s.human || s.reader);
|
||||
return this.#store.all('SELECT * FROM decisions WHERE business=? ORDER BY seq',s.business).flatMap(d=>{
|
||||
const closed=this.#closed(d.id), pending=autonomyEvent(this.#store,s.business,d.id,['autonomy.pending','autonomy.declined','autonomy.confirmed']);
|
||||
const waiting=pending?.body.operation==='autonomy.pending' && !closed;
|
||||
const notice=autonomyEvent(this.#store,s.business,d.id,['autonomy.notice']);
|
||||
const reversed=autonomyEvent(this.#store,s.business,d.id,['autonomy.reverse.consume']);
|
||||
if(owner && (waiting || (notice && !reversed)))return [{...this.#decisionView(d),autonomy:{pending:waiting,notice:!!notice,reverseUntil:notice?.body.reverseUntil??null}}];
|
||||
if(closed || waiting || d.route_to!==(owner?'human':s.role))return [];
|
||||
const c=owner?this.#consoleResolution(d):null;
|
||||
return [c?{...this.#decisionView(d),console:c}:this.#decisionView(d)];
|
||||
}).sort((a,b)=>(a.class==='gated'?0:1)-(b.class==='gated'?0:1)||a.seq-b.seq);
|
||||
}
|
||||
if (verb === 'agents') {
|
||||
const claims = this.#store.all(
|
||||
"SELECT * FROM role_claims c WHERE business=? AND op='claim' AND seq=(SELECT max(seq) FROM role_claims WHERE business=c.business AND role=c.role) ORDER BY role",
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
// #1873 CB2 (design section 5 steps 4 to 6): credentials.issue for an agent copy's bound service.
|
||||
// Gitea is the only service with a verifier in this slice. A copy token is accepted only after the
|
||||
// Gitea server it belongs to proves the whole grant live: the token's scopes, the account it belongs
|
||||
// to (the copy's own dedicated account, never an administrator or another copy's or agent's), every
|
||||
// repository that account can reach and the token's expiry, against the owner-approved binding. A
|
||||
// Vikunja binding refuses credential-scope-unverified: that is the shipped behaviour.
|
||||
//
|
||||
// Order (no lock is held across the remote work):
|
||||
// 1. checks that need nothing written: owner, service, date, token form, definition scan, the
|
||||
// record and the boot snapshot, the bound projects' remotes and the approved scopes;
|
||||
// 2. stage: the writer deactivates the record (pending, the service's grant fields gone), then the
|
||||
// token goes to the copy's slot in the self-only store;
|
||||
// 3. the slot is read back by the identity the stage wrote, and Gitea is asked;
|
||||
// 4. commit: the writer rechecks registry incarnation, document revision, binding generation,
|
||||
// copyId, decision and definition pin under its locks; its beforeWrite reads the slot back by
|
||||
// identity again and runs the caller's session recheck. Only then do the grant metadata and the
|
||||
// receipt land.
|
||||
// Any refusal after the stage removes the slot it wrote and leaves the record pending. A lost reply
|
||||
// or a crash leaves the record pending too; nothing resolves a pending record, and recovery is a
|
||||
// new verified issue. A token a crash left staged goes at the next start (sweepCopySlots). Nothing here logs, and no result, receipt or refusal holds the token, the
|
||||
// slot's path, a request or anything derived from the token.
|
||||
import { lstatSync, readdirSync, unlinkSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { BusError } from './broker.mjs';
|
||||
import { Credentials, assertNotHeld, day, privateDir } from './credentials.mjs';
|
||||
import { send, versionAtLeast, REPO_PAGE, REPO_PAGES } from './gitea-grant.mjs';
|
||||
import { scanOrganizations } from './definition-scan.mjs';
|
||||
import { parseGiteaUrl, GITEA_SCOPE_CATEGORIES, ID_PATTERN, SERVICES } from '../../organization/src/vocabulary.mjs';
|
||||
import { copyBotLogin, scopesWithin } from '../../organization/src/copy-bindings.mjs';
|
||||
|
||||
const deny = (code, reason) => {
|
||||
throw new BusError(code, reason);
|
||||
};
|
||||
const unverified = (reason) => deny('credential-scope-unverified', reason);
|
||||
const DAY_MS = 86400000;
|
||||
// A copy credential's rotation date is after today and at most this many days ahead.
|
||||
export const MAX_ROTATE_DAYS = 366;
|
||||
const object = (x) => x !== null && typeof x === 'object' && !Array.isArray(x);
|
||||
const positive = (n) => Number.isSafeInteger(n) && n > 0;
|
||||
const lower = (s) => (typeof s === 'string' ? s.toLowerCase() : null);
|
||||
|
||||
// The scopes a Gitea token reports, as {category: level}. Gitea writes write:X as implying read:X and
|
||||
// may list both; the higher level wins. "all", any admin category, a category Gitea added that this
|
||||
// bus doesn't know, or anything that doesn't parse returns null: such a token is broader than any
|
||||
// binding can approve.
|
||||
export function tokenLevels(scopes) {
|
||||
if (!Array.isArray(scopes) || scopes.length === 0) return null;
|
||||
const out = {};
|
||||
for (const scope of scopes) {
|
||||
const m = typeof scope === 'string' ? /^(read|write):([a-z]+)$/.exec(scope) : null;
|
||||
if (!m || !GITEA_SCOPE_CATEGORIES.includes(m[2])) return null;
|
||||
if (out[m[2]] !== 'write') out[m[2]] = m[1];
|
||||
}
|
||||
return out;
|
||||
}
|
||||
const asList = (levels) => Object.entries(levels).map(([c, l]) => `${l}:${c}`).sort();
|
||||
|
||||
// The approved Gitea repositories of a copy's binding: one https origin, lower-cased "owner/repo"
|
||||
// names, from each bound project's remote. A project with no Gitea remote, two remotes that disagree
|
||||
// or two servers cannot be proved.
|
||||
export function boundRepositories(doc, projects) {
|
||||
let origin = null;
|
||||
const repos = new Set();
|
||||
for (const project of projects ?? []) {
|
||||
const entry = doc.projects?.[project];
|
||||
if (!object(entry)) unverified('remote');
|
||||
const remotes = [entry.remote, entry.git?.mode === 'remote' ? entry.git.remote : undefined].filter((r) => r !== undefined);
|
||||
if (remotes.length === 0 || remotes.some((r) => r !== remotes[0])) unverified('remote');
|
||||
const parsed = parseGiteaUrl(remotes[0], { repo: true });
|
||||
if (!parsed.url) unverified('remote');
|
||||
if (origin !== null && parsed.url.origin !== origin) unverified('remote');
|
||||
origin = parsed.url.origin;
|
||||
repos.add(`${parsed.owner}/${parsed.repo}`.toLowerCase());
|
||||
}
|
||||
if (origin === null) unverified('remote');
|
||||
return { origin, repos };
|
||||
}
|
||||
|
||||
// Live introspection of one Gitea token against an approved binding. Returns { userId, tokenId,
|
||||
// login } or throws the refusal. `approved` is the binding's Gitea scopes; `repos` the lower-cased
|
||||
// names it may reach; `login` the account this copy's grant must belong to; `rotateBy` the date the
|
||||
// owner gave. Every call is GET with the token; a missing or ambiguous answer is unverified.
|
||||
export async function verifyGiteaToken({ transport = send, url, ca, timeoutMs, token, approved, repos, login, rotateBy }) {
|
||||
const call = (path) => transport({ url, method: 'GET', path, auth: { token }, ca, timeoutMs });
|
||||
const version = await call('/api/v1/version');
|
||||
if (version.status !== 200 || !object(version.json) || !versionAtLeast(version.json.version)) unverified('version');
|
||||
const info = await call('/api/v1/token');
|
||||
if (info.status === 401 || info.status === 403 || info.status === 404) unverified('token');
|
||||
if (info.status !== 200 || !object(info.json) || !positive(info.json.id) || !object(info.json.user) || !positive(info.json.user.id)
|
||||
|| typeof info.json.user.login !== 'string') unverified('token');
|
||||
if (lower(info.json.user.login) !== lower(login)) deny('credential-identity-refused', login);
|
||||
const levels = tokenLevels(info.json.scopes);
|
||||
if (!levels || !scopesWithin('gitea', asList(levels), approved)) deny('credential-scope-exceeded');
|
||||
if (!levels.user) unverified('scopes');
|
||||
// An expiry Gitea reports must not come before the owner's rotation date ends.
|
||||
if (info.json.expires_at !== undefined && info.json.expires_at !== null) {
|
||||
const at = typeof info.json.expires_at === 'string' ? Date.parse(info.json.expires_at) : NaN;
|
||||
if (Number.isNaN(at)) unverified('token');
|
||||
if (at < Date.parse(rotateBy + 'T00:00:00Z') + DAY_MS) deny('credential-date');
|
||||
}
|
||||
const user = await call('/api/v1/user');
|
||||
if (user.status !== 200 || !object(user.json) || typeof user.json.is_admin !== 'boolean') unverified('user');
|
||||
if (user.json.id !== info.json.user.id || lower(user.json.login) !== lower(login) || user.json.is_admin !== false)
|
||||
deny('credential-identity-refused', login);
|
||||
// Every repository the account can reach: owned, collaborator and team access. One unrelated
|
||||
// repository, an admin permission, or push where the binding approved read refuses. Push is the
|
||||
// account's right, so it is held to what the binding approved, not to the token's own level.
|
||||
const write = tokenLevels(approved)?.repository === 'write';
|
||||
const seen = new Map();
|
||||
let total = null;
|
||||
for (let page = 1; ; page++) {
|
||||
if (page > REPO_PAGES) unverified('repositories');
|
||||
const r = await call(`/api/v1/user/repos?limit=${REPO_PAGE}&page=${page}`);
|
||||
if (r.status !== 200 || !Array.isArray(r.json) || r.total === null || (total !== null && r.total !== total)) unverified('repositories');
|
||||
total = r.total;
|
||||
if (total > REPO_PAGE * REPO_PAGES) unverified('repositories');
|
||||
for (const repo of r.json) {
|
||||
if (!object(repo) || typeof repo.full_name !== 'string' || !object(repo.permissions)) unverified('repositories');
|
||||
const name = repo.full_name.toLowerCase();
|
||||
if (!repos.has(name)) deny('credential-scope-exceeded');
|
||||
const p = repo.permissions;
|
||||
if (p.admin !== false || typeof p.push !== 'boolean' || p.pull !== true || (p.push && !write)) deny('credential-scope-exceeded');
|
||||
seen.set(name, true);
|
||||
}
|
||||
if (seen.size >= total || r.json.length === 0) break;
|
||||
}
|
||||
if (seen.size !== total) unverified('repositories');
|
||||
for (const name of repos) if (!seen.has(name)) unverified('repositories');
|
||||
// An organization scope reaches organizations' own settings: the account must belong to none.
|
||||
if (levels.organization) {
|
||||
const orgs = await call(`/api/v1/user/orgs?limit=${REPO_PAGE}&page=1`);
|
||||
if (orgs.status !== 200 || !Array.isArray(orgs.json)) unverified('organizations');
|
||||
if (orgs.json.length) deny('credential-scope-exceeded');
|
||||
}
|
||||
return { userId: info.json.user.id, tokenId: info.json.id, login };
|
||||
}
|
||||
|
||||
// The issue for one copy's service. `copies` is the bus's boot snapshot ({ org, copies,
|
||||
// definitions }); `writer` the organization writer; `credentials` the bus's store. `hooks` are the
|
||||
// tests' crash points (afterStage, afterRemote, beforeCommit), never set at runtime.
|
||||
export function copyGrant({ credentials, writer, copies = null, organizations = null, transport = send, ca = undefined, timeoutMs = undefined, now = () => new Date(), hooks = {} }) {
|
||||
return async (business, args, { actor, owner = false, recheck = () => {} } = {}) => {
|
||||
const { agent: instance, service, token, date } = args;
|
||||
if (owner !== true || actor === 'tester') deny('human-required');
|
||||
if (service === 'vikunja') unverified('service');
|
||||
if (service !== 'gitea') deny('credential-unknown');
|
||||
const today = now().toISOString().slice(0, 10);
|
||||
if (!day(date) || date <= today || Date.parse(date) - Date.parse(today) > MAX_ROTATE_DAYS * DAY_MS) deny('credential-date');
|
||||
const value = Credentials.checkFormat(service, token);
|
||||
const { document: doc, revision } = writer.read(business);
|
||||
scanOrganizations({ organizations: organizations?.() ?? [business], own: { id: business, document: doc }, readRaw: (b) => writer.readRaw(b),
|
||||
boot: copies?.definitions ? { org: copies.org, definitions: copies.definitions } : null }, (v) => assertNotHeld(v, [value]));
|
||||
const record = doc.copyBindings?.[instance];
|
||||
if (!object(record) || record.state === 'unbound' || !object(record.services?.[service])) deny('credential-unbound');
|
||||
const copy = copies?.org === business && Object.hasOwn(copies.copies ?? {}, instance) ? copies.copies[instance] : null;
|
||||
if (!copy || copy.state === 'refused' || Object.hasOwn(doc.roles ?? {}, instance)) deny('copy-unavailable');
|
||||
if (copy.level !== record.source?.level || copy.name !== record.source?.name || (copy.project ?? null) !== (record.source?.project ?? null)
|
||||
|| copy.definition !== record.definition?.ref) deny('credential-binding-changed');
|
||||
const approved = record.services[service];
|
||||
if (!Array.isArray(approved.scopes) || !approved.scopes.some((s) => /^(read|write):user$/.test(s))) unverified('scopes');
|
||||
const { origin, repos } = boundRepositories(doc, approved.resources?.projects);
|
||||
const login = copyBotLogin(record.orgIncarnation, record.copyId, service);
|
||||
if (!login) deny('credential-binding-changed');
|
||||
const op = { instance, service, copyId: record.copyId, generation: record.generation, decision: record.decision, incarnation: record.orgIncarnation };
|
||||
const definitions = copies?.org === business ? copies.definitions ?? {} : {};
|
||||
const staged = writer.stageCopyGrant(business, op, { revision, actor, owner, definitions });
|
||||
const slot = business + '/' + instance;
|
||||
let identity = null;
|
||||
try {
|
||||
identity = credentials.stageCopy(slot, service, value.token);
|
||||
await hooks.afterStage?.();
|
||||
// An inode number can be reused after an unlink, so the read-back must also be the staged token.
|
||||
const read = credentials.readCopy(slot, service, identity);
|
||||
if (read.token !== value.token) deny('credential-file');
|
||||
const grant = await verifyGiteaToken({ transport, url: origin, ca, timeoutMs, token: read.token, approved: approved.scopes, repos, login, rotateBy: date });
|
||||
await hooks.afterRemote?.();
|
||||
await recheck();
|
||||
const grantId = `gitea:${grant.userId}:${grant.tokenId}`;
|
||||
const after = { ...op, generation: staged.generation };
|
||||
const committed = writer.commitCopyGrant(business, after, {
|
||||
grantId, rotateBy: date, revision: staged.revision, actor, owner, definitions,
|
||||
beforeWrite: () => {
|
||||
hooks.beforeCommit?.();
|
||||
const again = credentials.readCopy(slot, service, identity);
|
||||
if (again.token !== read.token) deny('credential-file');
|
||||
recheck();
|
||||
},
|
||||
});
|
||||
return { agent: instance, service, verb: 'issue', date, state: committed.state, account: login, generation: committed.generation };
|
||||
} catch (e) {
|
||||
if (identity) {
|
||||
try {
|
||||
credentials.unlinkCopy(slot, service, identity);
|
||||
} catch {}
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Security review P2: at start, before Credentials loads and before any issue can run, each staged
|
||||
// file of a copy's service whose binding holds no grant for that service goes, and so does any
|
||||
// interrupted write's temp file. An issue killed after its stage leaves the record pending and the
|
||||
// token on disk; nothing resolves a pending record and no reference loads a copy slot, so the file
|
||||
// only keeps a secret nothing will use. `documents` is [business, validated organization document]
|
||||
// for each loaded business (null when unreadable: nothing of it is touched). Only names the document
|
||||
// binds as copies are looked at, never a name its own roles hold or a slot a loaded reference names.
|
||||
// A store dir that isn't private refuses credential-file, as Credentials would. Returns
|
||||
// [{ instance, service }], never a path.
|
||||
export function sweepCopySlots(storeRoot, documents, references = {}) {
|
||||
const removed = [];
|
||||
if (!storeRoot || !privateDir(storeRoot, { missing: true })) return removed;
|
||||
for (const [business, doc] of documents) {
|
||||
if (!object(doc) || !object(doc.copyBindings) || !ID_PATTERN.test(business)) continue;
|
||||
const dir = join(storeRoot, business);
|
||||
if (!privateDir(dir, { missing: true })) continue;
|
||||
for (const [copy, record] of Object.entries(doc.copyBindings)) {
|
||||
if (!ID_PATTERN.test(copy) || Object.hasOwn(doc.roles ?? {}, copy)) continue;
|
||||
const copyDir = join(dir, copy);
|
||||
if (!privateDir(copyDir, { missing: true })) continue;
|
||||
const instance = business + '/' + copy;
|
||||
for (const name of readdirSync(copyDir)) {
|
||||
const service = name.endsWith('.tmp') ? name.slice(0, -4) : name;
|
||||
if (!SERVICES.includes(service) || references[instance]?.[service] !== undefined) continue;
|
||||
if (name === service && typeof record?.services?.[service]?.grantId === 'string') continue;
|
||||
if (lstatSync(join(copyDir, name)).isDirectory()) continue;
|
||||
unlinkSync(join(copyDir, name));
|
||||
removed.push({ instance, service });
|
||||
}
|
||||
}
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
// #1873 CB3: launch admission for an agent copy's bound service, and the one checked call a copy
|
||||
// session makes with it (design docs/plans/2026-11-11_copy-credential-binding-design.md section 5
|
||||
// steps 7 and 8). Gitea is the only service with a verifier.
|
||||
//
|
||||
// Boot: each copy the boot left unbound whose record is bound with every service granted is
|
||||
// re-resolved (role, definition pin, admitted projects, incarnation) and its slot's token is proved
|
||||
// live at the service again. Only a copy that passes all of it is materialized (promoteCopies). A
|
||||
// binding approved after boot is never launched: it answers restart-required until the next boot.
|
||||
//
|
||||
// Every launch (prepare, final admission and bindLaunch) recomputes the launch authority: a digest
|
||||
// of the organization, copy, definition pin, copyId, incarnation, binding generation, each grant's
|
||||
// id and generation, the services and projects the copy reaches now, and their repositories at the
|
||||
// one origin the boot proved the token at. Every check reads the registry entry again: an
|
||||
// organization no longer active, or with another incarnation, admits and sends nothing. Prepare
|
||||
// proves the slot live again; the digest before and after must agree. The
|
||||
// session keeps the digest bindLaunch admitted, and each call recomputes it before and after the
|
||||
// request: a revoke, a re-issue, a role change or a project change in between denies the call.
|
||||
//
|
||||
// A revoke of a copy's binding runs inside fence(): while one is in flight the copy admits nothing
|
||||
// and a call refuses credential-revoking. A revoke whose outcome is unknown keeps the fence until
|
||||
// the next boot. The token is never held: each call reads the slot by the identity the boot proved
|
||||
// and checks it against a keyed MAC of the token the boot proved. Nothing here logs, and no result
|
||||
// or refusal holds the token, the slot's path or anything derived from the token.
|
||||
import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
||||
import { BusError } from './broker.mjs';
|
||||
import { assertNotHeld } from './credentials.mjs';
|
||||
import { send } from './gitea-grant.mjs';
|
||||
import { boundRepositories, tokenLevels, verifyGiteaToken } from './copy-grant.mjs';
|
||||
import { readOrgEntrySync } from '../../organization/src/org-registry.mjs';
|
||||
import { allGranted, copyBotLogin, definitionPin, resolveCopyBinding } from '../../organization/src/copy-bindings.mjs';
|
||||
import { COPY_BINDABLE } from '../../organization/src/agent-copies.mjs';
|
||||
import { checkDefinitions, readTemplate } from '../../organization/src/definitions.mjs';
|
||||
import { loadRole } from '../../organization/src/role.mjs';
|
||||
import { resolveAgentLevels } from '../../organization/src/agent-levels.mjs';
|
||||
import { canonicalJson, sha256 } from '../../organization/src/util.mjs';
|
||||
|
||||
const deny = (code) => {
|
||||
throw new BusError(code);
|
||||
};
|
||||
const object = (x) => x !== null && typeof x === 'object' && !Array.isArray(x);
|
||||
// The repository API a copy session may call: /api/v1/repos/<owner>/<repo>[/<rest>], ASCII names,
|
||||
// no dot segment, no encoded separator, no query beyond plain pairs.
|
||||
const REPO_PATH = /^\/api\/v1\/repos\/([A-Za-z0-9_.-]{1,100})\/([A-Za-z0-9_.-]{1,100})((?:\/[A-Za-z0-9_.-]{1,200}){0,8})(\?[A-Za-z0-9_=&.-]{0,200})?$/;
|
||||
const READ = ['GET', 'HEAD'];
|
||||
const WRITE = ['POST', 'PUT', 'PATCH', 'DELETE'];
|
||||
// Codes a boot check keeps for the copy; anything else (a thrown bug, a transport surprise) is
|
||||
// credential-unavailable.
|
||||
const KEPT = /^(credential-|copy-)/;
|
||||
|
||||
// The launcher waits on its host channel for prepare, and the host stops on a reply later than its
|
||||
// request timeout (10 s); the boot runs inside the host's boot timeout (30 s). Each proof is held to
|
||||
// a budget well under those: a service slower than that answers no answer, so it is not proved.
|
||||
export const PREPARE_BUDGET_MS = 8000;
|
||||
export const BOOT_BUDGET_MS = 20000;
|
||||
|
||||
export function copyLaunchGate({ credentials, writer, configDir, rolesDir, snapshot, transport = send, ca = undefined, timeoutMs = undefined, now = () => new Date(),
|
||||
prepareMs = PREPARE_BUDGET_MS, bootMs = BOOT_BUDGET_MS }) {
|
||||
const org = snapshot?.org ?? null;
|
||||
const key = randomBytes(32);
|
||||
const mac = (token) => createHmac('sha256', key).update(token).digest();
|
||||
const verified = new Map();
|
||||
const failed = new Map();
|
||||
const fences = new Map();
|
||||
const today = () => now().toISOString().slice(0, 10);
|
||||
// The organization's registry incarnation as the registry stands now, or null when the entry is
|
||||
// gone, carries none, or is not active (reserved, deleting, archived). Read on every check, never
|
||||
// cached: an organization archived while the bus runs admits and sends nothing. An unreadable or
|
||||
// unsafe registry refuses credential-unavailable.
|
||||
const liveIncarnation = () => {
|
||||
let entry;
|
||||
try {
|
||||
entry = readOrgEntrySync(configDir, org);
|
||||
} catch {
|
||||
return deny('credential-unavailable');
|
||||
}
|
||||
return entry?.state === 'active' ? entry.incarnation : null;
|
||||
};
|
||||
// The organization file is still the revision the launch resolved its bundle from (#1873 A3).
|
||||
const sameRevision = (business, revision) => {
|
||||
if (revision === undefined) return;
|
||||
let now;
|
||||
try {
|
||||
now = writer.read(business);
|
||||
} catch {
|
||||
deny('organization-unreadable');
|
||||
}
|
||||
if (typeof revision !== 'string' || now?.revision !== revision) deny('organization-changed');
|
||||
};
|
||||
const read = (business) => {
|
||||
try {
|
||||
return writer.read(business).document;
|
||||
} catch {
|
||||
return deny('credential-unavailable');
|
||||
}
|
||||
};
|
||||
// The copy, its pin, role and admitted projects as the file stands now (hire-decisions.mjs
|
||||
// bindingFacts, without the refusals that function makes for a proposal).
|
||||
const facts = (business, instance, doc) => {
|
||||
const copy = snapshot?.org === business && Object.hasOwn(snapshot.copies ?? {}, instance) ? snapshot.copies[instance] : null;
|
||||
if (!copy || copy.state === 'refused' || Object.hasOwn(doc.roles ?? {}, instance)) deny('copy-unavailable');
|
||||
const definitions = { ...(snapshot.definitions ?? {}), ...(doc.definitions ?? {}) };
|
||||
const pin = definitionPin(copy.definition, { definitions, rolesDir, readTemplate });
|
||||
let role = null;
|
||||
try {
|
||||
role = copy.definition.startsWith('org:') ? checkDefinitions(definitions, 'copy definitions')[copy.definition] : loadRole(rolesDir, copy.definition);
|
||||
} catch {
|
||||
role = null;
|
||||
}
|
||||
if (!pin || !role) deny('credential-role-changed');
|
||||
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 {
|
||||
deny('copy-unavailable');
|
||||
}
|
||||
projects = code.filter((p) => levels.unions[p]?.ok && levels.unions[p].agents.some((a) => a.level === 'root' && a.name === copy.name));
|
||||
}
|
||||
return { copy, pin, role, projects };
|
||||
};
|
||||
const resolve = (business, instance, doc, record, inc) => {
|
||||
const f = facts(business, instance, doc);
|
||||
const res = resolveCopyBinding({ orgId: business, incarnation: inc, snapshot, instance, record, pin: f.pin, role: f.role, projects: f.projects });
|
||||
if (!res.ok) deny(res.code);
|
||||
if (Object.keys(res.services).some((s) => !COPY_BINDABLE.includes(s))) deny('credential-scope-unverified');
|
||||
return { ...f, res };
|
||||
};
|
||||
const grantsOf = (record) => Object.fromEntries(Object.keys(record.services).sort()
|
||||
.map((s) => [s, { grantId: record.services[s].grantId, grantGeneration: record.services[s].grantGeneration }]));
|
||||
const slot = (instance) => org + '/' + instance;
|
||||
// Live proof of one slot token against the record: the service's own answer, the grant id the
|
||||
// verifier committed, and a rotation date not yet passed.
|
||||
// The transport held to a deadline: each call gets what is left, and none starts after it.
|
||||
const within = (deadline) => (req) => {
|
||||
const left = deadline - Date.now();
|
||||
if (left <= 0) return Promise.resolve({ status: 0, json: null, total: null });
|
||||
return transport({ ...req, timeoutMs: Math.min(req.timeoutMs ?? left, left) });
|
||||
};
|
||||
const prove = async (doc, record, token, inc, deadline) => {
|
||||
const approved = record.services.gitea;
|
||||
if (approved.rotateBy < today()) deny('credential-expired');
|
||||
const { origin, repos } = boundRepositories(doc, approved.resources.projects);
|
||||
const login = copyBotLogin(inc, record.copyId, 'gitea');
|
||||
if (!login) deny('credential-revoked');
|
||||
const grant = await verifyGiteaToken({ transport: within(deadline), url: origin, ca, timeoutMs, token, approved: approved.scopes, repos, login, rotateBy: approved.rotateBy });
|
||||
if (`gitea:${grant.userId}:${grant.tokenId}` !== approved.grantId) deny('credential-revoked');
|
||||
};
|
||||
|
||||
async function boot(business) {
|
||||
if (business !== org) return;
|
||||
const deadline = Date.now() + bootMs;
|
||||
// An inactive or unreadable registry entry leaves null: the resolver refuses every copy with its
|
||||
// own code.
|
||||
let incarnation = null;
|
||||
try {
|
||||
incarnation = liveIncarnation();
|
||||
} catch {
|
||||
incarnation = null;
|
||||
}
|
||||
let doc;
|
||||
try {
|
||||
doc = writer.read(business).document;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
for (const [instance, copy] of Object.entries(snapshot.copies ?? {})) {
|
||||
if (copy.state !== 'unbound') continue;
|
||||
const record = doc.copyBindings?.[instance];
|
||||
if (!object(record) || record.state !== 'bound' || !allGranted(record.services)) continue;
|
||||
try {
|
||||
if ((copy.missing ?? []).some((s) => !COPY_BINDABLE.includes(s))) deny('credential-scope-unverified');
|
||||
const { pin, res } = resolve(business, instance, doc, record, incarnation);
|
||||
const identity = credentials.copyIdentity(slot(instance), 'gitea');
|
||||
if (!identity) deny('credential-unavailable');
|
||||
const { token } = credentials.readCopy(slot(instance), 'gitea', identity);
|
||||
await prove(doc, record, token, incarnation, deadline);
|
||||
// The origin the token was proved at is the only one a call goes to.
|
||||
const { origin } = boundRepositories(doc, res.services.gitea.projects);
|
||||
verified.set(instance, Object.freeze({
|
||||
copyId: record.copyId, incarnation, generation: record.generation, pin, grants: grantsOf(record),
|
||||
identity, mac: mac(token), services: Object.keys(res.services), origin,
|
||||
}));
|
||||
} catch (e) {
|
||||
failed.set(instance, e instanceof BusError && KEPT.test(e.code) ? e.code : 'credential-unavailable');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The current authority of a copy, or the refusal. `withToken` also returns the slot's token,
|
||||
// read now by the boot's identity and checked against the boot's MAC; only call() asks for it.
|
||||
function current(business, instance, { withToken = false } = {}) {
|
||||
if (business !== org || typeof instance !== 'string') deny('copy-unavailable');
|
||||
if (fences.get(instance)) deny('credential-revoking');
|
||||
const doc = read(business);
|
||||
const record = doc.copyBindings?.[instance];
|
||||
const v = verified.get(instance);
|
||||
if (!v) {
|
||||
if (object(record) && record.state === 'bound' && allGranted(record.services))
|
||||
deny(failed.get(instance) ?? 'restart-required');
|
||||
// Not bound: the resolver's own code (credential-unbound, a role change, a refused copy). This
|
||||
// branch always refuses and holds no credential, so an unreadable registry is no incarnation.
|
||||
let known = null;
|
||||
try { known = liveIncarnation(); } catch { known = null; }
|
||||
resolve(business, instance, doc, record, known);
|
||||
deny('restart-required');
|
||||
}
|
||||
// A verified copy holds only while its organization is active under the incarnation boot proved.
|
||||
const incarnation = liveIncarnation();
|
||||
if (!object(record) || record.state !== 'bound' || record.copyId !== v.copyId || record.orgIncarnation !== v.incarnation
|
||||
|| incarnation !== v.incarnation) deny('credential-revoked');
|
||||
if (record.generation !== v.generation || canonicalJson(grantsOf(record)) !== canonicalJson(v.grants)) deny('restart-required');
|
||||
const { res } = resolve(business, instance, doc, record, incarnation);
|
||||
if (canonicalJson(Object.keys(res.services)) !== canonicalJson(v.services)) deny('restart-required');
|
||||
// A project's remote moved to another server is a new binding: the token never goes there.
|
||||
const reach = boundRepositories(doc, res.services.gitea.projects);
|
||||
if (reach.origin !== v.origin) deny('restart-required');
|
||||
if (record.services.gitea.rotateBy < today()) deny('credential-expired');
|
||||
const identity = credentials.copyIdentity(slot(instance), 'gitea');
|
||||
if (!identity || identity.dev !== v.identity.dev || identity.ino !== v.identity.ino) deny('credential-unavailable');
|
||||
let token;
|
||||
try {
|
||||
token = credentials.readCopy(slot(instance), 'gitea', v.identity).token;
|
||||
} catch {
|
||||
deny('credential-unavailable');
|
||||
}
|
||||
if (!timingSafeEqual(mac(token), v.mac)) deny('credential-unavailable');
|
||||
const authority = sha256(canonicalJson({
|
||||
org, instance, pin: v.pin, copyId: v.copyId, incarnation: v.incarnation, generation: v.generation, grants: v.grants, services: res.services,
|
||||
origin: v.origin, repos: [...reach.repos].sort(),
|
||||
}));
|
||||
return { authority, services: v.services, record, res, reach, ...(withToken ? { doc, token } : {}) };
|
||||
}
|
||||
|
||||
const gate = {
|
||||
// { instance: [services] } of the copies the boot proved, for promoteCopies.
|
||||
bound: () => Object.fromEntries([...verified].map(([instance, v]) => [instance, [...v.services]])),
|
||||
verified: (business, instance) => business === org && verified.has(instance),
|
||||
boot,
|
||||
// Sync: the launch authority now, or the refusal.
|
||||
authority(business, instance) {
|
||||
const { authority, services } = current(business, instance);
|
||||
return { authority, services: [...services] };
|
||||
},
|
||||
// The launcher's prepare: the authority, a live proof of the slot at the service, a fresh
|
||||
// registry read, and the authority again; any change in between refuses.
|
||||
// With `revision` (the launcher's, from the bundle it resolved) the organization file must still
|
||||
// be that revision before and after the proof.
|
||||
async prepare(business, instance, revision = undefined) {
|
||||
sameRevision(business, revision);
|
||||
const before = current(business, instance, { withToken: true });
|
||||
await prove(before.doc, before.record, before.token, before.record.orgIncarnation, Date.now() + prepareMs);
|
||||
const after = current(business, instance);
|
||||
if (after.authority !== before.authority) deny('copy-authority-changed');
|
||||
sameRevision(business, revision);
|
||||
return { authority: after.authority, services: [...after.services] };
|
||||
},
|
||||
// Final admission and bindLaunch: the digest the launcher carries must still be the authority,
|
||||
// and with `revision` the organization file still the one the launch resolved.
|
||||
admit(business, instance, digest, revision = undefined) {
|
||||
if (typeof digest !== 'string') deny('copy-authority-changed');
|
||||
if (current(business, instance).authority !== digest) deny('copy-authority-changed');
|
||||
sameRevision(business, revision);
|
||||
},
|
||||
revoking: (business, instance) => business === org && Boolean(fences.get(instance)),
|
||||
// A write that can take a copy's grant away runs inside the fence. A definite answer (success,
|
||||
// or a refusal the writer gave before any change) lifts it; an unknown outcome keeps it.
|
||||
async fence(business, instance, fn) {
|
||||
if (business !== org || typeof instance !== 'string') return fn();
|
||||
fences.set(instance, (fences.get(instance) ?? 0) + 1);
|
||||
let release = true;
|
||||
try {
|
||||
return await fn();
|
||||
} catch (e) {
|
||||
// A refusal code (lowercase, the bus's and the organization writer's) is a definite answer
|
||||
// given before any change. outcome-unknown, a system error (ENOSPC, EIO) or an error with no
|
||||
// code may have changed the binding or the slot, so the fence stays.
|
||||
if (typeof e?.code !== 'string' || !/^[a-z][a-z0-9-]{1,80}$/.test(e.code) || e.code.startsWith('outcome-unknown')) release = false;
|
||||
throw e;
|
||||
} finally {
|
||||
if (release) {
|
||||
const n = fences.get(instance) - 1;
|
||||
if (n > 0) fences.set(instance, n);
|
||||
else fences.delete(instance);
|
||||
}
|
||||
}
|
||||
},
|
||||
// One checked Gitea request for a copy session: { business, role, copyAuthority } from the
|
||||
// session the bus bound, never from the caller's arguments. Returns { status, json }.
|
||||
async call(session, request) {
|
||||
if (!object(session) || !object(request)) deny('copy-request-refused');
|
||||
const { business, role: instance, copyAuthority } = session;
|
||||
if (typeof copyAuthority !== 'string') deny('copy-authority-changed');
|
||||
const { service = 'gitea', method, path, body } = request;
|
||||
if (service !== 'gitea' || typeof method !== 'string' || typeof path !== 'string' || path.length > 1024) deny('copy-request-refused');
|
||||
const m = REPO_PATH.exec(path);
|
||||
if (!m || [m[1], m[2], ...m[3].split('/').slice(1)].some((x) => x === '.' || x === '..')) deny('copy-request-refused');
|
||||
const before = current(business, instance, { withToken: true });
|
||||
if (before.authority !== copyAuthority) deny('copy-authority-changed');
|
||||
// Gitea's own semantics, enforced here: a read needs read:repository or write:repository, a
|
||||
// write needs write:repository. A binding with neither sends no repository request.
|
||||
const level = tokenLevels(before.res.services.gitea.scopes)?.repository;
|
||||
if (READ.includes(method) ? !['read', 'write'].includes(level) : !(WRITE.includes(method) && level === 'write')) deny('copy-request-refused');
|
||||
if (READ.includes(method) && body !== undefined) deny('copy-request-refused');
|
||||
const { origin, repos } = before.reach;
|
||||
if (!repos.has(`${m[1]}/${m[2]}`.toLowerCase())) deny('copy-request-refused');
|
||||
const r = await transport({ url: origin, method, path, auth: { token: before.token }, ...(body === undefined ? {} : { body }), ca, timeoutMs });
|
||||
// The answer is the session's only while the authority still holds.
|
||||
const after = current(business, instance);
|
||||
if (after.authority !== copyAuthority) deny('credential-revoked');
|
||||
if (!object(r) || !Number.isSafeInteger(r.status) || r.status === 0) deny('credential-unavailable');
|
||||
if (r.status >= 300 && r.status < 400) deny('copy-redirect-refused');
|
||||
const out = { status: r.status, json: r.json ?? null };
|
||||
assertNotHeld(out, [{ token: before.token }]);
|
||||
return out;
|
||||
},
|
||||
};
|
||||
return Object.freeze(gate);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { isDeepStrictEqual } from 'node:util';
|
||||
import { BusError } from './broker.mjs';
|
||||
import { credentialKind, isHostKind, ID_PATTERN } from '../../organization/src/vocabulary.mjs';
|
||||
import { credentialKind, isHostKind, ID_PATTERN, SERVICES } from '../../organization/src/vocabulary.mjs';
|
||||
const deny = (code) => {
|
||||
throw new BusError(code);
|
||||
};
|
||||
@@ -90,7 +90,7 @@ export function parseHostKey(text) {
|
||||
// Credentials.assertClean's test: credential-leak when the value's JSON text holds an entry's token
|
||||
// or one of its retiring model keys. org-defscan (#1873): issue's check runs it on the value it is
|
||||
// about to hold.
|
||||
function assertNotHeld(value, entries) {
|
||||
export function assertNotHeld(value, entries) {
|
||||
let encoded;
|
||||
try {
|
||||
encoded = JSON.stringify(value);
|
||||
@@ -479,6 +479,18 @@ export class Credentials {
|
||||
const value = Credentials.#format(service, token);
|
||||
check((v) => assertNotHeld(v, [value]));
|
||||
this.#invalidateModelProof(instance, service);
|
||||
this.#writeSlot(instance, service, service === 'model' ? JSON.stringify(value.model) + '\n' : value.key ? value.token : value.token + '\n');
|
||||
const file = this.#storePath(instance, service);
|
||||
commit();
|
||||
this.#refs.set(instance + ':' + service, { ref: { service, store: true, file, [service === 'vikunja' ? 'expires' : 'rotateBy']: date }, date });
|
||||
this.#tokens.set(instance + ':' + service, { ...value, date });
|
||||
this.#revoked.delete(instance + ':' + service);
|
||||
this.#held.delete(instance + ':' + service);
|
||||
return this.status().find((e) => e.instance === instance && e.service === service);
|
||||
}
|
||||
// The store slot write issue and stageCopy share: tmp (O_EXCL, O_NOFOLLOW, 0600), fsync, rename
|
||||
// over the slot, fsync the dir. Returns the identity of the file it wrote.
|
||||
#writeSlot(instance, service, text, { undo = false } = {}) {
|
||||
const file = this.#storePath(instance, service);
|
||||
const agentDir = dirname(file);
|
||||
mkdirSync(this.#storeRoot, { recursive: true, mode: 0o700 });
|
||||
@@ -493,27 +505,118 @@ export class Credentials {
|
||||
} catch (e) {
|
||||
if (e.code !== 'ENOENT') deny('credential-file');
|
||||
}
|
||||
let fd;
|
||||
// A write that fails leaves no token behind: the temp file this call made goes, and with `undo`
|
||||
// (stageCopy) so does the slot it renamed into place.
|
||||
let fd, identity;
|
||||
try {
|
||||
fd = openSync(tmp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
||||
writeSync(fd, service === 'model' ? JSON.stringify(value.model) + '\n' : value.key ? value.token : value.token + '\n');
|
||||
fsyncSync(fd);
|
||||
try {
|
||||
fd = openSync(tmp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
||||
const st = fstatSync(fd);
|
||||
identity = { dev: st.dev, ino: st.ino };
|
||||
writeSync(fd, text);
|
||||
fsyncSync(fd);
|
||||
} finally {
|
||||
if (fd !== undefined) closeSync(fd);
|
||||
}
|
||||
renameSync(tmp, file);
|
||||
} catch (e) {
|
||||
if (identity) {
|
||||
try {
|
||||
const st = lstatSync(tmp);
|
||||
if (st.isFile() && st.dev === identity.dev && st.ino === identity.ino) unlinkSync(tmp);
|
||||
} catch {}
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
try {
|
||||
const dirFd = openSync(agentDir, constants.O_RDONLY);
|
||||
try {
|
||||
fsyncSync(dirFd);
|
||||
} finally {
|
||||
closeSync(dirFd);
|
||||
}
|
||||
} catch (e) {
|
||||
if (undo) {
|
||||
try {
|
||||
this.unlinkCopy(instance, service, identity);
|
||||
} catch {}
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
return identity;
|
||||
}
|
||||
// #1873 CB2 (design section 5 step 4): a copy's service slot, <store>/<business>/<copy>/<service>.
|
||||
// The bus stages it while the copy's binding is inactive, and afterwards reads back or removes only
|
||||
// the file with the identity (dev, ino) it wrote. Memory and references never hold a copy's token
|
||||
// in this slice; launch admission (CB3) reads the slot. A name a loaded reference uses refuses.
|
||||
static checkFormat(service, token) {
|
||||
return Credentials.#format(service, token);
|
||||
}
|
||||
stageCopy(instance, service, token) {
|
||||
if (this.#refs.has(instance + ':' + service) || !SERVICES.includes(service)) deny('credential-reference');
|
||||
const value = Credentials.#format(service, token);
|
||||
return this.#writeSlot(instance, service, value.token + '\n', { undo: true });
|
||||
}
|
||||
// The staged token, read back with the store's checks plus one link only and the exact identity the
|
||||
// stage wrote: a symlink, a hard link, a replaced or a stale file refuses credential-file.
|
||||
readCopy(instance, service, identity) {
|
||||
const file = this.#storePath(instance, service);
|
||||
privateDir(this.#storeRoot);
|
||||
privateDir(dirname(dirname(file)));
|
||||
privateDir(dirname(file));
|
||||
const same = (st) => st.dev === identity?.dev && st.ino === identity?.ino;
|
||||
let original;
|
||||
try {
|
||||
original = lstatSync(file);
|
||||
} catch {
|
||||
deny('credential-file');
|
||||
}
|
||||
if (original.isSymbolicLink() || !original.isFile() || original.nlink !== 1 || !same(original)) deny('credential-file');
|
||||
let fd, text;
|
||||
try {
|
||||
fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW);
|
||||
const st = fstatSync(fd);
|
||||
if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o777) !== 0o600 || st.size > 16384 || st.nlink !== 1 || !same(st))
|
||||
deny('credential-file');
|
||||
text = readFileSync(fd, 'utf8');
|
||||
} catch (e) {
|
||||
if (e instanceof BusError) throw e;
|
||||
deny('credential-file');
|
||||
} finally {
|
||||
if (fd !== undefined) closeSync(fd);
|
||||
}
|
||||
renameSync(tmp, file);
|
||||
const dirFd = openSync(agentDir, constants.O_RDONLY);
|
||||
return Credentials.#format(service, text);
|
||||
}
|
||||
// #1873 CB3: the identity (dev, ino) of a copy's slot as it is now, for the launch gate's boot check,
|
||||
// or null when there is no single-link, self-owned 0600 regular file under private directories.
|
||||
// The gate then reads the token by this identity (readCopy) and proves it at the service.
|
||||
copyIdentity(instance, service) {
|
||||
if (this.#refs.has(instance + ':' + service) || !SERVICES.includes(service)) return null;
|
||||
try {
|
||||
fsyncSync(dirFd);
|
||||
} finally {
|
||||
closeSync(dirFd);
|
||||
const file = this.#storePath(instance, service);
|
||||
privateDir(this.#storeRoot);
|
||||
privateDir(dirname(dirname(file)));
|
||||
privateDir(dirname(file));
|
||||
const st = lstatSync(file);
|
||||
if (st.isSymbolicLink() || !st.isFile() || st.nlink !== 1 || st.uid !== process.getuid() || (st.mode & 0o777) !== 0o600) return null;
|
||||
return { dev: st.dev, ino: st.ino };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
commit();
|
||||
this.#refs.set(instance + ':' + service, { ref: { service, store: true, file, [service === 'vikunja' ? 'expires' : 'rotateBy']: date }, date });
|
||||
this.#tokens.set(instance + ':' + service, { ...value, date });
|
||||
this.#revoked.delete(instance + ':' + service);
|
||||
this.#held.delete(instance + ':' + service);
|
||||
return this.status().find((e) => e.instance === instance && e.service === service);
|
||||
}
|
||||
// Removes the slot only while it is still the file the stage wrote; another file is left alone.
|
||||
unlinkCopy(instance, service, identity) {
|
||||
const file = this.#storePath(instance, service);
|
||||
let st;
|
||||
try {
|
||||
st = lstatSync(file);
|
||||
} catch (e) {
|
||||
if (e.code === 'ENOENT') return false;
|
||||
deny('credential-file');
|
||||
}
|
||||
if (!st.isFile() || st.dev !== identity?.dev || st.ino !== identity?.ino) return false;
|
||||
unlinkSync(file);
|
||||
return true;
|
||||
}
|
||||
// Row 236 (#436b section 5, settle forward): memory takes the store file as it is now, with the
|
||||
// constructor's checks, and `date` becomes the entry's date. A held entry refuses.
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
import { decisionAutonomy, resolverSubject } from '../../organization/src/autonomy.mjs';
|
||||
import { autonomyEvent } from './autonomy-state.mjs';
|
||||
import { decisionResolution } from './autonomy-state.mjs';
|
||||
// The checks a landing makes on its merge decision and the task's review
|
||||
// (row 137, #1631). Two callers: scripts/land-work/read.mjs, which lands on
|
||||
// canonical refactor from a read-only copy of the bus, and the broker's
|
||||
@@ -96,7 +99,8 @@ export function readLanding(q, business, id, { into, arbiter = false }) {
|
||||
const raise = json(raises[0].body) ?? {};
|
||||
if (d.action !== MERGE || raise.action !== MERGE) refuse('not-a-merge', `action ${d.action}`);
|
||||
|
||||
const closes = queries.closes(q, id);
|
||||
const resolved=decisionResolution(q,b,id);
|
||||
const closes=resolved?[resolved]:[];
|
||||
if (closes.length !== 1 || closes[0].op !== 'resolved')
|
||||
refuse('not-resolved', closes.length ? closes.map((r) => r.op).join(',') : 'open');
|
||||
const res = closes[0];
|
||||
@@ -112,6 +116,13 @@ export function readLanding(q, business, id, { into, arbiter = false }) {
|
||||
// A cross-role decision routed to an agent closes only by decision.resolve, which takes it from
|
||||
// the routed role alone (s.role === route_to), so res.by here is the arbiter's run.
|
||||
const arbitrated = arbiter && d.class === 'cross-role' && d.route_to !== 'human' && res.via === 'broker' && !answers.length;
|
||||
if(arbitrated && !business.roles?.[d.route_to]?.authority?.withinRole?.includes('decision.resolve.technical'))refuse('not-resolver','The technical resolver lost decision.resolve.technical; resolve afresh under current policy.');
|
||||
if(arbitrated) {
|
||||
if(d.route_to===d.raised_by_role || resolverSubject(business,d.route_to,raise.target))refuse('not-resolver','A person decides a resolver subject.');
|
||||
const confirmed=autonomyEvent(q,b,id,['autonomy.confirmed']);
|
||||
if((decisionAutonomy('merge',business.autonomy)==='in the loop' || raise.autonomyLevel==='in the loop') && (!confirmed || confirmed.body.resolution.at!==res.at || confirmed.body.resolution.by!==res.by))refuse('confirmation-required','A person must confirm the arbiter resolution.');
|
||||
if(autonomyEvent(q,b,id,['autonomy.reverse.consume']))refuse('decision-reversed','This decision was reversed.');
|
||||
}
|
||||
if (!human && !arbitrated)
|
||||
refuse('not-human', `resolved by ${res.by} via ${res.via}, ${answers.length} human answer(s)`);
|
||||
const options = json(d.options) ?? [];
|
||||
|
||||
@@ -22,9 +22,12 @@ export function createModelCatalogue({ client, settings, pluginDataRoot = null,
|
||||
const loader = makeLoader({ log: () => {}, dataRoot:pluginDataRoot });
|
||||
const result = await loader.load({ 'model-provider': names });
|
||||
if (result.refused.length || loader.snapshot('model-provider').length !== names.length) throw Error();
|
||||
const c = client(); if (!c) throw Error();
|
||||
// A module that needs no model proxy (fixture) answers without one; a module
|
||||
// that asks the proxy while none is configured gets a refusal and reads unavailable.
|
||||
const c = client();
|
||||
const proxy = method => async (...args) => { if (!c) throw Error('model proxy not configured'); return c[method](...args); };
|
||||
const answers = await Promise.all(loader.snapshot('model-provider').map(async ({ name, module }) => {
|
||||
const answer = await module.catalogue({ listAccounts: () => c.getCredentials(), listModels: name => c.getCredentialModels(name), listGroups: () => c.getUpstreamGroups(), now: () => new Date(now()).toISOString() });
|
||||
const answer = await module.catalogue({ listAccounts: proxy('getCredentials'), listModels: proxy('getCredentialModels'), listGroups: proxy('getUpstreamGroups'), now: () => new Date(now()).toISOString() });
|
||||
if (answer?.status === 'unavailable' && answer.reason === 'model-key-collision') throw Object.assign(Error(), { code: 'model-key-collision' });
|
||||
if (answer?.status !== 'ready' || !Array.isArray(answer.models) || answer.models.length > 10000 || !Array.isArray(answer.notices) || answer.notices.length > 100) throw Error();
|
||||
for (const m of answer.models) {
|
||||
|
||||
@@ -244,7 +244,7 @@ export class ModelKeys {
|
||||
this.#receipt({ action: 'proxy.key.revoked', keyIds: [record.keyId, ...record.retiring.map(r => r.keyId)], remoteState: 'revoked', localState: 'kept' });
|
||||
} catch (error) { if (error.code === 'proxy-uncertain' || (this.#lock && this.#uncertain())) fail('proxy-uncertain'); fail('credential-revoke-failed'); }
|
||||
}
|
||||
prepareRevoke(instance) { return this.#exclusive(() => this.#prepareRevoke(instance)); }
|
||||
prepareRevoke(instance, beforeRevoke = () => {}) { return this.#exclusive(() => { beforeRevoke(); return this.#prepareRevoke(instance); }); }
|
||||
completeRevoke(instance) {
|
||||
try { this.#lock?.assertCertain(); } catch { fail('proxy-uncertain'); }
|
||||
try {
|
||||
@@ -359,7 +359,7 @@ export function modelOrganization({ modelKeys, writer }) {
|
||||
pending.set(business, prepare);
|
||||
const existed = modelKeys.hasRecord(instance);
|
||||
try {
|
||||
if (isRevoke) { await modelKeys.prepareRevoke(instance); prepare.remoteRevoked = existed; }
|
||||
if (isRevoke) { await modelKeys.prepareRevoke(instance, () => context?.beforeWrite?.()); prepare.remoteRevoked = existed; }
|
||||
return invoke(business, verb, args, actor, context);
|
||||
} catch (error) {
|
||||
if (isRevoke && prepare.remoteRevoked && (error.code === 'revision-conflict' || error.code === 'revision-conflict-revoked')) modelKeys.revisionConflict(instance);
|
||||
|
||||
@@ -40,6 +40,7 @@ const LAUNCH_OPS = new Set([
|
||||
'sessionClearUncertain',
|
||||
'sessionClearRetire',
|
||||
'sessionClearStart',
|
||||
'copyLaunch',
|
||||
'request.admission', 'request.launches', 'request.launchResult', 'request.release', 'request.chief', 'request.manifest',
|
||||
]);
|
||||
function launchOp(m) {
|
||||
@@ -90,7 +91,9 @@ function launchOp(m) {
|
||||
if (m.op === 'request.release') return runtime.requestRelease(m.cap, m.record);
|
||||
if (m.op === 'request.manifest') return runtime.requestManifest(m.record);
|
||||
if (m.op === 'request.chief') return runtime.requestChief(m.business);
|
||||
if (m.op === 'request.admission') return runtime.admission({ business: m.business, project: m.project, projects: m.projects, instance: m.instance, revision: m.revision, thread: m.thread, session: m.session });
|
||||
if (m.op === 'request.admission') return runtime.admission({ business: m.business, project: m.project, projects: m.projects, instance: m.instance, revision: m.revision, thread: m.thread, session: m.session, authority: m.authority });
|
||||
// #1873 CB3: a copy's launch prepare: the launch authority after a live proof at the service.
|
||||
if (m.op === 'copyLaunch') return runtime.copyLaunch({ business: m.business, instance: m.instance, revision: m.revision });
|
||||
if (m.op === 'credentialStatus') return runtime.credentialStatus(m.business, m.role);
|
||||
// #1873 T1 follow-up (org-defscan): the launcher's held-secret check of an org: definition.
|
||||
if (m.op === 'definitionScan') return runtime.definitionScan({ business: m.business, definition: m.definition, entry: m.entry });
|
||||
@@ -165,7 +168,7 @@ if (!process.send) {
|
||||
await runtime.close();
|
||||
return;
|
||||
}
|
||||
process.send({ ok: true, path: runtime.path, launches: runtime.launches, readers: runtime.readers });
|
||||
process.send({ ok: true, path: runtime.path, launches: runtime.launches, readers: runtime.readers, copyBound: runtime.copyBound });
|
||||
} catch (e) {
|
||||
// org-defscan: a held credential in a definition says which definition and key, never the text.
|
||||
const reason = e instanceof BusError && e.code === DEFINITION_HOLDS_CREDENTIAL && typeof e.reason === 'string' ? { reason: e.reason } : {};
|
||||
|
||||
@@ -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 } = {}) {
|
||||
|
||||
@@ -9,8 +9,35 @@ const group = (codes, rail, nextStep) => {
|
||||
entries.push([code, guidance]);
|
||||
}
|
||||
};
|
||||
group("authority-limits-unavailable", "R3/R12: future authority spend coverage", "Use authority near the loop with an enforced organization budget. Out of the loop waits for enforceable per-agent limits in every approvable definition; current-instance limits do not cover future hires.");
|
||||
group("policy-reverse-unavailable", "R3/R12: policy inverse unavailable", "Choose policy in the loop or out of the loop. Near the loop waits for a policy effect executor and a tested reverse.");
|
||||
group("invalid-autonomy autonomy-precondition reversal-unavailable", "R3/R12: validated owner autonomy and reversible effects", "Use only the four decision classes and three levels. Keep work in the loop until the class has a distinct resolver, tested inverse, notices, review and spend prerequisites; an unsupported effect needs an ordinary new decision. Authority near needs enabled limit checks and aggregate coverage of new agents. Moving out needs a near-loop resolution followed by the owner’s trail review. A failed inverse may have stopped an agent; inspect its writer receipt and use the normal launch or decision path.");
|
||||
group("confirmation-required", "R5/R12: person confirms the arbiter resolution", "The arbiter remains the resolver. Confirm its pending resolution in the owner Console, or return it with a reason for a fresh 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.");
|
||||
// #1873 CB2: a copy's service credential is accepted only after live verification at the service.
|
||||
group("credential-scope-unverified", "R1/R8: a copy credential is accepted only when the service proves its whole grant", "Gitea: approve read:user in the copy's binding (its role must declare it) so the account's repositories can be listed, give every bound project a Gitea remote on one server, and retry when the server answers. Vikunja has no verifier yet; its copy bindings stay unusable.");
|
||||
group("credential-identity-refused", "R1/R8: one dedicated service account per organization, copy and service", "Create the account the refusal names on the copy's Gitea server, not an administrator, with access to the bound projects' repositories only, and issue a token from that account. Never reuse another copy's or agent's account or token.");
|
||||
group("credential-date credential-format", "R1/R8: well-formed credentials with a future rotation date", "Supply the token on standard input alone, and a rotation date after today in YYYY-MM-DD form, no more than a year ahead for a copy credential.");
|
||||
// #1873 CB3: a copy launches only on a binding this bus verified at boot, and each use rechecks it.
|
||||
group("restart-required", "R1/R3/R8: a copy's binding is materialized only at boot, after live verification", "Ask the instance owner to restart the organization bus so it verifies the copy's newly approved or re-issued binding, then launch the copy again.");
|
||||
group("credential-revoked", "R1/R8: a revoked or replaced binding grants nothing, with no cached token", "Read the copy's binding. Ask the chief to raise a fresh binding decision for the owner, have the credential issued again, then restart the organization bus.");
|
||||
group("credential-revoking", "R1/R8: a revoke in flight blocks every new launch and call", "Wait for the revoke to finish and read its receipt. If its outcome stays unknown, ask the instance owner to restart the organization bus before launching the copy again.");
|
||||
group("credential-unavailable", "R1/R8: a copy credential is used only while the service and the stored slot prove it", "Check that the copy's Gitea server answers, then ask the owner to issue the copy's credential again and restart the organization bus. A slot changed outside the bus is never trusted.");
|
||||
group("credential-expired", "R1/R8: a copy credential stops at its rotation date", "Ask the owner to issue a fresh credential for the copy with a new rotation date, then restart the organization bus.");
|
||||
group("copy-authority-changed", "R1/R3/R8: the launch authority admitted at prepare must hold through bind and every use", "The copy's role, projects or binding changed during the launch. Read the copy's current binding and launch it again; a running session from the old authority must be stopped and relaunched.");
|
||||
group("copy-request-refused", "R1/R8: a copy calls only its bound repositories with the methods its approved scopes allow", "Call a repository of a project the copy is bound to, with GET or HEAD when the binding approved read:repository or write:repository, or a write method only when it approved write:repository. Ask the chief for a binding decision to widen it.");
|
||||
group("organization-changed organization-unreadable", "R3/R8: a launch holds to the organization file revision it resolved", "The organization file changed, or could not be read, while the launch was in progress. Launch the agent again so it resolves the file as it stands now.");
|
||||
group("copy-redirect-refused", "R1/R8: a copy credential goes only to the approved Gitea origin", "The Gitea server answered with a redirect. Fix the bound project's remote to name the server's final address, then ask the owner to issue the credential again; redirects are never followed.");
|
||||
group("project-repo-bound", "R1/R8: a binding grants the repository its projects name when the owner approved it", "Revoke the copy binding that names the project first: the chief raises an unbind decision and the owner approves it. Then set the project repository, then ask the chief to raise a new binding decision for the owner.");
|
||||
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.");
|
||||
@@ -45,6 +72,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",
|
||||
|
||||
+113
-21
@@ -1,3 +1,4 @@
|
||||
import { autonomyHost, authorityHost } from './autonomy-host.mjs';
|
||||
import { hireDecisions } from '../../organization/src/hire-decisions.mjs';
|
||||
import { organizationPlugins } from '../../plugins/src/organization.mjs';
|
||||
import { pluginService } from '../../plugins/src/tools.mjs';
|
||||
@@ -34,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';
|
||||
@@ -43,7 +45,9 @@ import { DEFINITION_HOLDS_CREDENTIAL, heldPath, scanDefinitions } from './defini
|
||||
import { ROLE_RIGHTS } from '../../organization/src/org-rights.mjs';
|
||||
import { getProjectRemote } from '../../organization/src/project-remotes.mjs';
|
||||
import { credentialVerbs } from './credential-verbs.mjs';
|
||||
import { loadWithCopies } from '../../organization/src/agent-copies.mjs';
|
||||
import { loadWithCopies, promoteCopies } from '../../organization/src/agent-copies.mjs';
|
||||
import { copyGrant, sweepCopySlots } from './copy-grant.mjs';
|
||||
import { copyLaunchGate } from './copy-launch.mjs';
|
||||
import { loadRole } from '../../organization/src/role.mjs';
|
||||
import { resolveInstance } from '../../organization/src/resolve.mjs';
|
||||
import { canonicalJson } from '../../organization/src/util.mjs';
|
||||
@@ -98,6 +102,9 @@ export async function webServices(businesses, { log = (e) => process.stderr.writ
|
||||
// checks a write runs, which are synchronous. A module that doesn't load, or answers anything but a
|
||||
// list of names, answers null, which refuses the write. The tree is pinned, so a module changes only
|
||||
// with a re-pin, and a re-pin restarts the bus.
|
||||
// AU (#1873): the same pass records each module's countsAccount() answer, which decides whether an
|
||||
// account quota covers authority hires. It is read once here; an installed harness whose lock pin
|
||||
// has changed since answers null (not covered) until the bus restarts and reads the new module.
|
||||
export async function harnessLevels({ log = (e) => process.stderr.write(`plugins: ${JSON.stringify(e)}\n`), dataRoot = null } = {}) {
|
||||
const levels = new Map();
|
||||
let names = [];
|
||||
@@ -106,22 +113,37 @@ export async function harnessLevels({ log = (e) => process.stderr.write(`plugins
|
||||
} catch (e) {
|
||||
log({ event: 'harness-levels-unread', message: e.message });
|
||||
}
|
||||
let pins = {};
|
||||
if (dataRoot !== null) {
|
||||
try { names = [...new Set([...names,...Object.keys(readPluginLock(dataRoot).plugins).filter(name=>hasModule('harness',name,dataRoot))])]; }
|
||||
try { pins = readPluginLock(dataRoot).plugins; names = [...new Set([...names,...Object.keys(pins).filter(name=>hasModule('harness',name,dataRoot))])]; }
|
||||
catch { log({event:'harness-levels-unread',message:'installed harness discovery refused'}); }
|
||||
}
|
||||
const accounts = new Map();
|
||||
for (const name of names) {
|
||||
const loader = createLoader({ log, dataRoot });
|
||||
if ((await loader.load({ harness: [name] })).refused.length) continue;
|
||||
const [module] = loader.modules('harness');
|
||||
try {
|
||||
const list = loader.modules('harness')[0].levels();
|
||||
const list = module.levels();
|
||||
if (Array.isArray(list) && list.every((level) => typeof level === 'string')) levels.set(name, Object.freeze([...list]));
|
||||
} catch (e) {
|
||||
log({ event: 'harness-levels-failed', name, message: e.message });
|
||||
}
|
||||
// AU (#1873): whether this harness's launches spend launch.account, as its module declares.
|
||||
try {
|
||||
const counts = module.countsAccount();
|
||||
if (typeof counts === 'boolean') accounts.set(name, counts);
|
||||
} catch (e) {
|
||||
log({ event: 'harness-account-failed', name, message: e.message });
|
||||
}
|
||||
}
|
||||
const lookup = (name) => levels.get(name) ?? null;
|
||||
lookup.names = [...levels.keys()].sort();
|
||||
const pinned = (name) => {
|
||||
if (!Object.hasOwn(pins, name)) return true;
|
||||
try { return readPluginLock(dataRoot).plugins[name]?.sha256 === pins[name].sha256; } catch { return false; }
|
||||
};
|
||||
lookup.countsAccount = (name) => (accounts.has(name) && pinned(name) ? accounts.get(name) : null);
|
||||
return lookup;
|
||||
}
|
||||
// Row 224 C2-05b: a host-only adapter. A failed fetch keeps serving immutable old
|
||||
@@ -299,6 +321,9 @@ export async function startBroker({
|
||||
// #1873 A3: the boot's root and project agent copies (cli config.mjs copiesAtBoot), plain data. Every
|
||||
// reload of the organization file below re-applies this snapshot, never the root file.
|
||||
copies = null,
|
||||
// #1873 CB3: the tests' Gitea transport and CA for the copy launch gate and copyGrant, { send, ca }.
|
||||
// In-process only, like librarySync; production passes none.
|
||||
copyTransport = null,
|
||||
}) {
|
||||
if (copies !== null && (typeof copies !== 'object' || Array.isArray(copies) || typeof copies.org !== 'string'
|
||||
|| !Object.hasOwn(businesses ?? {}, copies.org) || copies.copies === null || typeof copies.copies !== 'object' || Array.isArray(copies.copies)))
|
||||
@@ -315,10 +340,10 @@ 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;
|
||||
let store, credentials, server, adapter, processing, syncTimer = null, syncPass = null, accessTimer = null, requestTimer = null, copyLaunch = null;
|
||||
try {
|
||||
// Lifecycle Task 2 (#797): a product whose mapping is published but not yet activated is not part of
|
||||
// this process. A managed product is part of it only with both a committed activate receipt in the
|
||||
@@ -364,6 +389,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
|
||||
@@ -382,6 +408,20 @@ export async function startBroker({
|
||||
// Row 236 (#1742, #436b r5 section 5): each gitea grant record gives its agent a store reference to
|
||||
// gitea, and `held` names the files memory must not load until reconcile or a revoke.
|
||||
const { held } = storeRoot ? giteaStart(store, storeRoot, businesses, references) : { held: [] };
|
||||
// #1873 CB2 (security review P2): a copy slot an issue staged before a crash, whose binding holds
|
||||
// no grant for it, goes before Credentials loads and before any issue can run.
|
||||
if (storeRoot) {
|
||||
const reader = organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir, dataRoot: instanceRoot ?? dataRoot });
|
||||
const documents = Object.keys(businesses).map((id) => {
|
||||
try {
|
||||
return [id, reader.read(id).document];
|
||||
} catch {
|
||||
return [id, null];
|
||||
}
|
||||
});
|
||||
for (const entry of sweepCopySlots(storeRoot, documents, references))
|
||||
process.stderr.write(`copies: ${JSON.stringify({ event: 'copy-slot-swept', ...entry })}\n`);
|
||||
}
|
||||
// #1886 (F3 defect l): a store reference with no file yet reads as no key (design D3a), but Credentials
|
||||
// refuses a missing store root, and only issue() made it. Start makes it the same way issue() does when
|
||||
// any reference names the store. A root that exists with loose modes or as a symlink still refuses.
|
||||
@@ -401,6 +441,25 @@ export async function startBroker({
|
||||
}
|
||||
// The boot copy snapshot's definitions (the root agents file's, A4): every reload re-applies them.
|
||||
if (copies?.definitions !== undefined) scanDefinitions(copies.definitions, clean, { boot: true });
|
||||
// #1873 CB3: each unbound copy whose binding is bound is re-resolved and its slot proved live at
|
||||
// the service; only those that pass join the organization, now, before anything takes a copy of
|
||||
// `businesses`. A binding approved later answers restart-required. copyGrant keeps the boot's own
|
||||
// snapshot (bootCopies), and so does the gate: what it promotes is decided at boot only.
|
||||
const bootCopies = copies;
|
||||
if (organization && storeRoot && copies) {
|
||||
copyLaunch = copyLaunchGate({ credentials, writer: organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir, dataRoot: instanceRoot ?? dataRoot }),
|
||||
configDir: organization.dir, rolesDir: organization.rolesDir, snapshot: copies, ...(copyTransport ? { transport: copyTransport.send, ca: copyTransport.ca } : {}) });
|
||||
await copyLaunch.boot(copies.org);
|
||||
const verified = copyLaunch.bound();
|
||||
if (Object.keys(verified).length) {
|
||||
copies = promoteCopies(copies, verified);
|
||||
const b = loadOrg(copies.org);
|
||||
const resolved = {};
|
||||
for (const instance of Object.keys(b.roles)) resolved[instance] = resolveInstance({ system: organization.system, business: b, instance });
|
||||
businesses[copies.org] = withoutPending(copies.org, busOrganization(b, resolved));
|
||||
if (proxy) for (const instance of Object.keys(verified)) if (Object.hasOwn(businesses[copies.org].roles, instance)) credentials.registerModel(copies.org + '/' + instance);
|
||||
}
|
||||
}
|
||||
if (proxy) {
|
||||
const inventory = modelRunInventory(dataRoot, modelRuns, credentials);
|
||||
modelKeys = new ModelKeys({ credentials, client: modelProxyClient, configDir: org ? instanceConfigDir : organization.dir, org: org?.id ?? null, lock: proxyLock,
|
||||
@@ -592,6 +651,9 @@ export async function startBroker({
|
||||
&& fixedAtStart(b).libraries === boot.libraries
|
||||
&& Object.entries(fixedAtStart(b).roles).every(([role, f]) => f === (boot.roles[role] ?? fixedRole(null)));
|
||||
const booted = new Map(Object.entries(businesses).map(([id, b]) => [id, fixedAtStart(b)]));
|
||||
// #1873 CB3: a copy the boot snapshot had unbound (whether the gate promoted it or not).
|
||||
const copyNeedsAuthority = (id, instance) => copyLaunch !== null && bootCopies?.org === id
|
||||
&& Object.hasOwn(bootCopies.copies, instance) && bootCopies.copies[instance].state === 'unbound';
|
||||
const reloadLog = entry => process.stderr.write(`organization: ${JSON.stringify(entry)}\n`);
|
||||
function reload(id) {
|
||||
try {
|
||||
@@ -657,7 +719,10 @@ export async function startBroker({
|
||||
// project as the business stands now: a project that excludes it, or whose union refuses, is not ready.
|
||||
// `instance` comes with `revision`, the organization file revision the launch resolved its bundle
|
||||
// from; a file that has changed since (a narrow, a project define's vars, an exclude) is not ready.
|
||||
function admission({ business: id, project, projects, instance, revision: launched, thread, session }) {
|
||||
// #1873 CB3: with `instance`, a copy the boot left needing a binding is admitted only on
|
||||
// `authority`, the launch authority digest its prepare returned, still current now. Checked
|
||||
// before the projects, so a launch with no project is held to it as well.
|
||||
function admission({ business: id, project, projects, instance, revision: launched, thread, session, authority }) {
|
||||
if (projects !== undefined) {
|
||||
const no = code => ({ ready: false, revision: null, code });
|
||||
if (typeof id !== 'string' || !Object.hasOwn(businesses, id) || !Array.isArray(projects) || projects.some(p => typeof p !== 'string')) return no('project-unknown');
|
||||
@@ -682,6 +747,9 @@ export async function startBroker({
|
||||
try { clean(name); held = heldPath(now.document.definitions?.[name], clean); } catch { held = 'name'; }
|
||||
if (held !== null) return no(DEFINITION_HOLDS_CREDENTIAL);
|
||||
}
|
||||
if (copyNeedsAuthority(id, instance)) {
|
||||
try { copyLaunch.admit(id, instance, authority); } catch (e) { return no(e?.code ?? 'copy-authority-changed'); }
|
||||
}
|
||||
}
|
||||
for (const p of projects) {
|
||||
const result = admission({ business: id, project: p });
|
||||
@@ -771,6 +839,12 @@ export async function startBroker({
|
||||
// Row 236 (#1742): the Gitea grant path. The admin slot is <configDir>/secrets/grants/<business>/gitea.
|
||||
giteaGrant: organization ? giteaGrant({ configDir: organization.dir }) : null,
|
||||
giteaKeys: storeRoot ? giteaKeys({ credentials, storeRoot }) : null,
|
||||
// #1873 CB2: credentials.issue for an agent copy's bound Gitea service, verified live at the server.
|
||||
copyGrant: organization && storeRoot
|
||||
? copyGrant({ credentials, writer: organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir, dataRoot: instanceRoot ?? dataRoot }),
|
||||
copies: bootCopies, organizations: () => Object.keys(businesses), ...(copyTransport ? { transport: copyTransport.send, ca: copyTransport.ca } : {}) })
|
||||
: null,
|
||||
copyLaunch,
|
||||
usage,
|
||||
limits,
|
||||
store,
|
||||
@@ -890,23 +964,13 @@ export async function startBroker({
|
||||
return INSTANCE_VERBS.has(verb)?{...result,restart:!live}:result;
|
||||
}):null,
|
||||
// Row 194 (D2.3): the role files as they stand, for the hire checks.
|
||||
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});
|
||||
return {...service,apply:async(id,decision,caller)=>{
|
||||
const proposal=service.read(decision);
|
||||
const publish=async()=>{
|
||||
let approved=caller;
|
||||
if(modelKeys && proposal.op==='fire' && proposal.level!=='org') {
|
||||
const instance=`${id}/${proposal.level==='project'?`${proposal.name}.${proposal.project}`:proposal.name}`;
|
||||
await modelKeys.prepareRevoke(instance);
|
||||
approved={...caller,beforeWrite:()=>modelKeys.completeRevoke(instance)};
|
||||
}
|
||||
const result=await service.apply(id,decision,approved);reload(id);return result;
|
||||
};
|
||||
if(proxyOrganization && proposal.level==='org' && proposal.op==='hire')return proxyOrganization.hire(id,proposal.name,()=>service.check(id,decision,caller),publish);
|
||||
return publish();
|
||||
}};
|
||||
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,
|
||||
secretCheck: (value) => credentials.assertClean(value),
|
||||
@@ -920,9 +984,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 })
|
||||
@@ -1268,8 +1341,27 @@ export async function startBroker({
|
||||
credentials,
|
||||
// P2 consumes this inside the bus only. No process IPC/verb returns it.
|
||||
get modelProxy() { return modelProxyClient; },
|
||||
// #1873 CB3: the copies the boot proved live, { instance: [service] }; names only.
|
||||
copyBound: Object.freeze(copyLaunch ? copyLaunch.bound() : {}),
|
||||
path,
|
||||
bindLaunch,
|
||||
// #1873 CB3: the launcher's prepare for a copy the boot left needing a binding: the launch
|
||||
// authority { authority, services } after a live proof at the service, or the refusal. Any
|
||||
// other instance needs none and gets { authority: null }.
|
||||
// `revision` is the organization file revision the launch resolved its bundle from (A3); a
|
||||
// copy's prepare holds the file to it.
|
||||
copyLaunch: async ({ business, instance, revision }) => {
|
||||
if (typeof business !== 'string' || typeof instance !== 'string') throw new BusError('invalid-request');
|
||||
if (!copyNeedsAuthority(business, instance)) return { authority: null, services: [] };
|
||||
if (typeof revision !== 'string') throw new BusError('invalid-request');
|
||||
return copyLaunch.prepare(business, instance, revision);
|
||||
},
|
||||
// #1873 CB3: one checked Gitea request for a bound copy's run. Who it is for comes from the
|
||||
// capability's session, never from the request. Returns { status, json }; nothing holds the token.
|
||||
copyCall: async (cap, request) => {
|
||||
if (!copyLaunch) throw new BusError('credential-unavailable');
|
||||
return copyLaunch.call(broker.copySession(cap), request);
|
||||
},
|
||||
recoverLaunch: ({ modelKeyId, ...record }) => broker.recoverLaunch(record),
|
||||
endLaunch: (r) => { const out = broker.endLaunch(r); modelRuns.delete(r.business + ':' + r.run); return out; },
|
||||
modelAdmission: async ({ business, instance, modelKey, harness, revision, entry: expected, provider, model, account, route, transport }) => {
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {mkdtempSync,mkdirSync,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 {autonomyHost,authorityHost} from '../src/autonomy-host.mjs';
|
||||
import {organizationWriter} from '../../organization/src/organization.mjs';
|
||||
import {instanceSettings} from '../../organization/src/instance-settings.mjs';
|
||||
import {hireDecisions} from '../../organization/src/hire-decisions.mjs';
|
||||
import {loadBusiness} from '../../organization/src/organization-config.mjs';
|
||||
import {businessDoc,rolesCopy,writeJson} from '../../organization/tests/helpers.mjs';
|
||||
const inline={tools:['read'],network:'none',authority:{withinRole:[],crossRole:[]},credentials:[],instructions:'Changed role'};
|
||||
const cap={limit:100000,since:'2026-01-01T00:00:00.000Z'};
|
||||
function world(t,options={}) {
|
||||
const root=mkdtempSync(join(tmpdir(),'au-authority-')),rolesDir=rolesCopy(root),dataRoot=join(root,'data');mkdirSync(dataRoot,{mode:0o700});
|
||||
const doc=businessDoc(root,'demo');doc.plugins={harness:['pi'],'limit-check':['token-limit']};
|
||||
doc.launch.limits.tokens={providers:{zai:cap}};
|
||||
// #1873: an account quota alone, covered only through the host's declared harness fact.
|
||||
if(options.account){doc.launch.account={name:'shared',credential:'FIXTURE_TOKEN'};doc.launch.limits.tokens={keys:{shared:cap}};}
|
||||
doc.definitions={judge:{rev:1,role:{...inline,authority:{withinRole:['decision.resolve.authority'],crossRole:[]}},instructions:'Judge'},scout:{rev:3,role:structuredClone(inline),instructions:'Prior role'}};
|
||||
// Definition instructions live beside the role composition.
|
||||
for(const d of Object.values(doc.definitions))delete d.role.instructions;
|
||||
doc.roles.judge={definition:'org:judge',vars:{harness:'pi',model:'claude-opus-5-5'}};
|
||||
doc.roles.scout={definition:'org:scout',vars:{harness:'pi',model:'claude-opus-5-5'}};
|
||||
doc.launch.instances.push('scout');doc.autonomy={authority:'near the loop',authorityResolver:'judge',notices:true,reverseWindowHours:1};
|
||||
writeJson(join(root,'businesses/demo.json'),doc);
|
||||
let reversing=false,broker,now=Date.now;
|
||||
const writer=organizationWriter({dir:root,rolesDir,barrier:point=>{if(reversing && point==='prepared' && options.expirePrepared)Date.now=()=>now()+3600001;if(!reversing && point==='prepared' && options.removeResolverPrepared){const b=structuredClone(loaded());b.roles.judge.authority.withinRole=[];broker.reloadBusiness('demo',b);}}});
|
||||
const store=new Store(dataRoot),order=[];
|
||||
const loaded=()=>{const b=loadBusiness('demo',{dir:root,rolesDir});return {...b,roles:Object.fromEntries(Object.entries(b.roles).map(([n,e])=>[n,{...b.definitions[e.definition],...e}]))};};
|
||||
const reload=id=>{if(reversing && options.crossAfterCommit)Date.now=()=>now()+3600001;if(reversing && options.reloadRefused)return false;broker.reloadBusiness(id,loaded());return true;};
|
||||
const service=hireDecisions({writer,rolesDir,configDir:root,dataRoot,assertClean:()=>{}});
|
||||
const hires=authorityHost(service,{reload,modelKeys:{prepareRevoke:async(instance,guard)=>{guard();assert.equal(instance,'demo/scout');order.push('prepare');},completeRevoke:instance=>{assert.equal(instance,'demo/scout');assert.ok(store.get("SELECT 1 FROM events WHERE json_extract(body,'$.action')='fire.stopped'"));assert.equal(writer.read('demo').document.definitions.scout.instructions,'Changed role');order.push('complete');}}});
|
||||
const host=autonomyHost({writer,dataRoot,business:()=>loaded(),hires,reload,...(options.countsAccount?{countsAccount:options.countsAccount}:{})});
|
||||
const instances=instanceSettings({dir:root,rolesDir,writer,revoke:()=>{order.push('fire-revoke');}});
|
||||
broker=new Broker({store,businesses:{demo:loaded()},hires,autonomy:host,organization:(id,verb,args,actor,context)=>{if(verb==='instance.fireCheck'){instances.checkFire(id,args.name);return {};}if(verb==='instance.fire')return instances.fire(id,args,{actor,context});throw Error('unexpected organization verb');}});
|
||||
const caps={};const call=(who,verb,args={})=>broker.request(caps[who],{verb,args});
|
||||
for(const role of ['chief','judge','scout']){caps[role]=broker.bindLaunch({business:'demo',role,run:role+'-run',harness:'pi'});call(role,'role.claim');}
|
||||
caps.owner=broker.bindConsole({business:'demo',human:'jason',subject:{provider:'test',iss:'test',sub:'owner'}});
|
||||
t.after(()=>{Date.now=now;store.close();rmSync(root,{recursive:true,force:true});});
|
||||
const raise=(extra={})=>call('chief','hire.raise',{op:'change',level:'org',name:'scout',role:{inline},route:{harness:'pi',model:'claude-opus-5-5'},reason:'Change the task',...extra});
|
||||
const reverse=id=>{reversing=true;return call('owner','decision.reverse.console',{id});};
|
||||
return {writer,store,order,broker,call,raise,reverse,loaded};
|
||||
}
|
||||
for(const crossAfterCommit of [false,true])test(`AU authority reverse uses runtime key wrapper and relaunches after commit (deadline crossed: ${crossAfterCommit})`,async t=>{
|
||||
const f=world(t,{crossAfterCommit}),d=await f.raise();assert.deepEqual(d.hire.flagged,[]);assert.equal(d.route_to,'judge');
|
||||
await f.call('judge','decision.resolve',{id:d.id,choice:'yes'});assert.equal(f.writer.read('demo').document.definitions.scout.rev,4);
|
||||
const r=await f.reverse(d.id);assert.equal(r.committed,true);assert.equal(r.relaunch,'requested');assert.deepEqual(f.order,['prepare','complete']);
|
||||
const restored=f.writer.read('demo').document.definitions.scout;assert.equal(restored.rev,5);assert.equal(restored.instructions,'Prior role');
|
||||
assert.equal(f.store.get("SELECT count(*) n FROM events WHERE json_extract(body,'$.operation')='autonomy.reverse'").n,1);
|
||||
assert.equal(f.store.get("SELECT count(*) n FROM events WHERE json_extract(body,'$.operation')='autonomy.reverse.failed'").n,0);
|
||||
assert.ok(f.store.get("SELECT 1 FROM events WHERE json_extract(body,'$.action')='lifecycle.launch' AND subject='scout'"));
|
||||
});
|
||||
test('AU a committed authority reverse reports pending recovery when reload refuses',async t=>{
|
||||
const f=world(t,{reloadRefused:true}),d=await f.raise();await f.call('judge','decision.resolve',{id:d.id,choice:'yes'});
|
||||
const r=await f.reverse(d.id);assert.equal(r.committed,true);assert.equal(r.restart,true);assert.equal(r.relaunch,'pending');assert.equal(f.writer.read('demo').document.definitions.scout.instructions,'Prior role');
|
||||
assert.equal(f.store.get("SELECT count(*) n FROM events WHERE json_extract(body,'$.operation')='autonomy.reverse.failed'").n,0);
|
||||
});
|
||||
test('AU authority reverse crossing deadline in prepared IO never publishes',async t=>{
|
||||
const f=world(t,{expirePrepared:true}),d=await f.raise();await f.call('judge','decision.resolve',{id:d.id,choice:'yes'});const prior=f.writer.read('demo').revision;
|
||||
await assert.rejects(f.reverse(d.id),{code:'reverse-window-closed'});assert.equal(f.writer.read('demo').revision,prior);f.writer.recover('demo');assert.equal(f.writer.read('demo').revision,prior);
|
||||
});
|
||||
test('AU authority resolver rechecks budget removal after a proposal was raised',async t=>{
|
||||
const f=world(t),d=await f.raise(),b=structuredClone(f.loaded());b.plugins['limit-check']=[];f.broker.reloadBusiness('demo',b);
|
||||
const before=f.writer.read('demo').revision;await assert.rejects(f.call('judge','decision.resolve',{id:d.id,choice:'yes'}),{code:'autonomy-precondition'});
|
||||
assert.equal(f.writer.read('demo').revision,before);assert.equal(f.store.get('SELECT count(*) n FROM decision_events WHERE decision=?',d.id).n,0);
|
||||
});
|
||||
|
||||
test('AU authority apply rechecks a removed resolve right at publication',async t=>{
|
||||
const f=world(t,{removeResolverPrepared:true}),d=await f.raise(),before=f.writer.read('demo').revision;
|
||||
await assert.rejects(f.call('judge','decision.resolve',{id:d.id,choice:'yes'}),{code:'not-resolver'});
|
||||
assert.equal(f.writer.read('demo').revision,before);f.writer.recover('demo');assert.equal(f.writer.read('demo').revision,before);
|
||||
});
|
||||
test('AU authority hire reverse runs normal fire/revoke and records committed delisting',async t=>{
|
||||
const f=world(t),d=await f.raise({op:'hire',name:'helper'});await f.call('judge','decision.resolve',{id:d.id,choice:'yes'});
|
||||
assert.ok(f.writer.read('demo').document.launch.instances.includes('helper'));
|
||||
const r=await f.reverse(d.id);assert.equal(r.committed,true);assert.ok(!f.writer.read('demo').document.launch.instances.includes('helper'));assert.deepEqual(f.order,['fire-revoke']);
|
||||
assert.equal(f.store.get("SELECT count(*) n FROM events WHERE json_extract(body,'$.operation')='autonomy.reverse'").n,1);
|
||||
});
|
||||
|
||||
test('AU completion-event failure after durable authority inverse records committed recovery, never reverse.failed',async t=>{
|
||||
const f=world(t),d=await f.raise();await f.call('judge','decision.resolve',{id:d.id,choice:'yes'});
|
||||
const run=f.store.run.bind(f.store);let injected=false;
|
||||
f.store.run=(sql,...args)=>{
|
||||
if(!injected && sql.includes('INSERT INTO events') && args.some(x=>typeof x==='string' && x.startsWith('{') && JSON.parse(x).action==='fire.done')){
|
||||
injected=true;throw new Error('injected completion event failure');
|
||||
}
|
||||
return run(sql,...args);
|
||||
};
|
||||
const r=await f.reverse(d.id);assert.equal(injected,true);assert.equal(r.committed,true);assert.equal(r.completion,'failed');assert.equal(r.restart,true);assert.equal(r.relaunch,'pending');
|
||||
assert.equal(f.writer.read('demo').document.definitions.scout.instructions,'Prior role');
|
||||
assert.equal(f.writer.read('demo').document.definitions.scout.rev,5);
|
||||
assert.equal(f.store.get("SELECT count(*) n FROM events WHERE json_extract(body,'$.operation')='autonomy.reverse'").n,1);
|
||||
assert.equal(f.store.get("SELECT count(*) n FROM events WHERE json_extract(body,'$.operation')='autonomy.reverse.failed'").n,0);
|
||||
});
|
||||
|
||||
test('AU inverse receipt-write failure returns committed with trailPending and never writes reverse.failed',async t=>{
|
||||
const f=world(t),d=await f.raise();await f.call('judge','decision.resolve',{id:d.id,choice:'yes'});
|
||||
const run=f.store.run.bind(f.store);let injected=false;
|
||||
f.store.run=(sql,...args)=>{
|
||||
if(sql.includes('INSERT INTO events') && args.some(x=>typeof x==='string' && x.startsWith('{') && JSON.parse(x).operation==='autonomy.reverse')){
|
||||
injected=true;throw new Error('injected receipt write failure');
|
||||
}
|
||||
return run(sql,...args);
|
||||
};
|
||||
const r=await f.reverse(d.id);assert.equal(injected,true);assert.equal(r.committed,true);assert.equal(r.trailPending,true);assert.equal(r.relaunch,'requested');
|
||||
assert.equal(f.writer.read('demo').document.definitions.scout.instructions,'Prior role');
|
||||
assert.equal(f.store.get("SELECT count(*) n FROM events WHERE json_extract(body,'$.operation')='autonomy.reverse.failed'").n,0);
|
||||
});
|
||||
test('AU an account-quota authority raise routes to the resolver only when the host says every harness spends the account (#1873)',async t=>{
|
||||
const covered=world(t,{account:true,countsAccount:name=>name==='pi'}),d=await covered.raise();assert.equal(d.route_to,'judge');
|
||||
for(const countsAccount of [undefined,()=>false,()=>null]){
|
||||
const f=world(t,{account:true,countsAccount});
|
||||
await assert.rejects(f.raise(),{code:'autonomy-precondition'});
|
||||
assert.equal(f.store.get("SELECT count(*) n FROM decisions").n,0);
|
||||
}
|
||||
});
|
||||
// #1873 review finding 2: the runtime itself carries each harness module's countsAccount into the
|
||||
// broker's authority budget check. Only harness modules decide; the business names none here but
|
||||
// through plugins.harness.
|
||||
for(const [harness,code] of [['claude-code',null],['pi','autonomy-precondition']])test(`AU runtime: an account-quota authority setting with only ${harness} enabled ${code?'refuses':'saves'} (#1873)`,async t=>{
|
||||
const {startBroker}=await import('../src/runtime.mjs');
|
||||
const {systemFor}=await import('../../organization/tests/helpers.mjs');
|
||||
const root=mkdtempSync(join(tmpdir(),'au-runtime-')),rolesDir=rolesCopy(root);t.after(()=>rmSync(root,{recursive:true,force:true}));
|
||||
const doc=businessDoc(root,'demo');doc.plugins={harness:[harness],'limit-check':['token-limit']};
|
||||
for(const r of Object.values(doc.roles))if(r.vars?.harness)r.vars.harness=harness;
|
||||
doc.launch.account={name:'shared',credential:'FIXTURE_TOKEN'};doc.launch.limits.tokens={keys:{shared:cap}};
|
||||
doc.definitions={judge:{rev:1,role:{...inline,authority:{withinRole:['decision.resolve.authority'],crossRole:[]}},instructions:'Judge'}};
|
||||
delete doc.definitions.judge.role.instructions;
|
||||
doc.roles.judge={definition:'org:judge',vars:{harness,model:'claude-opus-5-5'}};
|
||||
writeJson(join(root,'businesses/demo.json'),doc);mkdirSync(join(root,'data'),{mode:0o700});
|
||||
const loaded=()=>{const b=loadBusiness('demo',{dir:root,rolesDir});return {...b,roles:Object.fromEntries(Object.entries(b.roles).map(([n,e])=>[n,{...b.definitions[e.definition],...e}]))};};
|
||||
const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:loaded()},organization:{dir:root,rolesDir,system:systemFor(root)}});t.after(()=>bus.close());
|
||||
const owner=bus.broker.bindConsole({business:'demo',human:'jason',subject:{provider:'test',iss:'test',sub:'owner'}});
|
||||
const {revision}=await bus.broker.request(owner,{verb:'autonomy.get.console'});
|
||||
const set=bus.broker.request(owner,{verb:'autonomy.set.console',args:{revision,autonomy:{authority:'near the loop',authorityResolver:'judge',notices:true}}});
|
||||
if(code)await assert.rejects(set,{code});
|
||||
else {await set;assert.equal(JSON.parse(readFileSync(join(root,'businesses/demo.json'),'utf8')).autonomy.authority,'near the loop');}
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {mkdtempSync,mkdirSync,writeFileSync,rmSync} from 'node:fs';
|
||||
import {tmpdir} from 'node:os';
|
||||
import {join} from 'node:path';
|
||||
import {execFileSync} from 'node:child_process';
|
||||
import {Store} from '../src/store.mjs';
|
||||
import {Broker} from '../src/broker.mjs';
|
||||
import {autonomyHost} from '../src/autonomy-host.mjs';
|
||||
import {initializeWorking,workingRepo,push,prepareLand,finishLand,dropLand} from '../../seat/src/working.mjs';
|
||||
import {gitEnv} from '../../seat/src/checkout.mjs';
|
||||
const TASK='vikunja:1/2',SUBJECT={provider:'test',iss:'https://issuer.test',sub:'owner'};
|
||||
const git=(cwd,...args)=>execFileSync('git',['-c','user.name=Test','-c','[email protected]',...args],{cwd,encoding:'utf8',env:gitEnv()}).trim();
|
||||
async function world(t,level='near the loop') {
|
||||
const dir=mkdtempSync(join(tmpdir(),'au-')),root=join(dir,'root'),dataRoot=join(dir,'data');mkdirSync(root);mkdirSync(dataRoot,{mode:0o700});
|
||||
git(root,'init','-q','-b','main');writeFileSync(join(root,'file'),'before\n');git(root,'add','file');git(root,'commit','-qm','Initial');
|
||||
const before=git(root,'rev-parse','HEAD');
|
||||
const clone=join(dir,'clone');git(dir,'clone','-q',root,clone);git(clone,'checkout','-qb','feat');writeFileSync(join(clone,'file'),'after\n');git(clone,'commit','-qam','Change');const reviewed=git(clone,'rev-parse','HEAD');
|
||||
const bundle=join(dir,'bundle');git(clone,'bundle','create',bundle,'refs/heads/feat','--not','--remotes=origin');
|
||||
await initializeWorking({dataRoot,business:'demo',project:'stack',root});
|
||||
await push({dataRoot,business:'demo',project:'stack',root,taskRef:TASK,branch:'feat',bundle});
|
||||
const authority=(withinRole=[],crossRole=[])=>({withinRole,crossRole});
|
||||
const business={id:'demo',human:'jason',projects:{stack:{root}},autonomy:{merge:level,notices:true,reverseWindowHours:1},arbiters:{technical:'cto',delivery:'cto'},roles:{cto:{definition:'cto',authority:authority(['decision.resolve.technical'])},coder:{definition:'coder',authority:authority(['git.push.working','review.request','message.send'])},reviewer:{definition:'reviewer',authority:authority(['review.verdict','message.send'])},gate:{definition:'merge-gate',authority:authority([],['git.merge.protected'])}}};
|
||||
let gates=0;
|
||||
const host=autonomyHost({dataRoot,business:()=>business,processing:{work:fn=>fn({guard(){},signal:undefined})},gate:{classify:async()=>true,run:async()=>{gates++;return {ok:true};}}});
|
||||
const store=new Store(dataRoot),broker=new Broker({store,businesses:{demo:business},autonomy:host}),caps={};
|
||||
t.after(()=>{store.close();rmSync(dir,{recursive:true,force:true});});
|
||||
const call=(who,verb,args={})=>broker.request(caps[who],{verb,args});
|
||||
for(const role of Object.keys(business.roles)){caps[role]=broker.bindLaunch({business:'demo',role,run:role+'-run',harness:'pi'});call(role,'role.claim');}
|
||||
caps.human=broker.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});
|
||||
caps.owner=broker.bindConsole({business:'demo',human:'jason',subject:SUBJECT});
|
||||
broker.checkPush(caps.coder,TASK);broker.recordPush(caps.coder,{task_ref:TASK,project:'stack',branch:'feat',ref:'work/vikunja-1-2',commit:reviewed,previous:null});
|
||||
const request=call('coder','review.request',{task_ref:TASK,commit:reviewed,to:'reviewer'});
|
||||
call('reviewer','review.verdict',{task_ref:TASK,request:request.request,verdict:'approve',body:'Reviewed'});
|
||||
const d=call('gate','decision.raise',{action:'git.merge.protected',project:'stack',target:TASK,question:'Land work/vikunja-1-2?',options:[{key:'yes',text:'Land change'},{key:'no',text:'Hold'}],recommendation:'yes',blocking:false});
|
||||
call('cto','decision.resolve',{id:d.id,choice:'yes'});
|
||||
const land=async()=>{
|
||||
const facts=broker.checkLand(caps.gate,{task_ref:TASK,decision:d.id});
|
||||
const logs=join(dir,'land');mkdirSync(logs,{recursive:true});
|
||||
const prepared=await prepareLand({dataRoot,business:'demo',project:'stack',root,taskRef:TASK,commit:reviewed,dir:logs,name:'original',rootHits:()=>[],message:()=> 'Change'});
|
||||
try {await finishLand({dataRoot,business:'demo',project:'stack',land:prepared,record:r=>broker.recordLand(facts.ticket,{commit:r.commit,previous:r.previous,reviewed,base:prepared.base,patch_id:prepared.patch_id})});}finally{await dropLand({dataRoot,business:'demo',project:'stack',worktree:prepared.worktree});}
|
||||
return prepared.commit;
|
||||
};
|
||||
return {broker,business,store,caps,call,d,before,land,gates:()=>gates,show:(...args)=>git(dir,`--git-dir=${workingRepo(dataRoot,'demo','stack')}`,...args)};
|
||||
}
|
||||
test('AU near merge lands immediately, owner notice reverses through gate and records a real child revert',async t=>{
|
||||
const f=await world(t),landed=await f.land();
|
||||
assert.equal(f.show('show','main:file'),'after');assert.equal(f.call('human','inbox').find(x=>x.id===f.d.id).autonomy.notice,true);
|
||||
const reverse=await f.call('owner','decision.reverse.console',{id:f.d.id});
|
||||
assert.equal(f.gates(),1);assert.equal(f.show('show','main:file'),'before');assert.equal(f.show('rev-parse','main^'),landed);
|
||||
assert.match(f.show('log','-1','--format=%B','main'),new RegExp(f.d.id));
|
||||
assert.equal(f.store.get("SELECT count(*) n FROM events WHERE json_extract(body,'$.operation')='autonomy.reverse'").n,1);
|
||||
assert.equal(reverse.reverted,landed);
|
||||
});
|
||||
test('AU in-loop landing refuses until confirmation; decline requires a new arbiter resolution',async t=>{
|
||||
const f=await world(t,'in the loop');
|
||||
await assert.rejects(f.land(),e=>['not-resolved','confirmation-required'].includes(e.code));
|
||||
f.call('owner','decision.confirm.console',{id:f.d.id,confirm:false,reason:'Review again'});
|
||||
assert.throws(()=>f.call('owner','decision.confirm.console',{id:f.d.id,confirm:true}),{code:'decision-not-eligible'});
|
||||
f.call('cto','decision.resolve',{id:f.d.id,choice:'yes'});f.call('owner','decision.confirm.console',{id:f.d.id,confirm:true});
|
||||
await f.land();assert.equal(f.show('show','main:file'),'after');
|
||||
assert.throws(()=>f.call('owner','decision.confirm.console',{id:f.d.id,confirm:true}),{code:'decision-not-eligible'});
|
||||
});
|
||||
test('AU landing rechecks the resolver right both before land and after the gate',async t=>{
|
||||
const f=await world(t);const facts=f.broker.checkLand(f.caps.gate,{task_ref:TASK,decision:f.d.id});
|
||||
f.business.roles.cto.authority.withinRole=[];f.broker.reloadBusiness('demo',f.business);
|
||||
assert.throws(()=>f.broker.checkLand(f.caps.gate,{task_ref:TASK,decision:f.d.id}),{code:'not-resolver'});
|
||||
assert.throws(()=>f.broker.recordLand(facts.ticket,{commit:'a'.repeat(40),previous:f.before,reviewed:'b'.repeat(40),base:f.before,patch_id:'c'.repeat(40)}),{code:'not-resolver'});
|
||||
assert.equal(f.show('rev-parse','main'),f.before);
|
||||
});
|
||||
@@ -0,0 +1,164 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { fixture } from './spec-fixtures.mjs';
|
||||
const SUBJECT = {provider:'test',iss:'https://issuer.test',sub:'owner'};
|
||||
async function setup(t, autonomy, autonomyHost=null) {
|
||||
const f=await fixture(t,{autonomyHost,roles:{coder:{definition:'coder',authority:{withinRole:[],crossRole:['git.merge.protected','task.scope.change','policy.change','prd.approve']}}}});
|
||||
if(autonomy) {f.business.autonomy=autonomy;f.broker.reloadBusiness('demo',f.business);}
|
||||
f.owner=f.broker.bindConsole({business:'demo',human:'jason',subject:SUBJECT});
|
||||
return f;
|
||||
}
|
||||
test('AU no setting keeps merge with technical arbiter',async t=>{
|
||||
const f=await setup(t);const d=f.raise({action:'git.merge.protected'});assert.equal(d.route_to,'chief');
|
||||
});
|
||||
test('AU in-loop merge keeps the arbiter resolver, waits for person, decline returns to arbiter',async t=>{
|
||||
const f=await setup(t,{merge:'in the loop'}),d=f.raise({action:'git.merge.protected',target:'vikunja:1/2'});
|
||||
assert.equal(d.route_to,'chief');
|
||||
assert.throws(()=>f.call(f.human,'decision.resolve',{id:d.id,choice:'yes'}),{code:'not-resolver'});
|
||||
f.call(f.chief,'decision.resolve',{id:d.id,choice:'yes'});
|
||||
assert.throws(()=>f.broker.authorize(f.coder,'git.merge.protected',{decision:d.id,target:'vikunja:1/2'}),{code:'confirmation-required'});
|
||||
f.call(f.owner,'decision.confirm.console',{id:d.id,confirm:false,reason:'Please revise'});
|
||||
assert.equal(f.call(f.chief,'inbox').find(x=>x.id===d.id)?.id,d.id);
|
||||
f.call(f.chief,'decision.resolve',{id:d.id,choice:'yes'});
|
||||
f.call(f.owner,'decision.confirm.console',{id:d.id,confirm:true});
|
||||
assert.equal(f.broker.authorize(f.coder,'git.merge.protected',{decision:d.id,target:'vikunja:1/2'}).decision,d.id);
|
||||
});
|
||||
test('AU R5 subject resolver or resolver definition routes to person',async t=>{
|
||||
const f=await setup(t);
|
||||
for(const target of ['chief','chief-of-staff'])assert.equal(f.raise({action:'policy.change',target}).route_to,'human');
|
||||
});
|
||||
test('AU mismatched PRD domain refuses at raise with expected domain',async t=>{
|
||||
const f=await setup(t);
|
||||
assert.throws(()=>f.raise({action:'prd.approve',domain:'delivery'}),e=>e.code==='invalid-domain' && /technical/.test(e.reason));
|
||||
assert.equal(f.store.get("SELECT count(*) n FROM decisions WHERE action='prd.approve'").n,0);
|
||||
});
|
||||
test('AU revoked resolver right refuses when an approved decision is consumed',async t=>{
|
||||
const f=await setup(t),d=f.raise({action:'task.scope.change',target:'vikunja:1/2'});
|
||||
f.call(f.chief,'decision.resolve',{id:d.id,choice:'yes'});
|
||||
f.business.roles.chief.authority.withinRole=[];f.broker.reloadBusiness('demo',f.business);
|
||||
assert.throws(()=>f.broker.authorize(f.coder,'task.scope.change',{decision:d.id,target:'vikunja:1/2'}),{code:'not-resolver'});
|
||||
});
|
||||
test('AU member, agent and dev tester cannot change settings',async t=>{
|
||||
const revision='a'.repeat(64),f=await setup(t,null,{get:()=>({revision,autonomy:{}})});
|
||||
const member=f.broker.bindHireConsole({business:'demo',actor:'member',subject:{...SUBJECT,sub:'member'},rights:['org.hire']});
|
||||
for(const cap of [f.coder,member])assert.throws(()=>f.call(cap,'autonomy.set.console',{revision,autonomy:{merge:'near the loop'}}),{code:'human-required'});
|
||||
assert.throws(()=>f.call(f.human,'autonomy.set.console',{revision,autonomy:{merge:'near the loop'}}),{code:'human-required'});
|
||||
});
|
||||
test('AU near-loop merge sends owner a bounded notice; expired reverse names ordinary path',async t=>{
|
||||
const f=await setup(t,{merge:'near the loop',notices:true,reverseWindowHours:1}),d=f.raise({action:'git.merge.protected'});
|
||||
f.call(f.chief,'decision.resolve',{id:d.id,choice:'yes'});
|
||||
const n=f.call(f.human,'inbox').find(x=>x.id===d.id);
|
||||
assert.equal(n.autonomy.notice,true);assert.ok(Date.parse(n.autonomy.reverseUntil)>Date.now());
|
||||
const now=Date.now;
|
||||
try{Date.now=()=>now()+3600001;await assert.rejects(Promise.resolve().then(()=>f.call(f.owner,'decision.reverse.console',{id:d.id})),e=>e.code==='reverse-window-closed' && /ordinary.*decision/.test(e.reason));}finally{Date.now=now;}
|
||||
});
|
||||
test('AU no setting can move each hard wait from a person',async t=>{
|
||||
const f=await setup(t,{'cross-role':'out of the loop',merge:'near the loop',authority:'out of the loop'});
|
||||
for(const action of ['git.push.protected','host.sudo','credential.mint','spend','deploy','data.delete']){
|
||||
if(action==='host.sudo')continue; // Its host grant precondition is covered in hard-waits.test.mjs.
|
||||
assert.equal(f.raise({action}).route_to,'human');
|
||||
}
|
||||
});
|
||||
test('AU owner setting changes have immutable actor trail; bad classes/levels refuse; tester refused',async t=>{
|
||||
let f,autonomy={},revision='a'.repeat(64);
|
||||
const host={get:()=>({autonomy,revision}),set:(_b,a)=>{autonomy=a.autonomy;revision='b'.repeat(64);f.business.autonomy=autonomy;f.broker.reloadBusiness('demo',f.business);return {revision,transaction:'transaction-1'};},reversible:()=>true};
|
||||
f=await setup(t,null,host);
|
||||
await f.call(f.owner,'autonomy.set.console',{revision,autonomy:{merge:'in the loop'}});
|
||||
assert.equal(f.store.all("SELECT actor_role,actor_run,body FROM events WHERE json_extract(body,'$.operation')='autonomy.set'").length,1);
|
||||
const row=f.store.get("SELECT body FROM events WHERE json_extract(body,'$.operation')='autonomy.set'");assert.deepEqual(JSON.parse(row.body).subject,SUBJECT);
|
||||
for(const value of [{unknown:'near the loop'},{merge:'fast'}])await assert.rejects(f.call(f.owner,'autonomy.set.console',{revision,autonomy:value}),{code:'invalid-autonomy'});
|
||||
f.business.vars={'bus.tester':true};f.broker.reloadBusiness('demo',f.business);const tester=f.broker.bindTester({business:'demo'});
|
||||
assert.throws(()=>f.call(tester,'autonomy.set.console',{revision,autonomy:{}}),{code:'human-required'});
|
||||
assert.throws(()=>f.broker.recordEvent(f.coder,{kind:'action.allowed',body:{operation:'autonomy.confirmed',decision:'forged'}}),{code:'reserved-event'});
|
||||
});
|
||||
test('AU reverse consumes once and rechecks the window at the effect boundary',async t=>{
|
||||
let effects=0;
|
||||
const host={reverse:async({beforeWrite,record})=>{beforeWrite();effects++;return {commit:'b'.repeat(40)};}};
|
||||
const f=await setup(t,{merge:'near the loop',notices:true},host),d=f.raise({action:'git.merge.protected'});
|
||||
f.call(f.chief,'decision.resolve',{id:d.id,choice:'yes'});
|
||||
const r=await f.call(f.owner,'decision.reverse.console',{id:d.id});assert.equal(r.reversed,true);assert.equal(effects,1);
|
||||
await assert.rejects(f.call(f.owner,'decision.reverse.console',{id:d.id}),{code:'decision-reversed'});
|
||||
assert.equal(f.store.get("SELECT count(*) n FROM events WHERE json_extract(body,'$.operation')='autonomy.reverse'").n,1);
|
||||
});
|
||||
test('AU a near-loop reverse that crosses the deadline cannot write',async t=>{
|
||||
let effects=0;const now=Date.now;
|
||||
const f=await setup(t,{merge:'near the loop',notices:true,reverseWindowHours:1},{reverse:async({beforeWrite})=>{Date.now=()=>now()+3600001;beforeWrite();effects++;return {};}});
|
||||
const d=f.raise({action:'git.merge.protected'});f.call(f.chief,'decision.resolve',{id:d.id,choice:'yes'});
|
||||
try{await assert.rejects(f.call(f.owner,'decision.reverse.console',{id:d.id}),{code:'reverse-window-closed'});}finally{Date.now=now;}
|
||||
assert.equal(effects,0);
|
||||
});
|
||||
|
||||
test('AU policy near refuses with a named next step; in and the existing out level remain usable',async t=>{
|
||||
let f,autonomy={},revision='a'.repeat(64);
|
||||
const host={get:()=>({autonomy,revision}),set:(_b,a)=>{autonomy=a.autonomy;revision='b'.repeat(64);f.business.autonomy=autonomy;f.broker.reloadBusiness('demo',f.business);return {revision,transaction:'policy-setting'};},reversible:()=>false};
|
||||
f=await setup(t,null,host);
|
||||
await assert.rejects(f.call(f.owner,'autonomy.set.console',{revision,autonomy:{policy:'near the loop'}}),e=>e.code==='policy-reverse-unavailable' && /in the loop or out of the loop/.test(e.nextStep));
|
||||
assert.deepEqual(autonomy,{});
|
||||
await f.call(f.owner,'autonomy.set.console',{revision,autonomy:{policy:'in the loop'}});
|
||||
const d=f.raise({action:'policy.change',target:'policy-1'});f.call(f.chief,'decision.resolve',{id:d.id,choice:'yes'});
|
||||
assert.throws(()=>f.broker.authorize(f.coder,'policy.change',{decision:d.id,target:'policy-1'}),{code:'confirmation-required'});
|
||||
f.call(f.owner,'decision.confirm.console',{id:d.id,confirm:true});
|
||||
assert.equal(f.broker.authorize(f.coder,'policy.change',{decision:d.id,target:'policy-1'}).decision,d.id);
|
||||
await f.call(f.owner,'autonomy.set.console',{revision,autonomy:{policy:'out of the loop'}});
|
||||
const out=f.raise({action:'policy.change',target:'policy-2'});f.call(f.chief,'decision.resolve',{id:out.id,choice:'yes'});
|
||||
assert.equal(f.broker.authorize(f.coder,'policy.change',{decision:out.id,target:'policy-2'}).decision,out.id);
|
||||
});
|
||||
test('AU near cross-role authorization without a trusted inverse fails closed before consumption',async t=>{
|
||||
const f=await setup(t,{'cross-role':'near the loop',notices:true}),d=f.raise({action:'task.scope.change',target:'vikunja:1/2'});
|
||||
f.call(f.chief,'decision.resolve',{id:d.id,choice:'yes'});
|
||||
assert.throws(()=>f.broker.authorize(f.coder,'task.scope.change',{decision:d.id,target:'vikunja:1/2'}),{code:'reversal-unavailable'});
|
||||
assert.equal(f.store.get("SELECT count(*) n FROM events WHERE json_extract(body,'$.decision')=? AND json_extract(body,'$.action')='task.scope.change' AND json_extract(body,'$.operation') IS NOT 'decision.raise'",d.id).n,0);
|
||||
});
|
||||
|
||||
|
||||
test('AU a withdrawal after decline is terminal for resolver, owner and confirmation',async t=>{
|
||||
const f=await setup(t,{merge:'in the loop'}),d=f.raise({action:'git.merge.protected'});
|
||||
f.call(f.chief,'decision.resolve',{id:d.id,choice:'yes'});
|
||||
f.call(f.owner,'decision.confirm.console',{id:d.id,confirm:false,reason:'Revise'});
|
||||
f.call(f.coder,'decision.withdraw',{id:d.id});
|
||||
assert.throws(()=>f.call(f.chief,'decision.resolve',{id:d.id,choice:'yes'}),{code:'decision-closed'});
|
||||
for(const cap of [f.chief,f.human])assert.ok(!f.call(cap,'inbox').some(x=>x.id===d.id));
|
||||
assert.throws(()=>f.call(f.owner,'decision.confirm.console',{id:d.id,confirm:true}),e=>['decision-closed','decision-not-eligible'].includes(e.code));
|
||||
});
|
||||
async function settingsFixture(t) {
|
||||
let f,autonomy={},revision='a'.repeat(64),writes=0;
|
||||
const host={get:()=>({autonomy,revision}),set:(_b,a)=>{writes++;autonomy=a.autonomy;revision='b'.repeat(64);f.business.autonomy=autonomy;f.broker.reloadBusiness('demo',f.business);return {revision,transaction:'setting-test'};},reversible:()=>true};
|
||||
f=await setup(t,null,host);
|
||||
f.business.roles.judge={definition:'judge',authority:{withinRole:['decision.resolve.authority'],crossRole:[]}};
|
||||
f.business.roles.reviewer.authority.withinRole.push('review.verdict');
|
||||
f.business.launch={by:'chief',models:{model:{provider:'anthropic',model:'test-model'}},limits:{tokens:{}}};
|
||||
f.business.plugins={'limit-check':['token-limit']};
|
||||
f.broker.reloadBusiness('demo',f.business);
|
||||
return {...f,set:value=>f.call(f.owner,'autonomy.set.console',{revision,autonomy:value}),writes:()=>writes};
|
||||
}
|
||||
const authorityNear={authority:'near the loop',authorityResolver:'judge',notices:true};
|
||||
const tokenCap={limit:1000,since:'2026-01-01T00:00:00.000Z'};
|
||||
test('AU authority near requires an enforced organization budget covering every allowed model, including zero caps',async t=>{
|
||||
const f=await settingsFixture(t);
|
||||
for(const tokens of [{},{instances:{coder:tokenCap}},{providers:{unrelated:tokenCap}}]){
|
||||
f.business.launch.limits.tokens=tokens;f.broker.reloadBusiness('demo',f.business);
|
||||
await assert.rejects(f.set(authorityNear),{code:'autonomy-precondition'});
|
||||
}
|
||||
f.business.launch.limits.tokens={providers:{anthropic:{...tokenCap,limit:0}}};
|
||||
f.business.plugins={'limit-check':[]};f.broker.reloadBusiness('demo',f.business);
|
||||
await assert.rejects(f.set(authorityNear),{code:'autonomy-precondition'});
|
||||
f.business.plugins={'limit-check':['token-limit']};f.broker.reloadBusiness('demo',f.business);
|
||||
await f.set(authorityNear);assert.equal(f.writes(),1);
|
||||
});
|
||||
test('AU authority out refuses until per-definition agent limits exist, even with current agents capped',async t=>{
|
||||
const f=await settingsFixture(t);
|
||||
f.business.launch.limits.tokens={providers:{anthropic:tokenCap},instances:Object.fromEntries(Object.keys(f.business.roles).map(n=>[n,tokenCap]))};
|
||||
f.broker.reloadBusiness('demo',f.business);await f.set(authorityNear);
|
||||
f.call(f.owner,'autonomy.review.console',{decisionClass:'authority'});
|
||||
await assert.rejects(f.set({...authorityNear,authority:'out of the loop'}),e=>e.code==='authority-limits-unavailable' && /near the loop/.test(e.nextStep));
|
||||
assert.equal(f.writes(),1);
|
||||
});
|
||||
test('AU moving out requires an actual near resolution and a later owner review',async t=>{
|
||||
const f=await settingsFixture(t);await f.set({merge:'in the loop'});await f.set({merge:'near the loop',notices:true});
|
||||
f.call(f.owner,'autonomy.review.console',{decisionClass:'merge'});
|
||||
await assert.rejects(f.set({merge:'out of the loop',notices:true}),{code:'autonomy-precondition'});
|
||||
const d=f.raise({action:'git.merge.protected'});f.call(f.chief,'decision.resolve',{id:d.id,choice:'yes'});
|
||||
await assert.rejects(f.set({merge:'out of the loop',notices:true}),{code:'autonomy-precondition'});
|
||||
f.call(f.owner,'autonomy.review.console',{decisionClass:'merge'});
|
||||
await f.set({merge:'out of the loop',notices:true});
|
||||
});
|
||||
@@ -89,7 +89,7 @@ function setup(t) {
|
||||
// #797 / #1839 adds the owner-only artifact read to the existing decision and request controls;
|
||||
// no unsuffixed verb is ever a console verb.
|
||||
test('N4: owner Console verbs have an explicit immutable whitelist and exclude decision.resolve', () => {
|
||||
assert.deepEqual([...CONSOLE_VERBS], ['system-types.console', 'project.remote.get.console', 'project.remote.set.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'hire.preview.console', 'hire.resolve.console', 'hire.start.console', 'hire.templates.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
|
||||
assert.deepEqual([...CONSOLE_VERBS], ['system-types.console', 'project.remote.get.console', 'project.remote.set.console', 'autonomy.get.console', 'autonomy.set.console', 'autonomy.review.console', 'decision.confirm.console', 'decision.reverse.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'hire.preview.console', 'hire.resolve.console', 'hire.start.console', 'hire.templates.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
|
||||
'request.get.console', 'request.projects.console', 'request.answer.console', 'request.attach.console', 'request.cancel.console']);
|
||||
assert.ok(CONSOLE_VERBS.every(verb => verb.endsWith('.console')));
|
||||
assert.equal(CONSOLE_VERBS.includes('decision.resolve'), false);
|
||||
@@ -227,7 +227,8 @@ test('ordinary PRD approval and nine non-yes/no choices use the same protocol wi
|
||||
test('ordinary role routes refuse Console resolution and agent answers retain the actual role/run', (t) => {
|
||||
const f = setup(t);
|
||||
for (const route of ['pm', 'chief']) {
|
||||
const id = grantDecision(f.store, { action: 'policy.change', route, close: null });
|
||||
const id = grantDecision(f.store, { action: 'policy.change', route, raiser:route==='chief'?'pm':'chief', close: null });
|
||||
|
||||
refusesSync(() => f.call('console', 'decision.preview.console', { id }), 'decision-not-eligible');
|
||||
refusesSync(() => f.call('console', 'decision.resolve.console', { id, choice: 'yes', digest: '0'.repeat(64) }), 'decision-not-eligible');
|
||||
f.call(route, 'decision.resolve', { id, choice: 'yes' });
|
||||
|
||||
@@ -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,227 @@
|
||||
// #1873 CB2 live acceptance: credentials.issue for an agent copy's Gitea service against a scratch
|
||||
// gitea/gitea:1.27.1, a throwaway container on 127.0.0.1 over TLS with a scratch CA, removed after.
|
||||
// The test never pulls an image; without Docker or the image it skips. Every token here is a scratch
|
||||
// token this container made; none is printed, and nothing here reaches git.mosaicstack.dev or any
|
||||
// real service.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { request } from 'node:https';
|
||||
import { createServer } from 'node:net';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { Broker, BusError } from '../src/broker.mjs';
|
||||
import { Store } from '../src/store.mjs';
|
||||
import { Credentials } from '../src/credentials.mjs';
|
||||
import { copyGrant } from '../src/copy-grant.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, copiesView } from '../../organization/src/agent-copies.mjs';
|
||||
import { activateOrg, reserveOrg } from '../../organization/src/org-registry.mjs';
|
||||
import { copyBotLogin } from '../../organization/src/copy-bindings.mjs';
|
||||
import { businessDoc, rolesCopy, writeJson } from '../../organization/tests/helpers.mjs';
|
||||
import { scratchCa } from '../../seat/tests/fake-gitea.mjs';
|
||||
|
||||
const IMAGE = 'gitea/gitea:1.27.1';
|
||||
const SUBJECT = { provider: 'test', iss: 'https://issuer.test', sub: 'owner' };
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const ahead = (days) => new Date(Date.now() + days * 86400000).toISOString().slice(0, 10);
|
||||
const sha = (s) => createHash('sha256').update(s).digest('hex');
|
||||
const docker = (...args) => spawnSync('docker', args, { encoding: 'utf8', timeout: 120000 });
|
||||
const skip = docker('image', 'inspect', IMAGE).status === 0 ? false : `no Docker or no local ${IMAGE}; this test never pulls`;
|
||||
|
||||
const freePort = () =>
|
||||
new Promise((resolve, reject) => {
|
||||
const s = createServer();
|
||||
s.once('error', reject);
|
||||
s.listen(0, '127.0.0.1', () => {
|
||||
const { port } = s.address();
|
||||
s.close(() => resolve(port));
|
||||
});
|
||||
});
|
||||
|
||||
// One HTTPS call to the scratch container, trusting only the scratch CA.
|
||||
function call(url, ca, method, path, { auth = null, body = null } = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const data = body ? JSON.stringify(body) : null;
|
||||
const headers = { accept: 'application/json', ...(data ? { 'content-type': 'application/json' } : {}), ...(auth ? { authorization: auth } : {}) };
|
||||
const req = request(url + path, { method, ca, headers, timeout: 10000 }, (res) => {
|
||||
let text = '';
|
||||
res.on('data', (c) => (text += c));
|
||||
res.on('end', () => {
|
||||
let json = null;
|
||||
try {
|
||||
json = text ? JSON.parse(text) : null;
|
||||
} catch {}
|
||||
resolve({ status: res.statusCode, json });
|
||||
});
|
||||
});
|
||||
req.on('timeout', () => req.destroy());
|
||||
req.on('error', () => resolve({ status: 0, json: null }));
|
||||
req.end(data ?? undefined);
|
||||
});
|
||||
}
|
||||
|
||||
test('CB2-D: on scratch 1.27.1 an owner-issued copy token is proved live: equal and narrower bind; broader, all, admin, foreign, unrelated and opaque refuse', { skip, timeout: 300000 }, async (t) => {
|
||||
// Capture the child's complete TAP stdout/stderr before any of it reaches this runner, so the parent
|
||||
// can search for the actual minted values without putting one in an assertion.
|
||||
if (!process.env.CB2_COPY_CAPTURE) {
|
||||
const capture = mkdtempSync(join(tmpdir(), 'cb2cap-'));
|
||||
t.after(() => rmSync(capture, { recursive: true, force: true }));
|
||||
const file = join(capture, 'minted');
|
||||
const childEnv = { ...process.env, CB2_COPY_CAPTURE: file };
|
||||
delete childEnv.NODE_TEST_CONTEXT;
|
||||
const child = spawnSync(process.execPath, ['--test', '--test-name-pattern=^CB2-D:', fileURLToPath(import.meta.url)], {
|
||||
encoding: 'utf8', timeout: 280000, env: childEnv,
|
||||
});
|
||||
const values = existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : [];
|
||||
const output = (child.stdout ?? '') + (child.stderr ?? '');
|
||||
assert.ok(values.every((value) => !output.includes(value) && !output.includes(sha(value))), 'complete TAP stdout/stderr contain no minted token');
|
||||
t.diagnostic(output);
|
||||
t.diagnostic(`minted ${values.length}, hits 0 (complete TAP stdout/stderr)`);
|
||||
assert.equal(child.status, 0, 'Docker child passed');
|
||||
assert.equal(values.length, 6, 'all six scratch tokens were minted: the acceptance actually ran');
|
||||
return;
|
||||
}
|
||||
|
||||
const dir = mkdtempSync(join(tmpdir(), 'cb2d-'));
|
||||
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
||||
const tlsDir = join(dir, 'tls'), tls = scratchCa(tlsDir);
|
||||
// The container's git user reads the scratch key; it is a throwaway key for this run only.
|
||||
chmodSync(tlsDir, 0o755);
|
||||
chmodSync(join(tlsDir, 'server.key'), 0o644);
|
||||
const ca = readFileSync(tls.ca, 'utf8'), port = await freePort();
|
||||
const url = `https://127.0.0.1:${port}`, name = 'cb2-' + randomBytes(6).toString('hex');
|
||||
t.after(() => docker('rm', '-f', name));
|
||||
const started = docker('run', '-d', '--rm', '--name', name, '-p', `127.0.0.1:${port}:3000`, '-v', `${tlsDir}:/certs:ro`,
|
||||
'-e', 'GITEA__security__INSTALL_LOCK=true', '-e', 'GITEA__database__DB_TYPE=sqlite3',
|
||||
'-e', 'GITEA__service__DISABLE_REGISTRATION=true', '-e', 'GITEA__server__PROTOCOL=https',
|
||||
'-e', 'GITEA__server__CERT_FILE=/certs/server.pem', '-e', 'GITEA__server__KEY_FILE=/certs/server.key',
|
||||
'-e', `GITEA__server__ROOT_URL=${url}/`, IMAGE);
|
||||
assert.equal(started.status, 0, 'scratch container starts');
|
||||
const end = Date.now() + 120000;
|
||||
while ((await call(url, ca, 'GET', '/api/v1/version')).status !== 200) {
|
||||
assert.ok(Date.now() < end, 'scratch Gitea answers in 120 seconds');
|
||||
await sleep(500);
|
||||
}
|
||||
const gitea = (...args) => docker('exec', '-u', 'git', name, 'gitea', ...args);
|
||||
const minted = [];
|
||||
const createUser = (user, admin = false) => {
|
||||
const r = gitea('admin', 'user', 'create', '--username', user, '--email', `${user}@example.test`,
|
||||
'--password', randomBytes(16).toString('base64url'), '--must-change-password=false', ...(admin ? ['--admin'] : []));
|
||||
assert.equal(r.status, 0, `create scratch ${user}`);
|
||||
};
|
||||
const mint = (user, scopes) => {
|
||||
const r = gitea('admin', 'user', 'generate-access-token', '--username', user, '--token-name', 'cb2-' + randomBytes(4).toString('hex'),
|
||||
'--scopes', scopes.join(','), '--raw');
|
||||
assert.equal(r.status, 0, `mint scratch ${user}`);
|
||||
const value = r.stdout.trim();
|
||||
assert.ok(/^[0-9a-f]{40}$/.test(value), 'minted token shape');
|
||||
minted.push(value);
|
||||
writeFileSync(process.env.CB2_COPY_CAPTURE, JSON.stringify(minted), { mode: 0o600 });
|
||||
return value;
|
||||
};
|
||||
createUser('scratch-admin', true);
|
||||
createUser('owner');
|
||||
const admin = 'token ' + mint('scratch-admin', ['write:admin', 'write:repository', 'write:user']);
|
||||
for (const repo of ['stack', 'other']) {
|
||||
const made = await call(url, ca, 'POST', '/api/v1/admin/users/owner/repos', { auth: admin, body: { name: repo, auto_init: true, private: true } });
|
||||
assert.equal(made.status, 201, `create owner/${repo}`);
|
||||
}
|
||||
|
||||
// The organization: one root agent "rev" whose copy binding approves write:repository and
|
||||
// read:user on project stack, whose remote is the scratch owner/stack.
|
||||
const root = mkdtempSync(join(tmpdir(), 'cb2o-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const rolesDir = rolesCopy(root);
|
||||
const doc = { ...businessDoc(root, 'demo'), plugins: { harness: ['pi'] }, vars: { 'bus.tester': true } };
|
||||
doc.projects.stack.remote = `${url}/owner/stack`;
|
||||
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' };
|
||||
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(dataRoot);
|
||||
t.after(() => store.close());
|
||||
const service = hireDecisions({ writer, rolesDir, dataRoot, configDir: root, assertClean: () => {}, copies: (id) => (snapshot.org === id ? snapshot : null) });
|
||||
const loaded = () => {
|
||||
const d = loadBusiness('demo', { dir: root, rolesDir });
|
||||
// The boot's copies view, as the runtime's loadWithCopies gives the broker.
|
||||
return { ...d, roles: Object.fromEntries(Object.entries(d.roles).map(([n, e]) => [n, { ...d.definitions[e.definition], ...e }])), copies: copiesView(snapshot.copies) };
|
||||
};
|
||||
const storeRoot = join(root, 'secrets', 'agents');
|
||||
mkdirSync(storeRoot, { recursive: true, mode: 0o700 });
|
||||
chmodSync(join(root, 'secrets'), 0o700);
|
||||
const credentials = new Credentials({ references: {}, storeRoot, repoRoots: [], dataRoot: '/nonexistent-data' });
|
||||
const issue = copyGrant({ credentials, writer, copies: snapshot, organizations: () => ['demo'], ca, timeoutMs: 10000 });
|
||||
const b = new Broker({
|
||||
store, businesses: { demo: loaded() },
|
||||
hires: { ...service, apply: async (...args) => { const r = await service.apply(...args); b.reloadBusiness('demo', loaded()); return r; } },
|
||||
credentialVerbs: () => { throw new BusError('credential-unknown'); }, copyGrant: (...a) => issue(...a),
|
||||
});
|
||||
const consoleCap = b.bindConsole({ business: 'demo', human: 'jason', subject: SUBJECT });
|
||||
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const request_ = (cap, verb, args) => Promise.resolve().then(() => b.request(cap, { verb, args }));
|
||||
await request_(consoleCap, 'hire.start.console', { op: 'change', level: 'root', name: 'rev', reason: 'Bind the copy',
|
||||
binding: { services: { gitea: { scopes: ['write:repository', 'read:user'], projects: ['stack'] } } } });
|
||||
const rec = () => JSON.parse(readFileSync(file, 'utf8')).copyBindings.rev;
|
||||
assert.equal(rec().state, 'pending');
|
||||
const slot = join(storeRoot, 'demo', 'rev', 'gitea');
|
||||
const issueToken = (token) => request_(human, 'credentials.issue', { agent: 'rev', service: 'gitea', date: ahead(30), token });
|
||||
|
||||
// The copy's dedicated account: a read collaborator on owner/stack only.
|
||||
const login = copyBotLogin(rec().orgIncarnation, rec().copyId, 'gitea');
|
||||
createUser(login);
|
||||
const collaborate = (repo, permission) => call(url, ca, 'PUT', `/api/v1/repos/owner/${repo}/collaborators/${login}`, { auth: admin, body: { permission } });
|
||||
assert.equal((await collaborate('stack', 'read')).status, 204, 'bot is a read collaborator on owner/stack');
|
||||
|
||||
const refused = async (token, code, label) => {
|
||||
await assert.rejects(issueToken(token), { code }, label);
|
||||
assert.equal(rec().state, 'pending', label);
|
||||
assert.equal(rec().services.gitea.grantId, undefined, label);
|
||||
assert.ok(!existsSync(slot), `${label}: a refused token leaves no slot`);
|
||||
};
|
||||
|
||||
// Equal: the approved scopes exactly.
|
||||
const equal = mint(login, ['write:repository', 'read:user']);
|
||||
const self = await call(url, ca, 'GET', '/api/v1/token', { auth: 'token ' + equal });
|
||||
assert.equal(self.status, 200);
|
||||
assert.equal(self.json.user.login, login, 'Gitea 1.27.1 names the token owner on /api/v1/token');
|
||||
const one = await issueToken(equal);
|
||||
assert.equal(one.state, 'bound');
|
||||
assert.equal(one.account, login);
|
||||
assert.deepEqual([rec().state, rec().services.gitea.grantId, rec().services.gitea.grantGeneration], ['bound', `gitea:${self.json.user.id}:${self.json.id}`, 1]);
|
||||
assert.equal(statSync(slot).mode & 0o777, 0o600);
|
||||
assert.equal(readFileSync(slot, 'utf8'), equal + '\n');
|
||||
|
||||
// Narrower: read within the approved write.
|
||||
const narrow = mint(login, ['read:repository', 'read:user']);
|
||||
await issueToken(narrow);
|
||||
assert.deepEqual([rec().state, rec().services.gitea.grantGeneration], ['bound', 2]);
|
||||
assert.equal(readFileSync(slot, 'utf8'), narrow + '\n');
|
||||
|
||||
// Broader in one category, and "all": exceeded; a failed re-issue deactivates the binding.
|
||||
await refused(mint(login, ['write:repository', 'write:user']), 'credential-scope-exceeded', 'write:user beyond read:user');
|
||||
await refused(mint(login, ['all']), 'credential-scope-exceeded', 'all');
|
||||
// The administrator's own token, inside the approved scopes: not the copy's account.
|
||||
await refused(mint('scratch-admin', ['read:repository', 'read:user']), 'credential-identity-refused', 'admin account');
|
||||
// A token Gitea never issued.
|
||||
await refused(randomBytes(20).toString('hex'), 'credential-scope-unverified', 'opaque token');
|
||||
// An unrelated repository the account can also reach.
|
||||
assert.equal((await collaborate('other', 'read')).status, 204);
|
||||
await refused(narrow, 'credential-scope-exceeded', 'unrelated repository');
|
||||
|
||||
// Nothing kept names a token, its digest, the slot path or a request.
|
||||
const kept = [file, join(root, 'businesses/.history/demo/changes.jsonl')].filter(existsSync).map((p) => readFileSync(p, 'utf8')).join('\n')
|
||||
+ ['events', 'decisions'].map((tb) => JSON.stringify(store.all(`SELECT * FROM ${tb}`))).join('\n');
|
||||
for (const value of minted) assert.ok(!kept.includes(value) && !kept.includes(sha(value)) && !kept.includes(sha(value + '\n')), 'kept text holds no token');
|
||||
for (const bad of [slot, storeRoot, '/api/v1', 'authorization']) assert.ok(!kept.includes(bad), 'kept text holds no path or request');
|
||||
t.diagnostic(`minted ${minted.length}`);
|
||||
});
|
||||
@@ -0,0 +1,558 @@
|
||||
// #1873 CB2 (design section 5 steps 4 to 6, section 8 row CB2): credentials.issue for an agent copy's
|
||||
// bound Gitea service, verified live against a fake Gitea before any grant metadata lands. The
|
||||
// organization writer's half is packages/organization/tests/copy-grant-writer.test.mjs; the real
|
||||
// server acceptance is copy-grant-docker.test.mjs.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { chmodSync, existsSync, linkSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, renameSync, rmSync, statSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Broker, BusError } from '../src/broker.mjs';
|
||||
import { Store } from '../src/store.mjs';
|
||||
import { Credentials } from '../src/credentials.mjs';
|
||||
import { REFUSALS } from '../src/refusals.mjs';
|
||||
import { copyGrant, tokenLevels, verifyGiteaToken, boundRepositories, MAX_ROTATE_DAYS, sweepCopySlots } from '../src/copy-grant.mjs';
|
||||
import { fakeGiteaApi } from './fake-gitea-api.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, copiesView } from '../../organization/src/agent-copies.mjs';
|
||||
import { activateOrg, reserveOrg } from '../../organization/src/org-registry.mjs';
|
||||
import { copyBotLogin } from '../../organization/src/copy-bindings.mjs';
|
||||
import { businessDoc, rolesCopy, writeJson } from '../../organization/tests/helpers.mjs';
|
||||
|
||||
const SUBJECT = { provider: 'test', iss: 'https://issuer.test', sub: 'owner' };
|
||||
const READ = { gitea: { scopes: ['read:repository', 'read:user'], projects: ['stack'] } };
|
||||
const ahead = (days) => new Date(Date.now() + days * 86400000).toISOString().slice(0, 10);
|
||||
const present = (p) => { try { lstatSync(p); return true; } catch { return false; } };
|
||||
const sha = (s) => createHash('sha256').update(s).digest('hex');
|
||||
|
||||
async function fixture(t, { services = READ, edit = (d) => d } = {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'cb2-')), rolesDir = rolesCopy(root);
|
||||
const api = await fakeGiteaApi(t, root);
|
||||
const base = { ...businessDoc(root, 'demo'), plugins: { harness: ['pi'] }, vars: { 'bus.tester': true } };
|
||||
base.projects.stack.agents = { checker: { definition: 'reviewer' } };
|
||||
base.projects.stack.remote = `${api.url}/owner/stack`;
|
||||
const doc = edit(base);
|
||||
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' };
|
||||
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(dataRoot);
|
||||
const service = hireDecisions({ writer, rolesDir, dataRoot, configDir: root, assertClean: () => {}, 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 }])), copies: copiesView((opts.copies ?? snapshot).copies) }; };
|
||||
const storeRoot = join(root, 'secrets', 'agents');
|
||||
mkdirSync(storeRoot, { recursive: true, mode: 0o700 }); chmodSync(join(root, 'secrets'), 0o700);
|
||||
const credentials = new Credentials({ references: {}, storeRoot, repoRoots: [], dataRoot: '/nonexistent-data' });
|
||||
const hooks = {};
|
||||
const opts = { timeoutMs: 3000 };
|
||||
const issue = (...a) => copyGrant({ credentials, writer, copies: opts.copies ?? snapshot, organizations: () => ['demo'], ca: api.ca, timeoutMs: opts.timeoutMs, hooks })(...a);
|
||||
const direct = (args, ctx) => issue('demo', args, ctx);
|
||||
const b = new Broker({ store, businesses: { demo: loaded() }, hires: { ...service, apply: async (...args) => { const r = await service.apply(...args); b.reloadBusiness('demo', loaded()); return r; } },
|
||||
credentialVerbs: () => { throw new BusError('credential-unknown'); }, copyGrant: (...a) => issue(...a) });
|
||||
const owner = b.bindConsole({ business: 'demo', human: 'jason', subject: SUBJECT });
|
||||
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const chief = b.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' });
|
||||
b.request(chief, { verb: 'role.claim' });
|
||||
const call = (cap, verb, args = {}) => Promise.resolve().then(() => b.request(cap, { verb, args }));
|
||||
const bind = (name = 'rev', svc = services, extra = {}) => call(owner, 'hire.start.console', { op: 'change', level: 'root', name, reason: 'Bind the copy', binding: { services: svc }, ...extra });
|
||||
await bind();
|
||||
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
const rec = (name = 'rev') => JSON.parse(readFileSync(file, 'utf8')).copyBindings?.[name];
|
||||
const repo = api.addRepo('owner', 'stack');
|
||||
// The copy's dedicated account, a read collaborator on owner/stack, and one token from it.
|
||||
const bot = (name = 'rev', { perm = 'read', admin = false } = {}) => {
|
||||
const r = rec(name);
|
||||
const login = copyBotLogin(r.orgIncarnation, r.copyId, 'gitea');
|
||||
const u = api.users.get(login) ?? api.addUser(login, { is_admin: admin }).user;
|
||||
repo.collaborators.push(login); repo.permissions[login] = perm;
|
||||
return u;
|
||||
};
|
||||
const token = (u, scopes = ['read:repository', 'read:user'], o) => api.addToken(u.login, `t${api.issued.length}`, scopes, o);
|
||||
const slot = (name = 'rev') => join(storeRoot, 'demo', name, 'gitea');
|
||||
const issueAs = (cap, args) => call(cap, 'credentials.issue', { agent: 'rev', service: 'gitea', date: ahead(30), ...args });
|
||||
return { root, file, api, b, store, writer, credentials, hooks, opts, direct, owner, human, chief, call, bind, rec, bot, token, slot, repo, storeRoot, issueAs, loaded, snapshot };
|
||||
}
|
||||
|
||||
// Everything the bus or the organization keeps after an issue: the document, the receipts, the store.
|
||||
function kept(f) {
|
||||
const files = [f.file, join(f.root, 'businesses/.history/demo/changes.jsonl')].filter(existsSync).map((p) => readFileSync(p, 'utf8'));
|
||||
const rows = ['events', 'decisions'].map((tb) => JSON.stringify(f.store.all(`SELECT * FROM ${tb}`)));
|
||||
return files.join('\n') + rows.join('\n');
|
||||
}
|
||||
|
||||
test('CB2 an owner-issued copy token is verified live, staged and bound; nothing kept names the token or its path', async (t) => {
|
||||
const f = await fixture(t);
|
||||
assert.equal(f.rec().state, 'pending');
|
||||
const u = f.bot();
|
||||
const tok = f.token(u);
|
||||
const date = ahead(30);
|
||||
const result = await f.issueAs(f.human, { token: tok.sha1, date });
|
||||
assert.deepEqual(result, { agent: 'rev', service: 'gitea', verb: 'issue', date, state: 'bound', account: u.login, generation: result.generation });
|
||||
const r = f.rec();
|
||||
assert.equal(r.state, 'bound');
|
||||
assert.deepEqual(r.services.gitea, { slot: 'self', scopes: ['read:repository', 'read:user'], resources: { projects: ['stack'] }, rotateBy: date, grantId: `gitea:${u.id}:${tok.id}`, grantGeneration: 1 });
|
||||
assert.equal(statSync(f.slot()).mode & 0o777, 0o600);
|
||||
assert.equal(readFileSync(f.slot(), 'utf8'), tok.sha1 + '\n');
|
||||
// Only GETs went to Gitea, every one as the copy's account.
|
||||
assert.ok(f.api.requests.length > 0);
|
||||
for (const req of f.api.requests) {
|
||||
assert.equal(req.method, 'GET');
|
||||
if (req.path !== '/api/v1/version') assert.equal(req.as, u.login);
|
||||
}
|
||||
const text = kept(f) + JSON.stringify(result);
|
||||
for (const bad of [tok.sha1, sha(tok.sha1), sha(tok.sha1 + '\n'), f.slot(), f.storeRoot, '/api/v1', 'authorization']) assert.ok(!text.includes(bad), 'kept text holds a secret, path or request');
|
||||
// Copies still cannot launch in CB2: the snapshot does not resolve a gitea-only copy (CB3 owns launch).
|
||||
});
|
||||
|
||||
test('CB2 narrower and equal scopes are accepted; any broader token is exceeded and leaves the record inactive', async (t) => {
|
||||
const f = await fixture(t, { services: { gitea: { scopes: ['write:repository', 'read:user'], projects: ['stack'] } } });
|
||||
const u = f.bot('rev', { perm: 'write' });
|
||||
for (const scopes of [['write:repository', 'write:user'], ['all'], ['write:admin', 'read:user'], ['read:repository', 'read:user', 'read:issue'], ['read:user', 'read:misc']]) {
|
||||
const tok = f.token(u, scopes);
|
||||
await assert.rejects(f.issueAs(f.human, { token: tok.sha1 }), { code: 'credential-scope-exceeded' }, scopes.join());
|
||||
assert.equal(f.rec().state, 'pending', scopes.join());
|
||||
assert.equal(f.rec().services.gitea.grantId, undefined);
|
||||
assert.ok(!existsSync(f.slot()), 'a refused token leaves no slot');
|
||||
}
|
||||
// Equal.
|
||||
await f.issueAs(f.human, { token: f.token(u, ['write:repository', 'read:user']).sha1 });
|
||||
assert.equal(f.rec().state, 'bound');
|
||||
// Narrower (read within the approved write) re-issues; the grant generation counts up.
|
||||
f.repo.permissions[u.login] = 'read';
|
||||
const narrow = f.token(u, ['read:repository', 'read:user']);
|
||||
await f.issueAs(f.human, { token: narrow.sha1 });
|
||||
assert.deepEqual([f.rec().state, f.rec().services.gitea.grantGeneration, f.rec().services.gitea.grantId], ['bound', 2, `gitea:${u.id}:${narrow.id}`]);
|
||||
// A failed re-issue after bound deactivates the binding and removes its slot.
|
||||
await assert.rejects(f.issueAs(f.human, { token: f.token(u, ['all']).sha1 }), { code: 'credential-scope-exceeded' });
|
||||
assert.deepEqual([f.rec().state, f.rec().services.gitea.grantId, existsSync(f.slot())], ['pending', undefined, false]);
|
||||
});
|
||||
|
||||
test('CB2 read is narrower than write within a category; categories are independent; push needs write', async (t) => {
|
||||
assert.deepEqual(tokenLevels(['read:repository', 'write:repository', 'read:user']), { repository: 'write', user: 'read' });
|
||||
assert.deepEqual(tokenLevels(['write:issue', 'read:issue']), { issue: 'write' });
|
||||
for (const bad of [['all'], ['write:admin'], ['read:admin'], ['read:sudo'], ['repository'], [], null, [7]]) assert.equal(tokenLevels(bad), null, JSON.stringify(bad));
|
||||
const f = await fixture(t);
|
||||
// The binding approved read:repository; the account can push, so the token reaches more than read.
|
||||
const u = f.bot('rev', { perm: 'write' });
|
||||
await assert.rejects(f.issueAs(f.human, { token: f.token(u).sha1 }), { code: 'credential-scope-exceeded' });
|
||||
// write:issue is another category: read:repository never approves it.
|
||||
f.repo.permissions[u.login] = 'read';
|
||||
await assert.rejects(f.issueAs(f.human, { token: f.token(u, ['read:repository', 'read:user', 'write:issue']).sha1 }), { code: 'credential-scope-exceeded' });
|
||||
await f.issueAs(f.human, { token: f.token(u).sha1 });
|
||||
assert.equal(f.rec().state, 'bound');
|
||||
});
|
||||
|
||||
test('CB2 push is held to the binding, not the token: a narrower token binds for a push account on a write binding', async () => {
|
||||
const push = { '/api/v1/user/repos': { status: 200, json: [{ full_name: 'owner/stack', permissions: { admin: false, push: true, pull: true } }], total: 1 } };
|
||||
await assert.rejects(V(scripted(push).transport), { code: 'credential-scope-exceeded' });
|
||||
const write = { approved: ['write:repository', 'read:user'] };
|
||||
assert.deepEqual(await V(scripted(push).transport, write), { userId: 5, tokenId: 9, login: 'bot' });
|
||||
const writeToken = { '/api/v1/token': { status: 200, json: { id: 9, scopes: ['write:repository', 'read:user'], user: { id: 5, login: 'bot' } } } };
|
||||
assert.deepEqual(await V(scripted({ ...push, ...writeToken }).transport, write), { userId: 5, tokenId: 9, login: 'bot' });
|
||||
await assert.rejects(V(scripted({ ...push, ...writeToken }).transport), { code: 'credential-scope-exceeded' });
|
||||
});
|
||||
|
||||
test('CB2 the boot copy must still match the record: a missing, refused or changed copy refuses before anything is staged or sent', async (t) => {
|
||||
const f = await fixture(t);
|
||||
const tok = f.token(f.bot());
|
||||
const rev = f.snapshot.copies.rev;
|
||||
const cases = [
|
||||
[{ ...f.snapshot, copies: {} }, 'copy-unavailable'],
|
||||
[{ ...f.snapshot, org: 'other' }, 'copy-unavailable'],
|
||||
[{ ...f.snapshot, copies: { rev: { ...rev, state: 'refused' } } }, 'copy-unavailable'],
|
||||
[{ ...f.snapshot, copies: { rev: { ...rev, level: 'project', project: 'stack' } } }, 'credential-binding-changed'],
|
||||
[{ ...f.snapshot, copies: { rev: { ...rev, name: 'other' } } }, 'credential-binding-changed'],
|
||||
[{ ...f.snapshot, copies: { rev: { ...rev, definition: 'coder' } } }, 'credential-binding-changed'],
|
||||
];
|
||||
const before = kept(f), sent = f.api.requests.length;
|
||||
for (const [copies, code] of cases) {
|
||||
f.opts.copies = copies;
|
||||
await assert.rejects(f.direct({ agent: 'rev', service: 'gitea', token: tok.sha1, date: ahead(30) }, { actor: 'jason', owner: true }), { code }, JSON.stringify(copies.copies?.rev ?? copies.org));
|
||||
}
|
||||
f.opts.copies = null;
|
||||
// No record for the name, or a name the organization's own roles hold.
|
||||
await assert.rejects(f.direct({ agent: 'nobody', service: 'gitea', token: tok.sha1, date: ahead(30) }, { actor: 'jason', owner: true }), { code: 'credential-unbound' });
|
||||
f.opts.copies = { ...f.snapshot, copies: { ...f.snapshot.copies, chief: { ...rev, name: 'chief' } } };
|
||||
const { document } = f.writer.read('demo');
|
||||
const shadow = { ...f.writer, read: () => ({ ...f.writer.read('demo'), document: { ...document, copyBindings: { ...document.copyBindings, chief: document.copyBindings.rev } } }) };
|
||||
await assert.rejects(copyGrant({ credentials: f.credentials, writer: shadow, copies: f.opts.copies, organizations: () => ['demo'] })('demo', { agent: 'chief', service: 'gitea', token: tok.sha1, date: ahead(30) }, { actor: 'jason', owner: true }), { code: 'copy-unavailable' });
|
||||
f.opts.copies = null;
|
||||
assert.deepEqual([kept(f) === before, f.api.requests.length, present(join(f.storeRoot, 'demo'))], [true, sent, false]);
|
||||
await f.issueAs(f.human, { token: tok.sha1 });
|
||||
assert.equal(f.rec().state, 'bound');
|
||||
});
|
||||
|
||||
test('CB2 stageCopy refuses a name a loaded reference holds and an unknown service, leaving the file alone', async (t) => {
|
||||
const root = mkdtempSync(join(tmpdir(), 'cb2-ref-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const storeRoot = join(root, 'agents');
|
||||
mkdirSync(join(storeRoot, 'demo', 'rev'), { recursive: true, mode: 0o700 }); chmodSync(storeRoot, 0o700); chmodSync(join(storeRoot, 'demo'), 0o700);
|
||||
const file = join(storeRoot, 'demo', 'rev', 'gitea');
|
||||
writeFileSync(file, 'r'.repeat(40) + '\n', { mode: 0o600 });
|
||||
const credentials = new Credentials({ references: { 'demo/rev': { gitea: { service: 'gitea', store: true, file, rotateBy: ahead(30) } } }, storeRoot, repoRoots: [], dataRoot: '/nonexistent-data' });
|
||||
assert.throws(() => credentials.stageCopy('demo/rev', 'gitea', 'a'.repeat(40)), { code: 'credential-reference' });
|
||||
assert.equal(readFileSync(file, 'utf8'), 'r'.repeat(40) + '\n');
|
||||
assert.throws(() => credentials.stageCopy('demo/other', 'model', 'a'.repeat(40)), { code: 'credential-reference' });
|
||||
assert.equal(present(join(storeRoot, 'demo', 'other')), false);
|
||||
});
|
||||
|
||||
test('CB2 a Vikunja binding refuses credential-scope-unverified before anything is staged or sent', async (t) => {
|
||||
const vik = { tasks: ['read_one'] };
|
||||
const f = await fixture(t, { services: { gitea: READ.gitea, vikunja: { scopes: vik, projects: ['stack'] } } });
|
||||
const before = readFileSync(f.file, 'utf8');
|
||||
await assert.rejects(f.issueAs(f.human, { service: 'vikunja', token: 'v'.repeat(40) }), { code: 'credential-scope-unverified' });
|
||||
assert.equal(readFileSync(f.file, 'utf8'), before);
|
||||
assert.equal(f.api.requests.length, 0);
|
||||
assert.ok(!existsSync(join(f.storeRoot, 'demo', 'rev')));
|
||||
// With a Gitea grant the record still waits on Vikunja: pending, never bound.
|
||||
await f.issueAs(f.human, { token: f.token(f.bot()).sha1 });
|
||||
assert.equal(f.rec().state, 'pending');
|
||||
assert.ok(f.rec().services.gitea.grantId);
|
||||
assert.ok(REFUSALS['credential-scope-unverified'].nextStep.includes('Vikunja'));
|
||||
});
|
||||
|
||||
test('CB2 unrelated repositories, admin rights or a missing bound repository refuse', async (t) => {
|
||||
const f = await fixture(t);
|
||||
const u = f.bot();
|
||||
const tok = f.token(u);
|
||||
const other = f.api.addRepo('someone', 'private', { collaborators: [u.login] });
|
||||
other.permissions[u.login] = 'read';
|
||||
await assert.rejects(f.issueAs(f.human, { token: tok.sha1 }), { code: 'credential-scope-exceeded' });
|
||||
other.collaborators.length = 0;
|
||||
const own = f.api.addRepo(u.login, 'scratch');
|
||||
await assert.rejects(f.issueAs(f.human, { token: tok.sha1 }), { code: 'credential-scope-exceeded' });
|
||||
f.api.repos.splice(f.api.repos.indexOf(own), 1);
|
||||
f.repo.collaborators.length = 0;
|
||||
await assert.rejects(f.issueAs(f.human, { token: tok.sha1 }), { code: 'credential-scope-unverified' });
|
||||
assert.equal(f.rec().state, 'pending');
|
||||
f.repo.collaborators.push(u.login);
|
||||
await f.issueAs(f.human, { token: tok.sha1 });
|
||||
assert.equal(f.rec().state, 'bound');
|
||||
});
|
||||
|
||||
// A scripted Gitea for the verifier's own branches: paging, organizations and malformed answers.
|
||||
function scripted(over = {}) {
|
||||
const base = {
|
||||
'/api/v1/version': { status: 200, json: { version: '1.27.1' } },
|
||||
'/api/v1/token': { status: 200, json: { id: 9, scopes: ['read:repository', 'read:user'], user: { id: 5, login: 'bot' } } },
|
||||
'/api/v1/user': { status: 200, json: { id: 5, login: 'bot', is_admin: false } },
|
||||
'/api/v1/user/repos': { status: 200, json: [{ full_name: 'owner/stack', permissions: { admin: false, push: false, pull: true } }], total: 1 },
|
||||
'/api/v1/user/orgs': { status: 200, json: [], total: 0 },
|
||||
...over,
|
||||
};
|
||||
const seen = [];
|
||||
const transport = async ({ path, method }) => {
|
||||
seen.push(`${method} ${path}`);
|
||||
const key = path.split('?')[0];
|
||||
const a = base[key];
|
||||
return typeof a === 'function' ? a(path) : { total: null, ...a };
|
||||
};
|
||||
return { transport, seen };
|
||||
}
|
||||
const V = (transport, extra = {}) => verifyGiteaToken({ transport, url: 'https://127.0.0.1:1', token: 'x'.repeat(40), approved: ['read:repository', 'read:user', 'read:organization'], repos: new Set(['owner/stack']), login: 'bot', rotateBy: ahead(30), ...extra });
|
||||
|
||||
test('CB2 verifier: paging must agree, organizations are refused with an organization scope', async () => {
|
||||
assert.deepEqual(await V(scripted().transport), { userId: 5, tokenId: 9, login: 'bot' });
|
||||
const pages = (p1, p2) => (path) => (path.includes('page=1') ? p1 : p2);
|
||||
const repo = (n) => ({ full_name: `owner/${n}`, permissions: { admin: false, push: false, pull: true } });
|
||||
const two = new Set(['owner/stack', 'owner/b']);
|
||||
const many = Array.from({ length: 50 }, (_, i) => repo(`r${i}`));
|
||||
const fifty = new Set(many.map((r) => r.full_name.toLowerCase()));
|
||||
// A total that changes between pages, a page that comes back short, and no total at all.
|
||||
await assert.rejects(V(scripted({ '/api/v1/user/repos': pages({ status: 200, json: many, total: 51 }, { status: 200, json: [repo('x')], total: 52 }) }).transport, { repos: fifty }), { code: 'credential-scope-unverified' });
|
||||
await assert.rejects(V(scripted({ '/api/v1/user/repos': pages({ status: 200, json: many, total: 52 }, { status: 200, json: [], total: 52 }) }).transport, { repos: fifty }), { code: 'credential-scope-unverified' });
|
||||
await assert.rejects(V(scripted({ '/api/v1/user/repos': { status: 200, json: [repo('stack')], total: null } }).transport), { code: 'credential-scope-unverified' });
|
||||
await assert.rejects(V(scripted({ '/api/v1/user/repos': { status: 200, json: [repo('stack'), repo('b')], total: 2 } }).transport), { code: 'credential-scope-exceeded' });
|
||||
assert.ok(await V(scripted({ '/api/v1/user/repos': { status: 200, json: [repo('stack'), repo('b')], total: 2 } }).transport, { repos: two }));
|
||||
await assert.rejects(V(scripted({ '/api/v1/user/repos': { status: 200, json: [{ full_name: 'owner/stack', permissions: { push: false, pull: true } }], total: 1 } }).transport), { code: 'credential-scope-exceeded' });
|
||||
await assert.rejects(V(scripted({ '/api/v1/user/repos': { status: 200, json: [{ full_name: 'owner/stack' }], total: 1 } }).transport), { code: 'credential-scope-unverified' });
|
||||
// Organizations: only asked when the token has an organization level.
|
||||
const plain = scripted({ '/api/v1/user/orgs': { status: 200, json: [{ id: 1, username: 'acme' }], total: 1 } });
|
||||
assert.ok(await V(plain.transport));
|
||||
assert.ok(!plain.seen.some((s) => s.includes('/user/orgs')));
|
||||
const orgTok = { '/api/v1/token': { status: 200, json: { id: 9, scopes: ['read:organization', 'read:repository', 'read:user'], user: { id: 5, login: 'bot' } } } };
|
||||
assert.ok(await V(scripted(orgTok).transport));
|
||||
await assert.rejects(V(scripted({ ...orgTok, '/api/v1/user/orgs': { status: 200, json: [{ id: 1, username: 'acme' }], total: 1 } }).transport), { code: 'credential-scope-exceeded' });
|
||||
await assert.rejects(V(scripted({ ...orgTok, '/api/v1/user/orgs': { status: 500, json: null } }).transport), { code: 'credential-scope-unverified' });
|
||||
// The bound projects' remotes: one https Gitea origin, agreeing remotes.
|
||||
const doc = (projects) => ({ projects });
|
||||
assert.deepEqual([...boundRepositories(doc({ a: { remote: 'https://127.0.0.1:9/Owner/Stack' } }), ['a']).repos], ['owner/stack']);
|
||||
for (const [d, ps] of [[doc({ a: { remote: 'http://127.0.0.1:9/o/r' } }), ['a']], [doc({ a: {} }), ['a']], [doc({}), ['a']], [doc({ a: { remote: 'https://127.0.0.1:9/o/r' }, b: { remote: 'https://127.0.0.2:9/o/r' } }), ['a', 'b']], [doc({}), []],
|
||||
[doc({ a: { remote: 'https://127.0.0.1:9/o/r', git: { mode: 'remote', remote: 'https://127.0.0.1:9/o/s' } } }), ['a']]])
|
||||
assert.throws(() => boundRepositories(d, ps), { code: 'credential-scope-unverified' });
|
||||
});
|
||||
|
||||
test('CB2 a foreign account, an administrator or another copy\'s token is identity-refused', async (t) => {
|
||||
const f = await fixture(t);
|
||||
const u = f.bot();
|
||||
const foreign = f.api.addUser('someone-else').user;
|
||||
f.repo.collaborators.push('someone-else');
|
||||
for (const tok of [f.token(foreign), { sha1: f.api.adminToken }]) {
|
||||
await assert.rejects(f.issueAs(f.human, { token: tok.sha1 }), { code: 'credential-identity-refused' });
|
||||
assert.deepEqual([f.rec().state, existsSync(f.slot())], ['pending', false]);
|
||||
}
|
||||
// The copy's own login, made an administrator.
|
||||
u.is_admin = true;
|
||||
await assert.rejects(f.issueAs(f.human, { token: f.token(u).sha1 }), { code: 'credential-identity-refused' });
|
||||
u.is_admin = false;
|
||||
// /user names another account than /token did.
|
||||
const undo = f.api.rule((e) => e.path === '/api/v1/user', { status: 200, json: { id: u.id + 1000, login: u.login, is_admin: false } });
|
||||
await assert.rejects(f.issueAs(f.human, { token: f.token(u).sha1 }), { code: 'credential-identity-refused' });
|
||||
undo();
|
||||
// Another copy's account and token: checker.stack is bound in the same organization.
|
||||
await f.call(f.owner, 'hire.start.console', { op: 'change', level: 'project', project: 'stack', name: 'checker', reason: 'Bind', binding: { services: READ } });
|
||||
const checker = f.bot('checker.stack');
|
||||
const theirs = f.token(checker);
|
||||
await assert.rejects(f.issueAs(f.human, { token: theirs.sha1 }), { code: 'credential-identity-refused' });
|
||||
await f.issueAs(f.human, { agent: 'checker.stack', token: theirs.sha1 });
|
||||
assert.equal(f.rec('checker.stack').state, 'bound');
|
||||
assert.equal(f.rec().state, 'pending');
|
||||
assert.notEqual(checker.login, u.login);
|
||||
});
|
||||
|
||||
test('CB2 an opaque or unverifiable token refuses unverified; a malformed one refuses credential-format', async (t) => {
|
||||
const f = await fixture(t);
|
||||
const u = f.bot();
|
||||
// A malformed token refuses before anything is staged or sent (build review 2): the bound record is
|
||||
// unchanged, nothing reached Gitea and no slot exists.
|
||||
const before = kept(f), sent = f.api.requests.length;
|
||||
await assert.rejects(f.issueAs(f.human, { token: 'short' }), { code: 'credential-format' });
|
||||
await assert.rejects(f.issueAs(f.human, { token: 'has space ' + 'x'.repeat(30) }), { code: 'credential-format' });
|
||||
assert.deepEqual([kept(f) === before, f.api.requests.length, present(join(f.storeRoot, 'demo', 'rev'))], [true, sent, false]);
|
||||
await assert.rejects(f.issueAs(f.human, { token: 'f'.repeat(40) }), { code: 'credential-scope-unverified' });
|
||||
const tok = f.token(u);
|
||||
for (const answer of [{ status: 200, json: { id: tok.id, scopes: tok.scopes } }, { status: 200, json: { id: tok.id, scopes: tok.scopes, user: { login: u.login } } }, { status: 500, json: null }, { drop: true }]) {
|
||||
const undo = f.api.rule((e) => e.path === '/api/v1/token', answer);
|
||||
await assert.rejects(f.issueAs(f.human, { token: tok.sha1 }), { code: 'credential-scope-unverified' }, JSON.stringify(answer));
|
||||
undo();
|
||||
}
|
||||
f.opts.timeoutMs = 300;
|
||||
const hang = f.api.rule((e) => e.path.startsWith('/api/v1/user/repos'), { hang: true });
|
||||
await assert.rejects(f.issueAs(f.human, { token: tok.sha1 }), { code: 'credential-scope-unverified' });
|
||||
hang();
|
||||
f.opts.timeoutMs = 3000;
|
||||
f.api.state.version = '1.26.0';
|
||||
await assert.rejects(f.issueAs(f.human, { token: tok.sha1 }), { code: 'credential-scope-unverified' });
|
||||
f.api.state.version = '1.27.1';
|
||||
// The token lacks read:user, which the binding approved: its repositories cannot be listed.
|
||||
await assert.rejects(f.issueAs(f.human, { token: f.token(u, ['read:repository']).sha1 }), { code: 'credential-scope-unverified' });
|
||||
assert.deepEqual([f.rec().state, existsSync(f.slot())], ['pending', false]);
|
||||
await f.issueAs(f.human, { token: tok.sha1 });
|
||||
assert.equal(f.rec().state, 'bound');
|
||||
});
|
||||
|
||||
test('CB2 a token any definition already holds refuses before anything is staged or sent', async (t) => {
|
||||
const f = await fixture(t);
|
||||
const tok = f.token(f.bot());
|
||||
const before = readFileSync(f.file, 'utf8');
|
||||
// The boot copy snapshot's definitions are live text for the bus's lifetime.
|
||||
f.opts.copies = { ...f.snapshot, definitions: { ...f.snapshot.definitions, leaky: { note: `see ${tok.sha1}` } } };
|
||||
await assert.rejects(f.issueAs(f.human, { token: tok.sha1 }), { code: 'definition-holds-credential' });
|
||||
assert.deepEqual([readFileSync(f.file, 'utf8') === before, existsSync(f.slot()), f.api.requests.length], [true, false, 0]);
|
||||
f.opts.copies = null;
|
||||
await f.issueAs(f.human, { token: tok.sha1 });
|
||||
assert.equal(f.rec().state, 'bound');
|
||||
});
|
||||
|
||||
test('CB2 a binding without read:user is unverifiable before anything is staged', async (t) => {
|
||||
const f = await fixture(t, { services: { gitea: { scopes: ['read:repository'], projects: ['stack'] } } });
|
||||
const before = readFileSync(f.file, 'utf8');
|
||||
await assert.rejects(f.issueAs(f.human, { token: f.token(f.bot()).sha1 }), { code: 'credential-scope-unverified' });
|
||||
assert.equal(readFileSync(f.file, 'utf8'), before);
|
||||
assert.equal(f.api.requests.length, 0);
|
||||
});
|
||||
|
||||
test('CB2 dates: the rotation date is after today, at most a year ahead, and not after the token expires', async (t) => {
|
||||
const f = await fixture(t);
|
||||
const u = f.bot();
|
||||
const tok = f.token(u);
|
||||
const before = readFileSync(f.file, 'utf8');
|
||||
for (const date of [ahead(0), ahead(-1), ahead(MAX_ROTATE_DAYS + 1), '2099-02-30', '2099-1-01', 'tomorrow'])
|
||||
await assert.rejects(f.issueAs(f.human, { token: tok.sha1, date }), { code: 'credential-date' }, date);
|
||||
assert.equal(readFileSync(f.file, 'utf8'), before);
|
||||
assert.equal(f.api.requests.length, 0);
|
||||
const soon = f.token(u, undefined, { expires: `${ahead(10)}T00:00:00Z` });
|
||||
await assert.rejects(f.issueAs(f.human, { token: soon.sha1, date: ahead(10) }), { code: 'credential-date' });
|
||||
await assert.rejects(f.issueAs(f.human, { token: soon.sha1, date: ahead(30) }), { code: 'credential-date' });
|
||||
const odd = f.token(u, undefined, { expires: 'not-a-date' });
|
||||
await assert.rejects(f.issueAs(f.human, { token: odd.sha1 }), { code: 'credential-scope-unverified' });
|
||||
assert.deepEqual([f.rec().state, existsSync(f.slot())], ['pending', false]);
|
||||
await f.issueAs(f.human, { token: soon.sha1, date: ahead(9) });
|
||||
assert.equal(f.rec().services.gitea.rotateBy, ahead(9));
|
||||
await f.issueAs(f.human, { token: f.token(u, undefined, { expires: `${ahead(400)}T00:00:00Z` }).sha1, date: ahead(MAX_ROTATE_DAYS) });
|
||||
assert.equal(f.rec().state, 'bound');
|
||||
});
|
||||
|
||||
test('CB2 the slot is read back by identity: symlinks, hard links, stale and replaced files refuse credential-file', async (t) => {
|
||||
const f = await fixture(t);
|
||||
const u = f.bot();
|
||||
const tok = f.token(u);
|
||||
const other = f.token(u);
|
||||
const elsewhere = join(f.root, 'elsewhere');
|
||||
const cases = {
|
||||
symlink: ['afterStage', () => { writeFileSync(elsewhere, tok.sha1 + '\n', { mode: 0o600 }); unlinkSync(f.slot()); symlinkSync(elsewhere, f.slot()); }],
|
||||
hardlink: ['afterStage', () => { rmSync(elsewhere, { force: true }); linkSync(f.slot(), elsewhere); }],
|
||||
stale: ['afterStage', () => { renameSync(f.slot(), elsewhere); writeFileSync(f.slot(), tok.sha1 + '\n', { mode: 0o600 }); }],
|
||||
mode: ['afterStage', () => chmodSync(f.slot(), 0o644)],
|
||||
// A writer replaces a file by renaming a new one over it, as the store does.
|
||||
replacedAfterRemote: ['afterRemote', () => { writeFileSync(elsewhere, other.sha1 + '\n', { mode: 0o600 }); renameSync(elsewhere, f.slot()); }],
|
||||
replacedBeforeCommit: ['beforeCommit', () => { writeFileSync(elsewhere, other.sha1 + '\n', { mode: 0o600 }); renameSync(elsewhere, f.slot()); }],
|
||||
// Unlinked and written again: the inode number may be reused, the content still differs.
|
||||
reusedInode: ['afterStage', () => { unlinkSync(f.slot()); writeFileSync(f.slot(), other.sha1 + '\n', { mode: 0o600 }); }],
|
||||
rewrittenInPlace: ['beforeCommit', () => writeFileSync(f.slot(), other.sha1 + '\n')],
|
||||
};
|
||||
for (const [name, [hook, act]] of Object.entries(cases)) {
|
||||
rmSync(elsewhere, { force: true });
|
||||
f.hooks[hook] = act;
|
||||
await assert.rejects(f.issueAs(f.human, { token: tok.sha1 }), { code: 'credential-file' }, name);
|
||||
delete f.hooks[hook];
|
||||
assert.equal(f.rec().state, 'pending', name);
|
||||
assert.equal(f.rec().services.gitea.grantId, undefined, name);
|
||||
// The bus removes only the file it wrote; a file someone else put there stays.
|
||||
if (['symlink', 'stale', 'replacedAfterRemote', 'replacedBeforeCommit'].includes(name)) assert.ok(present(f.slot()), name);
|
||||
if (['hardlink', 'mode', 'rewrittenInPlace'].includes(name)) assert.ok(!present(f.slot()), name);
|
||||
rmSync(f.slot(), { force: true });
|
||||
}
|
||||
await f.issueAs(f.human, { token: tok.sha1 });
|
||||
assert.equal(f.rec().state, 'bound');
|
||||
});
|
||||
|
||||
test('CB2 crash points leave the record pending and the slot removed; a new verified issue recovers', async (t) => {
|
||||
const f = await fixture(t);
|
||||
const u = f.bot();
|
||||
const tok = f.token(u);
|
||||
for (const hook of ['afterStage', 'afterRemote', 'beforeCommit']) {
|
||||
f.hooks[hook] = () => { throw new Error('crash'); };
|
||||
await assert.rejects(f.issueAs(f.human, { token: tok.sha1 }), { code: 'outcome-unknown' }, hook);
|
||||
delete f.hooks[hook];
|
||||
assert.deepEqual([f.rec().state, f.rec().services.gitea.grantId, existsSync(f.slot())], ['pending', undefined, false], hook);
|
||||
}
|
||||
// A hard crash after the stage (security review P2): the record is pending and the staged token is
|
||||
// on disk. The next start's sweep removes it before anything loads; the record stays pending and
|
||||
// only a fresh verified issue binds.
|
||||
const r = f.rec();
|
||||
const { revision } = f.writer.read('demo');
|
||||
f.writer.stageCopyGrant('demo', { instance: 'rev', service: 'gitea', copyId: r.copyId, generation: r.generation, decision: r.decision, incarnation: r.orgIncarnation }, { revision, actor: 'jason', owner: true });
|
||||
f.credentials.stageCopy('demo/rev', 'gitea', tok.sha1);
|
||||
assert.equal(f.rec().state, 'pending');
|
||||
assert.deepEqual(sweepCopySlots(f.storeRoot, [['demo', f.writer.read('demo').document]], {}), [{ instance: 'demo/rev', service: 'gitea' }]);
|
||||
assert.deepEqual([f.rec().state, present(f.slot())], ['pending', false]);
|
||||
await f.issueAs(f.human, { token: tok.sha1 });
|
||||
assert.deepEqual([f.rec().state, f.rec().services.gitea.grantGeneration], ['bound', 1]);
|
||||
// A lost reply: the commit landed but the caller never heard. Issuing again re-verifies.
|
||||
await f.issueAs(f.human, { token: tok.sha1 });
|
||||
assert.deepEqual([f.rec().state, f.rec().services.gitea.grantGeneration], ['bound', 2]);
|
||||
});
|
||||
|
||||
test('CB2 the start sweep removes only ungranted copy slots and staged temp files', async (t) => {
|
||||
const f = await fixture(t);
|
||||
const u = f.bot();
|
||||
const tok = f.token(u);
|
||||
await f.issueAs(f.human, { token: tok.sha1 });
|
||||
assert.equal(f.rec().state, 'bound');
|
||||
const doc = () => [['demo', f.writer.read('demo').document]];
|
||||
const copyDir = join(f.storeRoot, 'demo', 'rev');
|
||||
// A granted slot stays; an interrupted write's temp file goes; a name that is no service stays.
|
||||
writeFileSync(join(copyDir, 'gitea.tmp'), 'x', { mode: 0o600 });
|
||||
writeFileSync(join(copyDir, 'notes'), 'x', { mode: 0o600 });
|
||||
assert.deepEqual(sweepCopySlots(f.storeRoot, doc(), {}), [{ instance: 'demo/rev', service: 'gitea' }]);
|
||||
assert.deepEqual([present(f.slot()), present(join(copyDir, 'gitea.tmp')), present(join(copyDir, 'notes'))], [true, false, true]);
|
||||
// An ungranted service of a bound copy goes; a slot a loaded reference names stays.
|
||||
writeFileSync(join(copyDir, 'vikunja'), 'x', { mode: 0o600 });
|
||||
assert.deepEqual(sweepCopySlots(f.storeRoot, doc(), { 'demo/rev': { vikunja: { service: 'vikunja' } } }), []);
|
||||
assert.deepEqual(sweepCopySlots(f.storeRoot, doc(), {}), [{ instance: 'demo/rev', service: 'vikunja' }]);
|
||||
// A role's own slots and an unreadable organization are never touched.
|
||||
const roleDir = join(f.storeRoot, 'demo', 'chief');
|
||||
mkdirSync(roleDir, { mode: 0o700 }); writeFileSync(join(roleDir, 'gitea'), 'x', { mode: 0o600 });
|
||||
const edited = { ...f.writer.read('demo').document };
|
||||
edited.copyBindings = { ...edited.copyBindings, chief: { ...edited.copyBindings.rev, services: {} } };
|
||||
assert.deepEqual(sweepCopySlots(f.storeRoot, [['demo', edited]], {}), []);
|
||||
assert.equal(present(join(roleDir, 'gitea')), true);
|
||||
writeFileSync(join(copyDir, 'gitea.tmp'), 'x', { mode: 0o600 });
|
||||
assert.deepEqual(sweepCopySlots(f.storeRoot, [['demo', null]], {}), []);
|
||||
assert.equal(present(join(copyDir, 'gitea.tmp')), true);
|
||||
// No store, a missing org dir: nothing. A store dir that isn't private refuses as Credentials would.
|
||||
assert.deepEqual(sweepCopySlots(null, doc(), {}), []);
|
||||
assert.deepEqual(sweepCopySlots(f.storeRoot, [['other', f.writer.read('demo').document]], {}), []);
|
||||
chmodSync(copyDir, 0o755);
|
||||
assert.throws(() => sweepCopySlots(f.storeRoot, doc(), {}), /credential-file/);
|
||||
chmodSync(copyDir, 0o700);
|
||||
});
|
||||
|
||||
test('CB2 a stage write that fails leaves no staged token behind', async (t) => {
|
||||
const f = await fixture(t);
|
||||
// The slot path is a non-empty directory: the rename fails after the temp file holds the token.
|
||||
mkdirSync(join(f.slot(), 'x'), { recursive: true, mode: 0o700 });
|
||||
assert.throws(() => f.credentials.stageCopy('demo/rev', 'gitea', 'a'.repeat(40)));
|
||||
assert.deepEqual(readdirSync(join(f.storeRoot, 'demo', 'rev')), ['gitea']);
|
||||
});
|
||||
|
||||
test('CB2 a rebind, a failed owner recheck or a concurrent issue during the remote work refuses the commit', async (t) => {
|
||||
const f = await fixture(t);
|
||||
const u = f.bot();
|
||||
const tok = f.token(u);
|
||||
// The owner approves a new binding while Gitea is being asked.
|
||||
f.hooks.afterRemote = () => f.bind('rev', { gitea: { scopes: ['read:user', 'read:repository'], projects: ['stack'] } });
|
||||
// The document moved under the commit: its revision check refuses before the binding fence.
|
||||
await assert.rejects(f.issueAs(f.human, { token: tok.sha1 }), { code: 'revision-conflict' });
|
||||
delete f.hooks.afterRemote;
|
||||
assert.deepEqual([f.rec().state, existsSync(f.slot())], ['pending', false]);
|
||||
// The caller's owner recheck fails after the remote work (the broker's #copyOwner), and again
|
||||
// when it fails only under the writer's locks.
|
||||
for (const at of [1, 2]) {
|
||||
let n = 0;
|
||||
await assert.rejects(f.direct({ agent: 'rev', service: 'gitea', token: tok.sha1, date: ahead(30) }, { actor: 'jason', owner: true, recheck: () => { if (++n >= at) throw new BusError('human-required'); } }), { code: 'human-required' });
|
||||
assert.deepEqual([f.rec().state, existsSync(f.slot())], ['pending', false]);
|
||||
}
|
||||
// Not the owner, or the tester's name, never reaches the writer.
|
||||
for (const ctx of [{ actor: 'jason', owner: false }, { actor: 'tester', owner: true }])
|
||||
await assert.rejects(f.direct({ agent: 'rev', service: 'gitea', token: tok.sha1, date: ahead(30) }, ctx), { code: 'human-required' });
|
||||
// Two issues: the first is held after its remote work while the second binds.
|
||||
const second = f.token(u);
|
||||
let release, first = true;
|
||||
const held = new Promise((r) => { release = r; });
|
||||
f.hooks.afterRemote = () => { if (first) { first = false; return held; } };
|
||||
const a = f.issueAs(f.human, { token: tok.sha1 });
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
while (f.hooks.afterRemote && first) await new Promise((r) => setTimeout(r, 10));
|
||||
await f.issueAs(f.human, { token: second.sha1 });
|
||||
release();
|
||||
await assert.rejects(a, (e) => ['revision-conflict', 'credential-binding-changed', 'credential-file'].includes(e.code));
|
||||
delete f.hooks.afterRemote;
|
||||
assert.equal(f.rec().state, 'bound');
|
||||
assert.equal(f.rec().services.gitea.grantId, `gitea:${u.id}:${second.id}`);
|
||||
assert.equal(readFileSync(f.slot(), 'utf8'), second.sha1 + '\n', 'the second issue\'s slot stays');
|
||||
});
|
||||
|
||||
test('CB2 only the organization owner issues: the chief, the tester, a reader and an org.hire member are refused before staging', async (t) => {
|
||||
const f = await fixture(t);
|
||||
const tok = f.token(f.bot());
|
||||
const before = readFileSync(f.file, 'utf8');
|
||||
const member = (owner) => f.b.bindHireConsole({ business: 'demo', actor: 'member', subject: { ...SUBJECT, sub: 'member' }, rights: ['org.hire', 'decide'], owner, instanceOwner: true });
|
||||
const got = [];
|
||||
for (const cap of [f.chief, f.b.bindTester({ business: 'demo' }), f.b.bindReader({ business: 'demo' }), member(false), member(true), f.owner]) {
|
||||
await assert.rejects(f.issueAs(cap, { token: tok.sha1 }), (e) => { got.push(e.code); return !String(e.message).includes(tok.sha1); });
|
||||
}
|
||||
// The Console caps, the owner's included, never reach credentials.issue: the token comes from the
|
||||
// owner's CLI on standard input.
|
||||
assert.deepEqual(got, ['human-required', 'tester-refused', 'human-required', 'human-required', 'human-required', 'human-required']);
|
||||
assert.equal(readFileSync(f.file, 'utf8'), before);
|
||||
assert.equal(f.api.requests.length, 0);
|
||||
assert.ok(!existsSync(join(f.storeRoot, 'demo')));
|
||||
// Only a name the boot's copies view holds goes to the copy verifier; any other unknown name
|
||||
// answers credential-unknown as before, and nothing is staged or sent.
|
||||
await assert.rejects(f.issueAs(f.human, { agent: 'nobody', token: tok.sha1 }), { code: 'credential-unknown' });
|
||||
assert.deepEqual([readFileSync(f.file, 'utf8') === before, f.api.requests.length, existsSync(join(f.storeRoot, 'demo'))], [true, 0, false]);
|
||||
});
|
||||
|
||||
test('CB2 refusal table: the new codes have rails and next steps that name no path', () => {
|
||||
for (const code of ['credential-scope-unverified', 'credential-identity-refused', 'credential-date', 'credential-format', 'credential-file', 'copy-unavailable']) {
|
||||
const g = REFUSALS[code];
|
||||
assert.ok(g && g.rail.length && g.nextStep.length, code);
|
||||
assert.doesNotMatch(g.nextStep, /\/|~|\.json\b|secrets/, code);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,238 @@
|
||||
// #1873 CB3 live acceptance: a bound copy's launch gate against a scratch gitea/gitea:1.27.1, a
|
||||
// throwaway container on 127.0.0.1 over TLS with a scratch CA, removed after. The boot proves the
|
||||
// slot live, a launch carries its authority to the bound session, one checked call reads the bound
|
||||
// repository, and a reissue denies the next call before any request. The test never pulls an image;
|
||||
// without Docker or the image it skips. Every token here is a scratch token this container made;
|
||||
// none is printed, and nothing here reaches git.mosaicstack.dev or any real service.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash, randomBytes } from 'node:crypto';
|
||||
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { request } from 'node:https';
|
||||
import { createServer } from 'node:net';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { Broker, BusError } from '../src/broker.mjs';
|
||||
import { Store } from '../src/store.mjs';
|
||||
import { Credentials } from '../src/credentials.mjs';
|
||||
import { copyGrant } from '../src/copy-grant.mjs';
|
||||
import { copyLaunchGate } from '../src/copy-launch.mjs';
|
||||
import { send } from '../src/gitea-grant.mjs';
|
||||
import { hireDecisions } from '../../organization/src/hire-decisions.mjs';
|
||||
import { organizationWriter } from '../../organization/src/organization.mjs';
|
||||
import { checkRootAgents } from '../../organization/src/agent-levels.mjs';
|
||||
import { loadWithCopies, materializeCopies, promoteCopies } from '../../organization/src/agent-copies.mjs';
|
||||
import { activateOrg, reserveOrg } from '../../organization/src/org-registry.mjs';
|
||||
import { copyBotLogin } from '../../organization/src/copy-bindings.mjs';
|
||||
import { businessDoc, rolesCopy, writeJson } from '../../organization/tests/helpers.mjs';
|
||||
import { scratchCa } from '../../seat/tests/fake-gitea.mjs';
|
||||
|
||||
const IMAGE = 'gitea/gitea:1.27.1';
|
||||
const SUBJECT = { provider: 'test', iss: 'https://issuer.test', sub: 'owner' };
|
||||
const READ = { gitea: { scopes: ['read:repository', 'read:user'], projects: ['stack'] } };
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
const ahead = (days) => new Date(Date.now() + days * 86400000).toISOString().slice(0, 10);
|
||||
const sha = (s) => createHash('sha256').update(s).digest('hex');
|
||||
const docker = (...args) => spawnSync('docker', args, { encoding: 'utf8', timeout: 120000 });
|
||||
const skip = docker('image', 'inspect', IMAGE).status === 0 ? false : `no Docker or no local ${IMAGE}; this test never pulls`;
|
||||
|
||||
const freePort = () =>
|
||||
new Promise((resolve, reject) => {
|
||||
const s = createServer();
|
||||
s.once('error', reject);
|
||||
s.listen(0, '127.0.0.1', () => {
|
||||
const { port } = s.address();
|
||||
s.close(() => resolve(port));
|
||||
});
|
||||
});
|
||||
|
||||
// One HTTPS call to the scratch container as its administrator, trusting only the scratch CA.
|
||||
function call(url, ca, method, path, { auth = null, body = null } = {}) {
|
||||
return new Promise((resolve) => {
|
||||
const data = body ? JSON.stringify(body) : null;
|
||||
const headers = { accept: 'application/json', ...(data ? { 'content-type': 'application/json' } : {}), ...(auth ? { authorization: auth } : {}) };
|
||||
const req = request(url + path, { method, ca, headers, timeout: 10000 }, (res) => {
|
||||
let text = '';
|
||||
res.on('data', (c) => (text += c));
|
||||
res.on('end', () => {
|
||||
let json = null;
|
||||
try {
|
||||
json = text ? JSON.parse(text) : null;
|
||||
} catch {}
|
||||
resolve({ status: res.statusCode, json });
|
||||
});
|
||||
});
|
||||
req.on('timeout', () => req.destroy());
|
||||
req.on('error', () => resolve({ status: 0, json: null }));
|
||||
req.end(data ?? undefined);
|
||||
});
|
||||
}
|
||||
|
||||
test('CB3-D: on scratch 1.27.1 a bound copy boots proved, launches with its authority, reads only its repository, refuses a redirect, and a reissue denies the next call unsent', { skip, timeout: 300000 }, async (t) => {
|
||||
// The child's complete TAP output is captured before any of it reaches this runner, so the parent
|
||||
// can search it for the minted values without putting one in an assertion.
|
||||
if (!process.env.CB3_COPY_CAPTURE) {
|
||||
const capture = mkdtempSync(join(tmpdir(), 'cb3cap-'));
|
||||
t.after(() => rmSync(capture, { recursive: true, force: true }));
|
||||
const file = join(capture, 'minted');
|
||||
const childEnv = { ...process.env, CB3_COPY_CAPTURE: file };
|
||||
delete childEnv.NODE_TEST_CONTEXT;
|
||||
const child = spawnSync(process.execPath, ['--test', '--test-name-pattern=^CB3-D:', fileURLToPath(import.meta.url)], {
|
||||
encoding: 'utf8', timeout: 280000, env: childEnv,
|
||||
});
|
||||
const values = existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : [];
|
||||
const output = (child.stdout ?? '') + (child.stderr ?? '');
|
||||
assert.ok(values.every((value) => !output.includes(value) && !output.includes(sha(value))), 'complete TAP stdout/stderr contain no minted token');
|
||||
t.diagnostic(output);
|
||||
t.diagnostic(`minted ${values.length}, hits 0 (complete TAP stdout/stderr)`);
|
||||
assert.equal(child.status, 0, 'Docker child passed');
|
||||
assert.equal(values.length, 3, 'all three scratch tokens were minted: the acceptance actually ran');
|
||||
return;
|
||||
}
|
||||
|
||||
const dir = mkdtempSync(join(tmpdir(), 'cb3d-'));
|
||||
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
||||
const tlsDir = join(dir, 'tls'), tls = scratchCa(tlsDir);
|
||||
// The container's git user reads the scratch key; it is a throwaway key for this run only.
|
||||
chmodSync(tlsDir, 0o755);
|
||||
chmodSync(join(tlsDir, 'server.key'), 0o644);
|
||||
const ca = readFileSync(tls.ca, 'utf8'), port = await freePort();
|
||||
const url = `https://127.0.0.1:${port}`, name = 'cb3-' + randomBytes(6).toString('hex');
|
||||
t.after(() => docker('rm', '-f', name));
|
||||
const started = docker('run', '-d', '--rm', '--name', name, '-p', `127.0.0.1:${port}:3000`, '-v', `${tlsDir}:/certs:ro`,
|
||||
'-e', 'GITEA__security__INSTALL_LOCK=true', '-e', 'GITEA__database__DB_TYPE=sqlite3',
|
||||
'-e', 'GITEA__service__DISABLE_REGISTRATION=true', '-e', 'GITEA__server__PROTOCOL=https',
|
||||
'-e', 'GITEA__server__CERT_FILE=/certs/server.pem', '-e', 'GITEA__server__KEY_FILE=/certs/server.key',
|
||||
'-e', `GITEA__server__ROOT_URL=${url}/`, IMAGE);
|
||||
assert.equal(started.status, 0, 'scratch container starts');
|
||||
const end = Date.now() + 120000;
|
||||
while ((await call(url, ca, 'GET', '/api/v1/version')).status !== 200) {
|
||||
assert.ok(Date.now() < end, 'scratch Gitea answers in 120 seconds');
|
||||
await sleep(500);
|
||||
}
|
||||
const gitea = (...args) => docker('exec', '-u', 'git', name, 'gitea', ...args);
|
||||
const minted = [];
|
||||
const createUser = (user, admin = false) => {
|
||||
const r = gitea('admin', 'user', 'create', '--username', user, '--email', `${user}@example.test`,
|
||||
'--password', randomBytes(16).toString('base64url'), '--must-change-password=false', ...(admin ? ['--admin'] : []));
|
||||
assert.equal(r.status, 0, `create scratch ${user}`);
|
||||
};
|
||||
const mint = (user, scopes) => {
|
||||
const r = gitea('admin', 'user', 'generate-access-token', '--username', user, '--token-name', 'cb3-' + randomBytes(4).toString('hex'),
|
||||
'--scopes', scopes.join(','), '--raw');
|
||||
assert.equal(r.status, 0, `mint scratch ${user}`);
|
||||
const value = r.stdout.trim();
|
||||
assert.ok(/^[0-9a-f]{40}$/.test(value), 'minted token shape');
|
||||
minted.push(value);
|
||||
writeFileSync(process.env.CB3_COPY_CAPTURE, JSON.stringify(minted), { mode: 0o600 });
|
||||
return value;
|
||||
};
|
||||
createUser('scratch-admin', true);
|
||||
createUser('owner');
|
||||
const admin = 'token ' + mint('scratch-admin', ['write:admin', 'write:repository', 'write:user']);
|
||||
for (const repo of ['stack', 'other']) {
|
||||
const made = await call(url, ca, 'POST', '/api/v1/admin/users/owner/repos', { auth: admin, body: { name: repo, auto_init: true, private: true } });
|
||||
assert.equal(made.status, 201, `create owner/${repo}`);
|
||||
}
|
||||
|
||||
// The organization: one root agent "rev" (research, Gitea alone) bound read-only to project stack.
|
||||
const root = mkdtempSync(join(tmpdir(), 'cb3o-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const rolesDir = rolesCopy(root);
|
||||
const doc = { ...businessDoc(root, 'demo'), plugins: { harness: ['pi'] }, vars: { 'bus.tester': true } };
|
||||
doc.projects.stack.remote = `${url}/owner/stack`;
|
||||
writeJson(join(root, 'businesses/demo.json'), doc);
|
||||
const dataRoot = join(root, 'data');
|
||||
mkdirSync(dataRoot, { mode: 0o700 });
|
||||
const ctx = { configDir: root, dataRoot, 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: 'research' } } }, { rolesDir }) }, rolesDir });
|
||||
const writer = organizationWriter({ dir: root, rolesDir }), store = new Store(dataRoot);
|
||||
t.after(() => store.close());
|
||||
const service = hireDecisions({ writer, rolesDir, dataRoot, configDir: root, assertClean: () => {}, copies: (id) => (snapshot.org === id ? snapshot : null) });
|
||||
const storeRoot = join(root, 'secrets', 'agents');
|
||||
mkdirSync(storeRoot, { recursive: true, mode: 0o700 });
|
||||
chmodSync(join(root, 'secrets'), 0o700);
|
||||
const credentials = new Credentials({ references: {}, storeRoot, repoRoots: [], dataRoot: '/nonexistent-data' });
|
||||
// Every request the gate sends, by op only.
|
||||
const sent = [];
|
||||
const transport = (req) => { sent.push(`${req.method} ${req.path.split('?')[0]}`); return send(req); };
|
||||
const gate = copyLaunchGate({ credentials, writer, configDir: root, rolesDir, snapshot, transport, ca, timeoutMs: 10000 });
|
||||
let promoted = snapshot;
|
||||
const business = () => {
|
||||
const d = loadWithCopies('demo', { dir: root, rolesDir, snapshot: promoted });
|
||||
return { ...d, roles: Object.fromEntries(Object.entries(d.roles).map(([n, e]) => [n, { ...d.definitions?.[e.definition], ...e }])) };
|
||||
};
|
||||
const issue = copyGrant({ credentials, writer, copies: snapshot, organizations: () => ['demo'], ca, timeoutMs: 10000 });
|
||||
const b = new Broker({
|
||||
store, businesses: { demo: business() }, copyLaunch: gate,
|
||||
hires: { ...service, apply: async (...args) => { const r = await service.apply(...args); b.reloadBusiness('demo', business()); return r; } },
|
||||
credentialVerbs: () => { throw new BusError('credential-unknown'); }, copyGrant: (...a) => issue(...a),
|
||||
});
|
||||
const consoleCap = b.bindConsole({ business: 'demo', human: 'jason', subject: SUBJECT });
|
||||
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const request_ = (cap, verb, args) => Promise.resolve().then(() => b.request(cap, { verb, args }));
|
||||
await request_(consoleCap, 'hire.start.console', { op: 'change', level: 'root', name: 'rev', reason: 'Bind the copy', binding: { services: READ } });
|
||||
const rec = () => JSON.parse(readFileSync(join(root, 'businesses/demo.json'), 'utf8')).copyBindings.rev;
|
||||
const login = copyBotLogin(rec().orgIncarnation, rec().copyId, 'gitea');
|
||||
createUser(login);
|
||||
const collaborate = (repo) => call(url, ca, 'PUT', `/api/v1/repos/owner/${repo}/collaborators/${login}`, { auth: admin, body: { permission: 'read' } });
|
||||
assert.equal((await collaborate('stack')).status, 204, 'bot is a read collaborator on owner/stack');
|
||||
const issueToken = (token) => request_(human, 'credentials.issue', { agent: 'rev', service: 'gitea', date: ahead(30), token });
|
||||
assert.equal((await issueToken(mint(login, ['read:repository', 'read:user']))).state, 'bound');
|
||||
|
||||
// Bound after this gate was made: restart-required until a boot proves it.
|
||||
await assert.rejects(gate.prepare('demo', 'rev'), { code: 'restart-required' });
|
||||
assert.deepEqual(sent, []);
|
||||
await gate.boot('demo');
|
||||
assert.deepEqual(gate.bound(), { rev: ['gitea'] });
|
||||
assert.ok(sent.includes('GET /api/v1/token'), 'the boot proved the slot live at the service');
|
||||
promoted = promoteCopies(snapshot, gate.bound());
|
||||
b.reloadBusiness('demo', business());
|
||||
|
||||
// A launch: prepare proves the slot again, bindLaunch carries the authority to the session.
|
||||
const project = (await request_(human, 'project.create', { name: 'stack work', repo: 'stack' })).id;
|
||||
const thread = (await request_(human, 'thread.create', { project, name: 'one', instance: 'rev' })).id;
|
||||
const proofs = sent.length;
|
||||
const revision = writer.read('demo').revision;
|
||||
const { authority } = await gate.prepare('demo', 'rev', revision);
|
||||
assert.ok(sent.slice(proofs).includes('GET /api/v1/token'));
|
||||
const cap = b.bindLaunch({ business: 'demo', role: 'rev', run: 'rev-run', harness: 'pi', thread, copyAuthority: authority, revision });
|
||||
const use = (req) => Promise.resolve().then(() => gate.call(b.copySession(cap), req));
|
||||
const own = await use({ method: 'GET', path: '/api/v1/repos/owner/stack' });
|
||||
assert.equal(own.status, 200);
|
||||
assert.equal(own.json.full_name, 'owner/stack');
|
||||
// A repository outside the binding, and a write on a read binding: refused before any request.
|
||||
const before = sent.length;
|
||||
await assert.rejects(use({ method: 'GET', path: '/api/v1/repos/owner/other' }), { code: 'copy-request-refused' });
|
||||
await assert.rejects(use({ method: 'POST', path: '/api/v1/repos/owner/stack/issues', body: { title: 'x' } }), { code: 'copy-request-refused' });
|
||||
assert.equal(sent.length, before);
|
||||
|
||||
// A reissue (a new generation) while the session is live: the next call refuses, none is sent.
|
||||
await issueToken(mint(login, ['read:repository', 'read:user']));
|
||||
assert.equal(rec().services.gitea.grantGeneration, 2);
|
||||
const afterIssue = sent.length;
|
||||
await assert.rejects(use({ method: 'GET', path: '/api/v1/repos/owner/stack' }), { code: 'restart-required' });
|
||||
assert.equal(sent.length, afterIssue, 'no request after the reissue');
|
||||
|
||||
// Gitea answers a renamed repository's old path with a redirect. A fresh boot and launch on the
|
||||
// second token; the bound path now redirects, and the gate refuses it without following.
|
||||
const gate2 = copyLaunchGate({ credentials, writer, configDir: root, rolesDir, snapshot, transport, ca, timeoutMs: 10000 });
|
||||
await gate2.boot('demo');
|
||||
assert.deepEqual(gate2.bound(), { rev: ['gitea'] });
|
||||
const { authority: authority2 } = await gate2.prepare('demo', 'rev');
|
||||
// The broker's session for the old launch is gone with its authority; this one is gate2's own.
|
||||
assert.throws(() => b.copySession(cap), { code: 'restart-required' });
|
||||
const session = { business: 'demo', role: 'rev', copyAuthority: authority2 };
|
||||
assert.equal((await gate2.call(session, { method: 'GET', path: '/api/v1/repos/owner/stack' })).status, 200);
|
||||
assert.equal((await call(url, ca, 'PATCH', '/api/v1/repos/owner/stack', { auth: admin, body: { name: 'moved' } })).status, 200, 'rename owner/stack');
|
||||
const viaOld = await call(url, ca, 'GET', '/api/v1/repos/owner/stack', { auth: admin });
|
||||
assert.equal(viaOld.status, 301, 'Gitea 1.27.1 redirects a renamed repository\'s old path');
|
||||
const n = sent.length;
|
||||
await assert.rejects(gate2.call(session, { method: 'GET', path: '/api/v1/repos/owner/stack' }), { code: 'copy-redirect-refused' });
|
||||
assert.equal(sent.length, n + 1, 'one request, never followed');
|
||||
t.diagnostic(`minted ${minted.length}`);
|
||||
});
|
||||
@@ -0,0 +1,567 @@
|
||||
// #1873 CB3 (design section 5 steps 7 and 8, section 8 row CB3): a bound agent copy launches only
|
||||
// when the bus's boot proved its binding live at the service, every launch carries a launch
|
||||
// authority the bus checks again at prepare, final admission and bindLaunch, and each credential
|
||||
// call is checked against the session's authority before and after the request. The fake Gitea is
|
||||
// fake-gitea-api.mjs on 127.0.0.1; the runtime and launcher halves are runtime tests below and
|
||||
// packages/cli/tests/agent-copies.test.mjs.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { Broker, BusError } from '../src/broker.mjs';
|
||||
import { Store } from '../src/store.mjs';
|
||||
import { Credentials } from '../src/credentials.mjs';
|
||||
import { REFUSALS } from '../src/refusals.mjs';
|
||||
import { copyGrant } from '../src/copy-grant.mjs';
|
||||
import { copyLaunchGate } from '../src/copy-launch.mjs';
|
||||
import { send } from '../src/gitea-grant.mjs';
|
||||
import { fakeGiteaApi } from './fake-gitea-api.mjs';
|
||||
import { hireDecisions } from '../../organization/src/hire-decisions.mjs';
|
||||
import { organizationWriter } from '../../organization/src/organization.mjs';
|
||||
import { checkRootAgents } from '../../organization/src/agent-levels.mjs';
|
||||
import { loadWithCopies, materializeCopies, promoteCopies } from '../../organization/src/agent-copies.mjs';
|
||||
import { activateOrg, reserveOrg } from '../../organization/src/org-registry.mjs';
|
||||
import { copyBotLogin } from '../../organization/src/copy-bindings.mjs';
|
||||
import { businessDoc, rolesCopy, writeJson } from '../../organization/tests/helpers.mjs';
|
||||
|
||||
const SUBJECT = { provider: 'test', iss: 'https://issuer.test', sub: 'owner' };
|
||||
const READ = { gitea: { scopes: ['read:repository', 'read:user'], projects: ['stack'] } };
|
||||
const WRITE = { gitea: { scopes: ['read:user', 'write:repository'], projects: ['stack'] } };
|
||||
const ahead = (days) => new Date(Date.now() + days * 86400000).toISOString().slice(0, 10);
|
||||
const CODES = ['restart-required', 'credential-revoked', 'credential-revoking', 'credential-unavailable', 'credential-expired',
|
||||
'copy-authority-changed', 'copy-request-refused', 'copy-redirect-refused', 'organization-changed', 'organization-unreadable'];
|
||||
|
||||
// demo with the project stack on the fake Gitea and the root copy rev (research, which needs gitea
|
||||
// alone; a binding may be narrower than the role). The
|
||||
// owner binds rev with `services` and, unless `issue` is false, issues one live token for it (CB2).
|
||||
// `gate` is the CB3 gate on the boot snapshot; `boot()` runs its boot check and returns the broker's
|
||||
// business with the copies it proved promoted, as runtime.mjs does.
|
||||
async function fixture(t, { services = READ, perm = 'read', issue = true, definition = 'research', budgets = {} } = {}) {
|
||||
let clock = Date.now();
|
||||
const root = mkdtempSync(join(tmpdir(), 'cb3-')), rolesDir = rolesCopy(root);
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const api = await fakeGiteaApi(t, root);
|
||||
const doc = { ...businessDoc(root, 'demo'), plugins: { harness: ['pi'] }, vars: { 'bus.tester': true } };
|
||||
doc.projects.stack.remote = `${api.url}/owner/stack`;
|
||||
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' };
|
||||
await reserveOrg(ctx, 'demo'); await activateOrg(ctx, 'demo');
|
||||
const snapshot = materializeCopies({ orgId: 'demo', doc, root: { state: 'ok', ...checkRootAgents({ agentsVersion: 1, agents: { rev: { definition } } }, { rolesDir }) }, rolesDir });
|
||||
const writer = organizationWriter({ dir: root, rolesDir }), store = new Store(dataRoot);
|
||||
t.after(() => store.close());
|
||||
const service = hireDecisions({ writer, rolesDir, dataRoot, configDir: root, assertClean: () => {}, copies: (id) => snapshot.org === id ? snapshot : null });
|
||||
const storeRoot = join(root, 'secrets', 'agents');
|
||||
mkdirSync(storeRoot, { recursive: true, mode: 0o700 }); chmodSync(join(root, 'secrets'), 0o700);
|
||||
const credentials = new Credentials({ references: {}, storeRoot, repoRoots: [], dataRoot: '/nonexistent-data' });
|
||||
// Every request the gate sends, with what the test lets through; `hold` parks one until released.
|
||||
const sent = [];
|
||||
const transport = async (req) => { sent.push(`${req.method} ${req.path.split('?')[0]}`); return send(req); };
|
||||
const gate = copyLaunchGate({ credentials, writer, configDir: root, rolesDir, snapshot, transport, ca: api.ca, timeoutMs: 3000, now: () => new Date(clock), ...budgets });
|
||||
const advance = (days) => { clock += days * 86400000; };
|
||||
let promoted = snapshot;
|
||||
const business = () => {
|
||||
const d = loadWithCopies('demo', { dir: root, rolesDir, snapshot: promoted });
|
||||
return { ...d, roles: Object.fromEntries(Object.entries(d.roles).map(([n, e]) => [n, { ...d.definitions?.[e.definition], ...e }])) };
|
||||
};
|
||||
const issueGrant = (...a) => copyGrant({ credentials, writer, copies: snapshot, organizations: () => ['demo'], ca: api.ca, timeoutMs: 3000 })(...a);
|
||||
const b = new Broker({ store, businesses: { demo: business() }, copyLaunch: gate,
|
||||
hires: { ...service, apply: async (...args) => { const r = await service.apply(...args); b.reloadBusiness('demo', business()); return r; } },
|
||||
credentialVerbs: () => { throw new BusError('credential-unknown'); }, copyGrant: (...a) => issueGrant(...a) });
|
||||
const owner = b.bindConsole({ business: 'demo', human: 'jason', subject: SUBJECT });
|
||||
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const call = (cap, verb, args = {}) => Promise.resolve().then(() => b.request(cap, { verb, args }));
|
||||
const rec = () => JSON.parse(readFileSync(file, 'utf8')).copyBindings?.rev;
|
||||
const repo = api.addRepo('owner', 'stack');
|
||||
api.addRepo('owner', 'other');
|
||||
await call(owner, 'hire.start.console', { op: 'change', level: 'root', name: 'rev', reason: 'Bind the copy', binding: { services } });
|
||||
const r = rec();
|
||||
const login = copyBotLogin(r.orgIncarnation, r.copyId, 'gitea');
|
||||
const bot = api.addUser(login).user;
|
||||
repo.collaborators.push(login); repo.permissions[login] = perm;
|
||||
const scopes = services.gitea.scopes;
|
||||
const reissue = (scopesNow = scopes) => call(human, 'credentials.issue', { agent: 'rev', service: 'gitea', date: ahead(30), token: api.addToken(login, `t${api.issued.length}`, scopesNow).sha1 });
|
||||
if (issue) await reissue();
|
||||
const boot = async () => {
|
||||
await gate.boot('demo');
|
||||
promoted = promoteCopies(snapshot, gate.bound());
|
||||
b.reloadBusiness('demo', business());
|
||||
};
|
||||
const slot = join(storeRoot, 'demo', 'rev', 'gitea');
|
||||
// A run of rev, bound with the authority the launcher's prepare returned.
|
||||
// rev is a thread instance, so each run takes a thread of its own in the stack project.
|
||||
let project = null;
|
||||
const threadFor = async (run) => {
|
||||
project ??= (await call(human, 'project.create', { name: 'stack work', repo: 'stack' })).id;
|
||||
return (await call(human, 'thread.create', { project, name: run, instance: 'rev' })).id;
|
||||
};
|
||||
const launch = async (run = 'rev-run') => {
|
||||
const thread = await threadFor(run);
|
||||
const revision = writer.read('demo').revision;
|
||||
const { authority } = await gate.prepare('demo', 'rev', revision);
|
||||
return { authority, thread, cap: b.bindLaunch({ business: 'demo', role: 'rev', run, harness: 'pi', thread, copyAuthority: authority, revision }) };
|
||||
};
|
||||
const use = (cap, request) => Promise.resolve().then(() => gate.call(b.copySession(cap), request));
|
||||
const GET = { method: 'GET', path: '/api/v1/repos/owner/stack' };
|
||||
// The organization file edited in place, as an owner's editor would.
|
||||
const edit = (fn) => {
|
||||
const d = JSON.parse(readFileSync(file, 'utf8'));
|
||||
fn(d);
|
||||
writeFileSync(file, JSON.stringify(d, null, 2) + '\n');
|
||||
};
|
||||
// The next request matching `match` waits at the fake server until release() lets it through.
|
||||
const hold = (match) => {
|
||||
let release, arrived;
|
||||
const go = new Promise((r) => { release = r; });
|
||||
const seen = new Promise((r) => { arrived = r; });
|
||||
const unrule = api.rule((e, body) => match(e, body), async () => { unrule(); arrived(); await go; return { pass: true }; });
|
||||
return { release: () => release(), arrived: seen };
|
||||
};
|
||||
// Gate requests to the copy's repositories (the proof's /version, /token and /user routes aside).
|
||||
const repoCalls = () => sent.filter((x) => x.includes('/api/v1/repos/')).length;
|
||||
return { root, rolesDir, edit, hold, advance, repoCalls, file, api, b, gate, threadFor, writer, credentials, owner, human, call, rec, bot, login, repo, reissue, boot, slot, launch, use, sent, GET, storeRoot, snapshot, business };
|
||||
}
|
||||
|
||||
const refuses = (code) => (e) => { assert.ok(e instanceof BusError, String(e?.stack ?? e)); assert.equal(e.code, code); return true; };
|
||||
|
||||
test('CB3 every new refusal code is in the refusal table with a rail and a next step naming no path', () => {
|
||||
for (const code of CODES) {
|
||||
const entry = REFUSALS[code];
|
||||
assert.ok(entry, code);
|
||||
assert.match(entry.rail, /^R\d+[/:]/, code);
|
||||
assert.ok(entry.nextStep.length > 20, code);
|
||||
assert.ok(!/(^|\s)(\/|~\/)\S/.test(entry.nextStep), `${code} names a path`);
|
||||
}
|
||||
});
|
||||
|
||||
test('CB3 a copy bound before boot is proved live at boot and launches; prepare, admission and bindLaunch carry one authority', async (t) => {
|
||||
const f = await fixture(t);
|
||||
assert.equal(f.rec().state, 'bound');
|
||||
await f.boot();
|
||||
assert.deepEqual(f.gate.bound(), { rev: ['gitea'] });
|
||||
const role = f.b.taskRoles('demo').rev;
|
||||
assert.deepEqual([role.copyBound, role.credentials], [['gitea'], {}]);
|
||||
const { authority, cap } = await f.launch();
|
||||
assert.match(authority, /^[0-9a-f]{64}$/);
|
||||
assert.equal(f.gate.authority('demo', 'rev').authority, authority);
|
||||
// The live proof at prepare went to the bound origin as the copy's account.
|
||||
assert.ok(f.sent.includes('GET /api/v1/token'));
|
||||
const r = await f.use(cap, f.GET);
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal(r.json.full_name, 'owner/stack');
|
||||
assert.ok(!JSON.stringify(r).includes(f.api.issued.at(-1)));
|
||||
// bindLaunch refuses a bound copy without the authority, or with another one.
|
||||
const r2 = await f.threadFor('r2'), r3 = await f.threadFor('r3');
|
||||
assert.throws(() => f.b.bindLaunch({ business: 'demo', role: 'rev', run: 'r2', harness: 'pi', thread: r2 }), refuses('copy-authority-changed'));
|
||||
assert.throws(() => f.b.bindLaunch({ business: 'demo', role: 'rev', run: 'r3', harness: 'pi', thread: r3, copyAuthority: 'f'.repeat(64) }), refuses('copy-authority-changed'));
|
||||
// A non-copy never carries one.
|
||||
assert.throws(() => f.b.bindLaunch({ business: 'demo', role: 'chief', run: 'c1', harness: 'pi', copyAuthority: authority }), refuses('invalid-request'));
|
||||
});
|
||||
|
||||
test('CB3 a binding approved after boot answers restart-required at every launch step and sends nothing', async (t) => {
|
||||
const f = await fixture(t, { issue: false });
|
||||
assert.notEqual(f.rec().state, 'bound');
|
||||
await f.boot();
|
||||
assert.deepEqual([f.gate.bound(), f.sent], [{}, []]);
|
||||
// The owner issues the grant now: the record is bound and granted, but the boot didn't prove it.
|
||||
await f.reissue();
|
||||
assert.equal(f.rec().state, 'bound');
|
||||
assert.equal(f.b.taskRoles('demo').rev, undefined);
|
||||
assert.throws(() => f.gate.authority('demo', 'rev'), refuses('restart-required'));
|
||||
await assert.rejects(f.gate.prepare('demo', 'rev'), refuses('restart-required'));
|
||||
assert.throws(() => f.gate.admit('demo', 'rev', 'f'.repeat(64)), refuses('restart-required'));
|
||||
// The verb says so too, before its own unbound refusal.
|
||||
await assert.rejects(f.call(f.human, 'instance.launch', { instance: 'rev' }), refuses('restart-required'));
|
||||
assert.deepEqual(f.sent, []);
|
||||
});
|
||||
|
||||
test('CB3 a partial binding (a role needing gitea and vikunja, bound for gitea alone) never launches and its boot sends nothing', async (t) => {
|
||||
const f = await fixture(t, { definition: 'reviewer' });
|
||||
assert.equal(f.rec().state, 'bound');
|
||||
await f.boot();
|
||||
assert.deepEqual([f.gate.bound(), f.sent], [{}, []]);
|
||||
assert.throws(() => f.gate.authority('demo', 'rev'), refuses('credential-scope-unverified'));
|
||||
await assert.rejects(f.gate.prepare('demo', 'rev'), refuses('credential-scope-unverified'));
|
||||
await assert.rejects(f.call(f.human, 'instance.launch', { instance: 'rev' }), refuses('credential-scope-unverified'));
|
||||
assert.deepEqual(f.sent, []);
|
||||
});
|
||||
|
||||
test('CB3 a redirect from the bound origin is refused and never followed', async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.boot();
|
||||
const { cap } = await f.launch();
|
||||
f.api.rule((e) => e.path === '/api/v1/repos/owner/stack', { status: 302 });
|
||||
await assert.rejects(f.use(cap, f.GET), refuses('copy-redirect-refused'));
|
||||
assert.equal(f.api.requests.filter((e) => e.path.startsWith('/api/v1/repos/')).length, 1);
|
||||
});
|
||||
|
||||
test('CB3 a call reaches only the admitted repositories, by the methods the binding allows, with no body on a read', async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.boot();
|
||||
const { cap } = await f.launch();
|
||||
for (const request of [
|
||||
{ method: 'GET', path: '/api/v1/repos/owner/other' },
|
||||
{ method: 'GET', path: '/api/v1/repos/owner/stack/../other' },
|
||||
{ method: 'GET', path: '/api/v1/repos/owner/stack/%2e%2e/other' },
|
||||
{ method: 'GET', path: '/api/v1/user' },
|
||||
{ method: 'GET', path: 'https://evil.test/api/v1/repos/owner/stack' },
|
||||
{ method: 'GET', path: '//evil.test/api/v1/repos/owner/stack' },
|
||||
{ method: 'GET', path: '/api/v1/repos/owner/stack', body: { x: 1 } },
|
||||
{ method: 'POST', path: '/api/v1/repos/owner/stack/issues', body: { title: 'x' } },
|
||||
{ method: 'TRACE', path: '/api/v1/repos/owner/stack' },
|
||||
{ service: 'vikunja', method: 'GET', path: '/api/v1/repos/owner/stack' },
|
||||
]) await assert.rejects(f.use(cap, request), refuses('copy-request-refused'), JSON.stringify(request));
|
||||
assert.equal(f.repoCalls(), 0);
|
||||
// The session, never the arguments, names who the call is for.
|
||||
await assert.rejects(f.gate.call({ business: 'demo', role: 'rev', copyAuthority: 'f'.repeat(64) }, f.GET), refuses('copy-authority-changed'));
|
||||
assert.throws(() => f.b.copySession(f.human), refuses('copy-request-refused'));
|
||||
assert.equal(f.repoCalls(), 0);
|
||||
});
|
||||
|
||||
test('CB3 a write binding reaches its repository by POST as the copy\'s account (a READ binding is refused above)', async (t) => {
|
||||
const f = await fixture(t, { services: WRITE, perm: 'write' });
|
||||
await f.boot();
|
||||
const { cap } = await f.launch();
|
||||
f.api.rule((e) => e.method === 'POST' && e.path === '/api/v1/repos/owner/stack/issues', { status: 201, json: { number: 1 } });
|
||||
const r = await f.use(cap, { method: 'POST', path: '/api/v1/repos/owner/stack/issues', body: { title: 'x' } });
|
||||
assert.deepEqual(r, { status: 201, json: { number: 1 } });
|
||||
const seen = f.api.requests.filter((e) => e.path === '/api/v1/repos/owner/stack/issues');
|
||||
assert.deepEqual(seen.map((e) => [e.method, e.as]), [['POST', f.login]]);
|
||||
});
|
||||
|
||||
test('CB3 a re-issue, a replaced slot or rewritten token bytes deny the next call with no request and no cached token', async (t) => {
|
||||
for (const [name, change, code] of [
|
||||
['re-issue', (f) => f.reissue(), 'restart-required'],
|
||||
['slot replaced', (f) => { writeFileSync(f.slot + '.n', 'x'.repeat(40), { mode: 0o600 }); renameSync(f.slot + '.n', f.slot); }, 'credential-unavailable'],
|
||||
['bytes rewritten', (f) => writeFileSync(f.slot, readFileSync(f.slot, 'utf8').replace(/[0-9a-f]{40}/, '0'.repeat(40))), 'credential-unavailable'],
|
||||
['slot removed', (f) => unlinkSync(f.slot), 'credential-unavailable'],
|
||||
['mode widened', (f) => chmodSync(f.slot, 0o644), 'credential-unavailable'],
|
||||
]) {
|
||||
await t.test(name, async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.boot();
|
||||
const { cap } = await f.launch();
|
||||
assert.equal((await f.use(cap, f.GET)).status, 200);
|
||||
const before = f.sent.length;
|
||||
await change(f);
|
||||
// The bus's session lookup refuses the run itself, before the gate's own check.
|
||||
assert.throws(() => f.b.copySession(cap), refuses(code));
|
||||
await assert.rejects(f.use(cap, f.GET), refuses(code));
|
||||
// No new launch either.
|
||||
await assert.rejects(f.gate.prepare('demo', 'rev'), refuses(code));
|
||||
assert.deepEqual(f.sent.slice(before), []);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('CB3 a revoke while a call is in flight denies that call\'s answer and the next call', async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.boot();
|
||||
const { cap } = await f.launch();
|
||||
const h = f.hold((e) => e.path === '/api/v1/repos/owner/stack');
|
||||
const pending = f.use(cap, f.GET);
|
||||
await h.arrived;
|
||||
await f.reissue();
|
||||
h.release();
|
||||
await assert.rejects(pending, refuses('restart-required'));
|
||||
const before = f.sent.length;
|
||||
await assert.rejects(f.use(cap, f.GET), refuses('restart-required'));
|
||||
assert.equal(f.sent.length, before);
|
||||
});
|
||||
|
||||
test('CB3 a grant change in flight fences the copy: nothing is admitted or sent until it ends, and an unknown outcome keeps the fence', async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.boot();
|
||||
const { cap } = await f.launch();
|
||||
const r2 = await f.threadFor('r2');
|
||||
const authority = f.gate.authority('demo', 'rev').authority;
|
||||
// The owner's re-issue holds at its live proof; the copy is fenced meanwhile.
|
||||
const h = f.hold((e) => e.path === '/api/v1/version');
|
||||
const issuing = f.reissue();
|
||||
await h.arrived;
|
||||
const before = f.sent.length;
|
||||
assert.equal(f.gate.revoking('demo', 'rev'), true);
|
||||
assert.throws(() => f.gate.authority('demo', 'rev'), refuses('credential-revoking'));
|
||||
assert.throws(() => f.gate.admit('demo', 'rev', authority), refuses('credential-revoking'));
|
||||
await assert.rejects(f.gate.prepare('demo', 'rev'), refuses('credential-revoking'));
|
||||
assert.throws(() => f.b.bindLaunch({ business: 'demo', role: 'rev', run: 'r2', harness: 'pi', thread: r2, copyAuthority: authority }), refuses('credential-revoking'));
|
||||
await assert.rejects(f.use(cap, f.GET), refuses('credential-revoking'));
|
||||
assert.equal(f.sent.length, before);
|
||||
h.release();
|
||||
await issuing;
|
||||
assert.equal(f.gate.revoking('demo', 'rev'), false);
|
||||
assert.throws(() => f.gate.authority('demo', 'rev'), refuses('restart-required'));
|
||||
// A definite refusal lifts the fence; an unknown outcome, a system error or a codeless throw keeps it.
|
||||
await assert.rejects(f.gate.fence('demo', 'rev', () => { throw new BusError('credential-date'); }), refuses('credential-date'));
|
||||
assert.equal(f.gate.revoking('demo', 'rev'), false);
|
||||
for (const thrown of [new BusError('outcome-unknown'), Object.assign(new Error('disk'), { code: 'ENOSPC' }), new Error('bug')]) {
|
||||
const g = await fixture(t);
|
||||
await g.boot();
|
||||
await assert.rejects(g.gate.fence('demo', 'rev', () => { throw thrown; }));
|
||||
assert.equal(g.gate.revoking('demo', 'rev'), true, String(thrown.code));
|
||||
await assert.rejects(g.gate.prepare('demo', 'rev'), refuses('credential-revoking'));
|
||||
assert.deepEqual(g.sent.filter((x) => !x.startsWith('GET /api/v1/version') && !x.startsWith('GET /api/v1/token') && !x.startsWith('GET /api/v1/user')), []);
|
||||
}
|
||||
});
|
||||
|
||||
// Each change lands while prepare's live proof is at the server (between the authority before and
|
||||
// after), then again between prepare and bindLaunch, then between bindLaunch and the first use. None
|
||||
// may end in a request to the copy's repositories.
|
||||
const RACES = [
|
||||
['role definition changed', (f) => writeFileSync(join(f.rolesDir, 'research.md'), readFileSync(join(f.rolesDir, 'research.md'), 'utf8') + '\nChanged.\n')],
|
||||
['binding generation moved', (f) => f.edit((d) => { d.copyBindings.rev.generation += 1; })],
|
||||
['binding unbound', (f) => f.edit((d) => { d.copyBindings.rev.state = 'unbound'; })],
|
||||
['grant scopes narrowed', (f) => f.edit((d) => { d.copyBindings.rev.services.gitea.scopes = ['read:user']; })],
|
||||
['token bytes rewritten', (f) => writeFileSync(f.slot, readFileSync(f.slot, 'utf8').replace(/[0-9a-f]{40}/, '0'.repeat(40)))],
|
||||
['project remote moved', (f) => f.edit((d) => { d.projects.stack.remote = 'https://elsewhere.test/owner/stack'; })],
|
||||
['project removed', (f) => f.edit((d) => { delete d.projects.stack; })],
|
||||
['project excludes the copy', (f) => f.edit((d) => { d.projects.stack.agents = { ...d.projects.stack.agents, rev: { exclude: true } }; })],
|
||||
];
|
||||
|
||||
test('CB3 a change during prepare\'s live proof refuses the launch with no repository request', async (t) => {
|
||||
for (const [name, change] of RACES) {
|
||||
await t.test(name, async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.boot();
|
||||
const h = f.hold((e) => e.path === '/api/v1/token');
|
||||
const preparing = f.gate.prepare('demo', 'rev');
|
||||
await h.arrived;
|
||||
await change(f);
|
||||
h.release();
|
||||
await assert.rejects(preparing, (e) => e instanceof BusError && /^(copy-|credential-|restart-)/.test(e.code));
|
||||
assert.equal(f.repoCalls(), 0);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('CB3 a change between prepare and bindLaunch, or between bindLaunch and use, refuses with no repository request', async (t) => {
|
||||
for (const [name, change] of RACES) {
|
||||
await t.test(`${name} before bind`, async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.boot();
|
||||
const thread = await f.threadFor('r1');
|
||||
const { authority } = await f.gate.prepare('demo', 'rev');
|
||||
await change(f);
|
||||
assert.throws(() => f.b.bindLaunch({ business: 'demo', role: 'rev', run: 'r1', harness: 'pi', thread, copyAuthority: authority }),
|
||||
(e) => e instanceof BusError && /^(copy-|credential-|restart-)/.test(e.code));
|
||||
assert.equal(f.repoCalls(), 0);
|
||||
});
|
||||
await t.test(`${name} before use`, async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.boot();
|
||||
const { cap } = await f.launch();
|
||||
await change(f);
|
||||
await assert.rejects(f.use(cap, f.GET), (e) => e instanceof BusError && /^(copy-|credential-|restart-|run-)/.test(e.code));
|
||||
assert.equal(f.repoCalls(), 0);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// The organization's registry entry rewritten in place (mode kept), as a lifecycle change would.
|
||||
const registryEdit = (f, fn) => {
|
||||
const file = join(f.root, 'org-runtime.json');
|
||||
const d = JSON.parse(readFileSync(file, 'utf8'));
|
||||
fn(d.orgs.demo, d);
|
||||
writeFileSync(file, JSON.stringify(d, null, 2) + '\n');
|
||||
};
|
||||
|
||||
test('CB3 an organization no longer active admits and sends nothing, with the binding and incarnation unchanged', async (t) => {
|
||||
for (const state of ['archived', 'deleting', 'reserved']) {
|
||||
await t.test(`${state} after launch`, async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.boot();
|
||||
const { cap, authority } = await f.launch();
|
||||
registryEdit(f, (e) => { e.state = state; });
|
||||
const before = f.sent.length;
|
||||
assert.throws(() => f.gate.authority('demo', 'rev'), refuses('credential-revoked'));
|
||||
assert.throws(() => f.gate.admit('demo', 'rev', authority), refuses('credential-revoked'));
|
||||
await assert.rejects(f.gate.prepare('demo', 'rev'), refuses('credential-revoked'));
|
||||
await assert.rejects(f.use(cap, f.GET), refuses('credential-revoked'));
|
||||
assert.equal(f.sent.length, before);
|
||||
assert.equal(f.repoCalls(), 0);
|
||||
});
|
||||
}
|
||||
await t.test('archived while prepare\'s proof is at the server', async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.boot();
|
||||
const h = f.hold((e) => e.path === '/api/v1/token');
|
||||
const preparing = f.gate.prepare('demo', 'rev');
|
||||
await h.arrived;
|
||||
registryEdit(f, (e) => { e.state = 'archived'; });
|
||||
h.release();
|
||||
await assert.rejects(preparing, refuses('credential-revoked'));
|
||||
assert.equal(f.repoCalls(), 0);
|
||||
});
|
||||
await t.test('archived before boot', async (t) => {
|
||||
const f = await fixture(t);
|
||||
registryEdit(f, (e) => { e.state = 'archived'; });
|
||||
await f.boot();
|
||||
assert.deepEqual([f.gate.bound(), f.sent], [{}, []]);
|
||||
});
|
||||
await t.test('registry unsafe after launch', async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.boot();
|
||||
const { cap } = await f.launch();
|
||||
chmodSync(join(f.root, 'org-runtime.json'), 0o644);
|
||||
const before = f.sent.length;
|
||||
await assert.rejects(f.use(cap, f.GET), refuses('credential-unavailable'));
|
||||
assert.equal(f.sent.length, before);
|
||||
});
|
||||
});
|
||||
|
||||
test('CB3 an organization recreated under the same id (a new incarnation) admits and sends nothing for the old copy', async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.boot();
|
||||
const { cap, authority } = await f.launch();
|
||||
registryEdit(f, (e) => { e.incarnation = 'f'.repeat(32); });
|
||||
const before = f.sent.length;
|
||||
assert.throws(() => f.gate.admit('demo', 'rev', authority), refuses('credential-revoked'));
|
||||
await assert.rejects(f.gate.prepare('demo', 'rev'), refuses('credential-revoked'));
|
||||
await assert.rejects(f.use(cap, f.GET), refuses('credential-revoked'));
|
||||
assert.equal(f.sent.length, before);
|
||||
});
|
||||
|
||||
// The project points at another repository on the same server: origin, binding and slot are as
|
||||
// boot proved them, only the repositories the launch authority covers move.
|
||||
const repointed = (f) => f.edit((d) => { d.projects.stack.remote = d.projects.stack.remote.replace('/owner/stack', '/owner/other'); });
|
||||
|
||||
test('CB3 a change while a call is in flight denies that call\'s answer, including one that moves only the launch authority', async (t) => {
|
||||
for (const [name, change, code] of [['repository repointed', repointed, 'credential-revoked'],
|
||||
...RACES.filter(([n]) => ['role definition changed', 'project excludes the copy'].includes(n)).map(([n, c]) => [n, c, null])]) {
|
||||
await t.test(name, async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.boot();
|
||||
const { cap } = await f.launch();
|
||||
const h = f.hold((e) => e.path === '/api/v1/repos/owner/stack');
|
||||
const pending = f.use(cap, f.GET);
|
||||
await h.arrived;
|
||||
await change(f);
|
||||
h.release();
|
||||
await assert.rejects(pending, code ? refuses(code) : (e) => e instanceof BusError && /^(copy-|credential-|restart-)/.test(e.code));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('CB3 a running copy session whose launch authority moved answers no agent verb', async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.boot();
|
||||
const { cap } = await f.launch();
|
||||
// Past the copy check to the holder check: the session holds no claim yet.
|
||||
await assert.rejects(f.call(cap, 'inbox'), refuses('not-holder'));
|
||||
repointed(f);
|
||||
assert.doesNotThrow(() => f.gate.authority('demo', 'rev'));
|
||||
await assert.rejects(f.call(cap, 'inbox'), refuses('copy-authority-changed'));
|
||||
});
|
||||
|
||||
test('CB3 a binding with no repository scope sends no repository request, read or write', async (t) => {
|
||||
const f = await fixture(t, { services: { gitea: { scopes: ['read:user'], projects: ['stack'] } } });
|
||||
await f.boot();
|
||||
assert.deepEqual(f.gate.bound(), { rev: ['gitea'] });
|
||||
const { cap } = await f.launch();
|
||||
for (const method of ['GET', 'HEAD', 'POST'])
|
||||
await assert.rejects(f.use(cap, { method, path: '/api/v1/repos/owner/stack' }), refuses('copy-request-refused'), method);
|
||||
assert.equal(f.repoCalls(), 0);
|
||||
});
|
||||
|
||||
test('CB3 the organization file revision the launch resolved holds through prepare, final admission and bindLaunch', async (t) => {
|
||||
// Same document, other bytes: the launch authority is unchanged, only the revision moves.
|
||||
const rewrite = (f) => writeFileSync(f.file, JSON.stringify(JSON.parse(readFileSync(f.file, 'utf8'))) + '\n');
|
||||
await t.test('changed before prepare', async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.boot();
|
||||
const revision = f.writer.read('demo').revision;
|
||||
rewrite(f);
|
||||
const before = f.sent.length;
|
||||
await assert.rejects(f.gate.prepare('demo', 'rev', revision), refuses('organization-changed'));
|
||||
assert.equal(f.sent.length, before);
|
||||
});
|
||||
await t.test('changed during prepare\'s proof', async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.boot();
|
||||
const revision = f.writer.read('demo').revision;
|
||||
const h = f.hold((e) => e.path === '/api/v1/token');
|
||||
const preparing = f.gate.prepare('demo', 'rev', revision);
|
||||
await h.arrived;
|
||||
rewrite(f);
|
||||
h.release();
|
||||
await assert.rejects(preparing, refuses('organization-changed'));
|
||||
});
|
||||
await t.test('changed after final admission, before bindLaunch', async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.boot();
|
||||
const thread = await f.threadFor('r1');
|
||||
const revision = f.writer.read('demo').revision;
|
||||
const { authority } = await f.gate.prepare('demo', 'rev', revision);
|
||||
f.gate.admit('demo', 'rev', authority, revision);
|
||||
rewrite(f);
|
||||
assert.equal(f.gate.authority('demo', 'rev').authority, authority);
|
||||
assert.throws(() => f.gate.admit('demo', 'rev', authority, revision), refuses('organization-changed'));
|
||||
const bind = (extra) => f.b.bindLaunch({ business: 'demo', role: 'rev', run: 'r1', harness: 'pi', thread, copyAuthority: authority, ...extra });
|
||||
assert.throws(() => bind({ revision }), refuses('organization-changed'));
|
||||
assert.throws(() => bind({}), refuses('organization-changed'));
|
||||
assert.equal(f.repoCalls(), 0);
|
||||
// The same authority on the file as it stands now binds: the refusal above was the revision's.
|
||||
assert.ok(bind({ revision: f.writer.read('demo').revision }));
|
||||
});
|
||||
await t.test('a non-copy never carries a revision', async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.boot();
|
||||
assert.throws(() => f.b.bindLaunch({ business: 'demo', role: 'chief', run: 'c1', harness: 'pi', revision: f.writer.read('demo').revision }), refuses('invalid-request'));
|
||||
});
|
||||
});
|
||||
|
||||
test('CB3 a copy past its rotation date refuses credential-expired at prepare and at the next call', async (t) => {
|
||||
const f = await fixture(t);
|
||||
await f.boot();
|
||||
const { cap } = await f.launch();
|
||||
f.advance(31);
|
||||
const before = f.sent.length;
|
||||
await assert.rejects(f.gate.prepare('demo', 'rev'), refuses('credential-expired'));
|
||||
await assert.rejects(f.use(cap, f.GET), refuses('credential-expired'));
|
||||
assert.equal(f.sent.length, before);
|
||||
});
|
||||
|
||||
test('CB3 a boot that cannot verify proves nothing: the service unreachable, the token refused, the slot missing', async (t) => {
|
||||
for (const [name, setup, code] of [
|
||||
// The verifier answers an unreachable service as unverified (copy-grant.mjs verifyGiteaToken).
|
||||
['service down', (f) => f.api.rule(() => true, { drop: true }), 'credential-scope-unverified'],
|
||||
['token revoked at the service', (f) => f.api.rule((e) => e.path === '/api/v1/token', { status: 401, json: { message: 'no' } }), 'credential-scope-unverified'],
|
||||
['slot missing', (f) => unlinkSync(f.slot), 'credential-unavailable'],
|
||||
]) {
|
||||
await t.test(name, async (t) => {
|
||||
const f = await fixture(t);
|
||||
setup(f);
|
||||
await f.boot();
|
||||
assert.deepEqual(f.gate.bound(), {});
|
||||
assert.throws(() => f.gate.authority('demo', 'rev'), refuses(code));
|
||||
await assert.rejects(f.call(f.human, 'instance.launch', { instance: 'rev' }), refuses(code));
|
||||
assert.equal(f.repoCalls(), 0);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// The launcher's host stops on a reply later than 10 s, so a slow service must not hold prepare past
|
||||
// its budget: the proof gets no answer, the launch is refused, and nothing more is sent.
|
||||
test('CB3: a service that does not answer within the budget fails prepare and boot closed, inside the budget', { timeout: 60000 }, async (t) => {
|
||||
await t.test('prepare', async (t) => {
|
||||
const f = await fixture(t, { budgets: { prepareMs: 400 } });
|
||||
await f.boot();
|
||||
const unrule = f.api.rule((e) => e.path === '/api/v1/token', { hang: true });
|
||||
t.after(unrule);
|
||||
const at = Date.now();
|
||||
await assert.rejects(f.gate.prepare('demo', 'rev'), (e) => e.code === 'credential-scope-unverified');
|
||||
assert.ok(Date.now() - at < 2500, `prepare took ${Date.now() - at} ms`);
|
||||
assert.equal(f.repoCalls(), 0);
|
||||
});
|
||||
await t.test('boot', async (t) => {
|
||||
const f = await fixture(t, { budgets: { bootMs: 400 } });
|
||||
const unrule = f.api.rule((e) => e.path === '/api/v1/token', { hang: true });
|
||||
t.after(unrule);
|
||||
const at = Date.now();
|
||||
await f.boot();
|
||||
assert.ok(Date.now() - at < 2500, `boot took ${Date.now() - at} ms`);
|
||||
assert.deepEqual(f.gate.bound(), {});
|
||||
await assert.rejects(f.gate.prepare('demo', 'rev'), (e) => e.code === 'credential-scope-unverified');
|
||||
});
|
||||
});
|
||||
@@ -4,13 +4,15 @@
|
||||
// scratch root only.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs';
|
||||
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { startBroker } from '../src/runtime.mjs';
|
||||
import { Broker, CREDENTIAL_VERBS } from '../src/broker.mjs';
|
||||
import { Store } from '../src/store.mjs';
|
||||
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../organization/tests/helpers.mjs';
|
||||
import { definitionPin } from '../../organization/src/copy-bindings.mjs';
|
||||
import { readTemplate } from '../../organization/src/definitions.mjs';
|
||||
|
||||
const TOKEN = 'fake_issued_token_0123456789';
|
||||
const ROTATED = 'fake_rotated_token_9876543210';
|
||||
@@ -18,16 +20,18 @@ const OTHER = 'fake_other_token_5555555555';
|
||||
const DATE = '2099-01-01';
|
||||
const role = { authority: { withinRole: ['message.send'], crossRole: [] } };
|
||||
|
||||
async function setup(t, { storeRoot = true } = {}) {
|
||||
async function setup(t, { storeRoot = true, before = () => {}, edit = () => {} } = {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'bus-cred-verbs-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const rolesDir = rolesCopy(root);
|
||||
const doc = { ...businessDoc(root, 'demo'), plugins: { harness: ['pi'] } };
|
||||
edit(doc, rolesDir);
|
||||
doc.roles.coder.credentials.gitea = { store: true, rotateBy: DATE };
|
||||
writeJson(join(root, 'businesses/demo.json'), doc);
|
||||
mkdirSync(join(root, 'data'), { mode: 0o700 });
|
||||
if (storeRoot) mkdirSync(join(root, 'secrets', 'agents'), { mode: 0o700 });
|
||||
const store = (agent, service) => join(root, 'secrets', 'agents', 'demo', agent, service);
|
||||
before(store);
|
||||
// The bus's resolved business carries the same references; a store one names its derived file.
|
||||
const demo = {
|
||||
id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, vars: { 'bus.tester': true },
|
||||
@@ -187,3 +191,26 @@ test('#1886: a store root that exists with loose modes still refuses start', asy
|
||||
};
|
||||
await assert.rejects(startBroker({ dataRoot: join(root, 'data'), businesses: { demo }, organization: { dir: root, rolesDir, system: systemFor(root) } }), /credential-file/);
|
||||
});
|
||||
|
||||
test('CB2 security review P2: the start sweep removes a copy slot a crashed issue left staged', async (t) => {
|
||||
// A copy binding left pending by an issue killed after its stage, its token and a temp file on disk.
|
||||
const pending = (doc, rolesDir) => {
|
||||
doc.copyBindings = { rev: {
|
||||
copyId: 'c'.repeat(32), orgIncarnation: 'a'.repeat(32), source: { level: 'root', name: 'rev', project: null },
|
||||
definition: definitionPin('reviewer', { definitions: {}, rolesDir, readTemplate }), generation: 2, state: 'pending',
|
||||
services: { gitea: { slot: 'self', scopes: ['read:repository', 'read:user'], resources: { projects: ['stack'] } } },
|
||||
decision: '11111111-1111-4111-8111-111111111111' } };
|
||||
};
|
||||
const staged = (store) => {
|
||||
mkdirSync(join(store('rev', 'gitea'), '..'), { recursive: true, mode: 0o700 });
|
||||
chmodSync(join(store('rev', 'gitea'), '..', '..'), 0o700);
|
||||
for (const name of ['gitea', 'gitea.tmp']) writeFileSync(store('rev', name), TOKEN + '\n', { mode: 0o600 });
|
||||
mkdirSync(join(store('coder', 'gitea'), '..'), { recursive: true, mode: 0o700 });
|
||||
writeFileSync(store('coder', 'gitea'), TOKEN + '\n', { mode: 0o600 });
|
||||
};
|
||||
const c = await setup(t, { edit: pending, before: staged });
|
||||
assert.equal(c.file().copyBindings.rev.state, 'pending');
|
||||
assert.deepEqual(readdirSync(join(c.store('rev', 'gitea'), '..')), []);
|
||||
// A role's own store slot is the store's, not the sweep's.
|
||||
assert.equal(existsSync(c.store('coder', 'gitea')), true);
|
||||
});
|
||||
@@ -38,13 +38,14 @@ export async function fakeGiteaApi(t, dir, { version = '1.27.1', admin = 'mosaic
|
||||
issued: [],
|
||||
// A user and, with `scopes`, one token named `tokenName`. Returns the user and that token or null.
|
||||
addUser(login, { is_admin = false, prohibit_login = false, full_name = login, email = `${login}@example.test`, scopes = null, tokenName = 'test' } = {}) {
|
||||
const u = { id: nextId++, login, email, full_name, is_admin, prohibit_login, tokens: [] };
|
||||
const u = { id: nextId++, login, email, full_name, is_admin, prohibit_login, tokens: [], orgs: [] };
|
||||
users.set(lower(login), u);
|
||||
return { user: u, token: scopes ? api.addToken(login, tokenName, scopes) : null };
|
||||
},
|
||||
addToken(login, name, scopes) {
|
||||
// #1873 CB2: `expires` (an ISO instant) is reported as the token's expires_at.
|
||||
addToken(login, name, scopes, { expires = null } = {}) {
|
||||
const u = users.get(lower(login));
|
||||
const tok = { id: nextId++, name, sha1: fakeToken(), scopes: [...scopes].sort() };
|
||||
const tok = { id: nextId++, name, sha1: fakeToken(), scopes: [...scopes].sort(), expires };
|
||||
u.tokens.push(tok);
|
||||
api.issued.push(tok.sha1);
|
||||
return tok;
|
||||
@@ -96,7 +97,10 @@ export async function fakeGiteaApi(t, dir, { version = '1.27.1', admin = 'mosaic
|
||||
if (!who) return [401, { message: 'unauthorized' }];
|
||||
const me = who.user;
|
||||
if (method === 'GET' && path === '/api/v1/user') return [200, userJson(me)];
|
||||
if (method === 'GET' && path === '/api/v1/token') return [200, { id: who.token.id, name: who.token.name, scopes: who.token.scopes }];
|
||||
// Gitea 1.27 names the token's owner; #1873 CB2's verifier needs it.
|
||||
if (method === 'GET' && path === '/api/v1/token')
|
||||
return [200, { id: who.token.id, name: who.token.name, scopes: who.token.scopes, user: { id: me.id, login: me.login }, ...(who.token.expires ? { expires_at: who.token.expires } : {}) }];
|
||||
if (method === 'GET' && path === '/api/v1/user/orgs') return [200, (me.orgs ?? []).map((name, i) => ({ id: 1000 + i, username: name })), (me.orgs ?? []).length];
|
||||
if (method === 'GET' && path === '/api/v1/user/repos') {
|
||||
const all = reachable(me);
|
||||
const limit = Number(query.get('limit') ?? 30);
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
// keys are real local ssh-keygen pairs in the scratch store, and no ssh connection is made. Decisions a
|
||||
// test needs outside the human's yes go in by SQL as fixtures.
|
||||
import test from 'node:test';
|
||||
import {autonomyHost} from '../src/autonomy-host.mjs';
|
||||
import { hireServiceFixture, fixtureConsole, approveFixtureFire } from './hire-fixtures.mjs';
|
||||
import assert from 'node:assert/strict';
|
||||
import { createHash } from 'node:crypto';
|
||||
@@ -75,7 +76,7 @@ function makeBusiness(url) {
|
||||
};
|
||||
}
|
||||
|
||||
async function setup(t) {
|
||||
async function setup(t,autonomy=null) {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'g281-'));
|
||||
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
||||
const gitea = await fakeGiteaApi(t, join(dir, 'tls'));
|
||||
@@ -157,7 +158,7 @@ async function setup(t) {
|
||||
return { revision: REV };
|
||||
};
|
||||
const make = () =>
|
||||
new Broker({hires:hireServiceFixture,
|
||||
new Broker({hires:hireServiceFixture,autonomy,
|
||||
store,
|
||||
businesses: { demo: structuredClone(doc) },
|
||||
secretCheck: (value) => credentials.assertClean(value),
|
||||
@@ -997,3 +998,24 @@ test("row 239 T4b: an unmarked step B refusal keeps its code, and the notice say
|
||||
assert.ok(message.includes('(revision-conflict). The instance is still firing.'), message);
|
||||
assert.ok(!message.includes('may already be revoked'), message);
|
||||
});
|
||||
|
||||
for(const expired of [false,true])test(`AU hire inverse waits for grant removal and refuses late publication (expired: ${expired})`,async t=>{
|
||||
const host=autonomyHost({hires:{reverse:async(id,decision,caller)=>caller.fire({id:decision,level:'org',name:'coder',revision:REV,digest:'a'.repeat(64)})},reload:()=>true});
|
||||
const f=await setup(t,host);await f.approveHost('coder','web1');
|
||||
const decision=grantDecision(f.store,{action:'role.hire',route:'pm',close:{op:'resolved',by:'pm-run',choice:'yes',via:'broker'}});
|
||||
const at=new Date().toISOString(),deadline=new Date(Date.now()+3600000).toISOString();
|
||||
f.store.run("INSERT INTO events(id,at,business,kind,subject,body) VALUES(?,?,?,'action.allowed',?,?)",decision,at,'demo',decision,JSON.stringify({operation:'autonomy.notice',decision,decisionClass:'authority',reverseUntil:deadline}));
|
||||
f.held.add('web1:remove');
|
||||
const owner=fixtureConsole(f.broker),pending=f.broker.request(owner,{verb:'decision.reverse.console',args:{id:decision}});
|
||||
// Attach the rejection handler before letting the asynchronous revoke complete.
|
||||
const outcome=pending.then(value=>({value}),error=>({error}));
|
||||
await until(()=>f.sent.some(s=>s.startsWith('remove web1')));
|
||||
assert.equal(f.store.get("SELECT count(*) n FROM events WHERE json_extract(body,'$.operation')='autonomy.reverse'").n,0);assert.ok(f.doc.launch.instances.includes('coder'));
|
||||
const clock=Date.now;
|
||||
try {
|
||||
if(expired)Date.now=()=>Date.parse(deadline)+1;
|
||||
f.held.delete('web1:remove');const result=await outcome;await f.broker.grantsIdle();
|
||||
if(expired){assert.equal(result.error?.code,'reverse-window-closed');assert.ok(f.doc.launch.instances.includes('coder'));}
|
||||
else{assert.equal(result.error,undefined);assert.equal(result.value.committed,true);assert.match(result.value.transaction,/^t-/);assert.equal(f.store.get("SELECT count(*) n FROM events WHERE json_extract(body,'$.operation')='autonomy.reverse'").n,1);assert.ok(!f.doc.launch.instances.includes('coder'));}
|
||||
}finally{Date.now=clock;f.held.clear();}
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
// D3 (P0-4, readiness-1111): an organization on the fixture model provider, with no model proxy
|
||||
// configured, changes routes and thinking, takes limits.set and starts a hire. The real catalogue
|
||||
// service runs here (no modelCatalogue stand-in), so a fixture-only organization reads ready
|
||||
// without a proxy client. A thinking-only change saves while the catalogue is unavailable, a
|
||||
// change that needs the proxy still refuses, and removing cliproxy (the Routing page's Disable)
|
||||
// makes the next save apply.
|
||||
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 { busOrganization } from '../src/organization-context.mjs';
|
||||
import { startBroker } from '../src/runtime.mjs';
|
||||
import { createModelCatalogue } from '../src/model-catalogue.mjs';
|
||||
import { loadBusiness } from '../../organization/src/organization-config.mjs';
|
||||
import { resolveInstance } from '../../organization/src/resolve.mjs';
|
||||
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../organization/tests/helpers.mjs';
|
||||
import { FIXTURE_ENTRY, FIXTURE_KEY_NAME } from '../../plugins/model-provider/fixture/index.mjs';
|
||||
import { approveTemplateHire } from './hire-fixtures.mjs';
|
||||
|
||||
function busForm(root, rolesDir, id = 'demo') {
|
||||
const b = loadBusiness(id, { dir: root, rolesDir });
|
||||
const resolved = {};
|
||||
for (const instance of Object.keys(b.roles)) resolved[instance] = resolveInstance({ system: systemFor(root), business: b, instance });
|
||||
return busOrganization(b, resolved);
|
||||
}
|
||||
|
||||
async function setup(t, providers = ['fixture'], edit = () => {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'd3-fixture-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const rolesDir = rolesCopy(root);
|
||||
const doc = businessDoc(root, 'demo');
|
||||
doc.plugins = { harness: ['pi'], 'model-provider': providers };
|
||||
doc.launch.models[FIXTURE_KEY_NAME] = { ...FIXTURE_ENTRY };
|
||||
doc.roles.coder.vars = { harness: 'pi', model: FIXTURE_KEY_NAME, thinking: 'off', 'limits.network': 'none' };
|
||||
// The audit's instance: the assistant on the fixture route.
|
||||
doc.roles.assistant = { definition: 'assistant', vars: { harness: 'pi', model: FIXTURE_KEY_NAME, thinking: 'off' } };
|
||||
edit(doc);
|
||||
const file = writeJson(join(root, 'businesses/demo.json'), doc);
|
||||
mkdirSync(join(root, 'data'), { mode: 0o700 });
|
||||
// No proxy option: the runtime has no model proxy client and no proxy lock.
|
||||
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: busForm(root, rolesDir) }, organization: { dir: root, rolesDir, system: systemFor(root) } });
|
||||
t.after(() => bus.close());
|
||||
const human = bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
|
||||
const ask = (verb, args) => Promise.resolve().then(() => bus.broker.request(human, { verb, args }));
|
||||
const saved = () => JSON.parse(readFileSync(file, 'utf8'));
|
||||
return { root, rolesDir, file, bus, ask, saved };
|
||||
}
|
||||
const route = (thinking, model = FIXTURE_KEY_NAME) => ({ harness: 'pi', model, thinking, fallback: null });
|
||||
// readiness-1111 evidence patch-low.json: `mosaic limits set --file patch-low.json`.
|
||||
const patch = (revision, thinking) => ({ revision, assistants: { assistant: { definition: 'assistant', vars: { harness: 'pi', model: FIXTURE_KEY_NAME, thinking }, tracker: null } } });
|
||||
|
||||
test('D3 a fixture-only organization with no model proxy reads a ready catalogue and saves a thinking change and a fixture route', async t => {
|
||||
const f = await setup(t);
|
||||
const view = await f.ask('routes');
|
||||
assert.equal(view.catalogue.status, 'ready');
|
||||
assert.deepEqual(view.catalogue.models.map(m => m.key), [FIXTURE_KEY_NAME]);
|
||||
const thinking = await f.ask('routes.set', { revision: view.revision, routes: { coder: route('low') } });
|
||||
assert.ok(thinking.transaction);
|
||||
assert.equal(f.saved().roles.coder.vars.thinking, 'low');
|
||||
// A fixture route change: the reviewer moves onto the fixture model.
|
||||
const moved = await f.ask('routes.set', { revision: thinking.revision, routes: { reviewer: route('off') } });
|
||||
assert.ok(moved.transaction);
|
||||
assert.equal(f.saved().roles.reviewer.vars.model, FIXTURE_KEY_NAME);
|
||||
});
|
||||
|
||||
test('D3 limits.set takes the audit thinking patch on a fixture-only organization with no model proxy', async t => {
|
||||
const f = await setup(t);
|
||||
const { revision } = await f.ask('limits');
|
||||
const result = await f.ask('limits.set', patch(revision, 'low'));
|
||||
assert.ok(result.transaction);
|
||||
assert.equal(f.saved().roles.assistant.vars.thinking, 'low');
|
||||
});
|
||||
|
||||
test('D3 hire start on a fixture-only organization with no model proxy writes the hire', async t => {
|
||||
const f = await setup(t);
|
||||
const hired = await approveTemplateHire(f.bus.broker, f.rolesDir, { name: 'sec-1', definition: 'chief-information-security-officer', model: FIXTURE_KEY_NAME });
|
||||
assert.ok(hired.transaction);
|
||||
assert.equal(f.saved().roles['sec-1'].vars.model, FIXTURE_KEY_NAME);
|
||||
});
|
||||
|
||||
test('D3 with cliproxy enabled and no model proxy, a thinking change saves, a proxy model refuses, and removing cliproxy lets a route change save', async t => {
|
||||
const f = await setup(t, ['fixture', 'cliproxy']);
|
||||
let view = await f.ask('routes');
|
||||
assert.equal(view.catalogue.status, 'unavailable');
|
||||
// Thinking only: same harness, model key and entry, so no catalogue is asked.
|
||||
const thinking = await f.ask('routes.set', { revision: view.revision, routes: { coder: route('low') } });
|
||||
assert.equal(f.saved().roles.coder.vars.thinking, 'low');
|
||||
const limits = await f.ask('limits');
|
||||
await f.ask('limits.set', patch(limits.revision, 'medium'));
|
||||
assert.equal(f.saved().roles.assistant.vars.thinking, 'medium');
|
||||
// A route change truly needs the catalogue, which the absent proxy makes unavailable.
|
||||
const before = readFileSync(f.file, 'utf8');
|
||||
view = await f.ask('routes');
|
||||
await assert.rejects(f.ask('routes.set', { revision: view.revision, routes: { reviewer: route('off') } }), { code: 'model-catalogue-unavailable' });
|
||||
await assert.rejects(f.ask('routes.set', { revision: view.revision, routes: { reviewer: route('off', 'zai-not-offered') } }), { code: 'model-catalogue-unavailable' });
|
||||
assert.equal(readFileSync(f.file, 'utf8'), before);
|
||||
assert.ok(thinking.transaction);
|
||||
// Disable model proxy catalogue: plugins.set without cliproxy.
|
||||
const settings = await f.ask('plugins');
|
||||
await f.ask('plugins.set', { revision: settings.revision, plugins: { 'model-provider': ['fixture'] } });
|
||||
view = await f.ask('routes');
|
||||
assert.equal(view.catalogue.status, 'ready');
|
||||
await f.ask('routes.set', { revision: view.revision, routes: { reviewer: route('off') } });
|
||||
assert.equal(f.saved().roles.reviewer.vars.model, FIXTURE_KEY_NAME);
|
||||
});
|
||||
|
||||
test('D3 the catalogue asks a module that needs no proxy even with no proxy client, and a module that asks the proxy reads unavailable', async () => {
|
||||
let names = ['fixture'];
|
||||
const service = createModelCatalogue({ client: () => null, settings: () => ({ 'model-provider': names }) });
|
||||
const ready = await service.read('demo', { fresh: true });
|
||||
assert.equal(ready.status, 'ready');
|
||||
assert.deepEqual(ready.models.map(m => [m.key, m.available]), [[FIXTURE_KEY_NAME, true]]);
|
||||
names = ['cliproxy'];
|
||||
assert.equal((await service.read('demo', { fresh: true })).status, 'unavailable');
|
||||
names = ['fixture', 'cliproxy'];
|
||||
const both = await service.read('demo', { fresh: true });
|
||||
assert.equal(both.status, 'unavailable');
|
||||
assert.ok(both.models.every(m => m.available === false));
|
||||
});
|
||||
|
||||
// D3 review (finding 1): Disable can't make a move between two model-proxy models apply, which is
|
||||
// why the Routing page names Disable only for a move onto a model from another listed module.
|
||||
test('D3 a move between model-proxy models refuses before and after cliproxy is removed, and writes nothing', async t => {
|
||||
const proxyEntry = model => ({ provider: 'proxy', model, module: 'cliproxy', upstream: 'claude' });
|
||||
const f = await setup(t, ['fixture', 'cliproxy'], doc => {
|
||||
doc.launch.models['claude-a'] = proxyEntry('claude-a');
|
||||
doc.launch.models['claude-b'] = proxyEntry('claude-b');
|
||||
doc.roles.reviewer.vars = { ...doc.roles.reviewer.vars, harness: 'pi', model: 'claude-a', thinking: 'off' };
|
||||
});
|
||||
const before = readFileSync(f.file, 'utf8');
|
||||
let view = await f.ask('routes');
|
||||
await assert.rejects(f.ask('routes.set', { revision: view.revision, routes: { reviewer: route('off', 'claude-b') } }), { code: 'model-catalogue-unavailable' });
|
||||
assert.equal(readFileSync(f.file, 'utf8'), before);
|
||||
const settings = await f.ask('plugins');
|
||||
await f.ask('plugins.set', { revision: settings.revision, plugins: { 'model-provider': ['fixture'] } });
|
||||
const disabled = readFileSync(f.file, 'utf8');
|
||||
view = await f.ask('routes');
|
||||
assert.equal(view.catalogue.status, 'ready');
|
||||
await assert.rejects(f.ask('routes.set', { revision: view.revision, routes: { reviewer: route('off', 'claude-b') } }), { code: 'model-provider-unavailable' });
|
||||
assert.equal(readFileSync(f.file, 'utf8'), disabled);
|
||||
assert.notEqual(disabled, before);
|
||||
});
|
||||
@@ -66,7 +66,7 @@ test('REQ-DEC-2: a gated raise citing the current spec goes to the arbiter for i
|
||||
const raise = { action, ...(action === 'role.revoke' ? { target: 'reviewer' } : {}) };
|
||||
assert.deepEqual(routed(f, f.raise(raise)), { class: 'gated', route_to: 'human' }, action);
|
||||
assert.deepEqual(routed(f, await f.raise({ ...raise, ...cite(f), domain: 'technical' })), { class: 'gated', route_to: action === 'policy.change' ? 'chief' : 'cto' }, action);
|
||||
assert.deepEqual(routed(f, await f.raise({ ...raise, ...cite(f) })), { class: 'gated', route_to: action === 'git.merge.protected' ? 'cto' : 'chief' }, action);
|
||||
assert.deepEqual(routed(f, await f.raise({ ...raise, ...cite(f) })), { class: 'gated', route_to: ['git.merge.protected','prd.approve'].includes(action) ? 'cto' : 'chief' }, action);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -205,3 +205,36 @@ test('A4 inbox and decision reads expire unopened hires once without changing co
|
||||
} finally {Date.now=original;}
|
||||
assert.deepEqual(readFileSync(f.file),before);assert.equal(f.writes(),0);
|
||||
});
|
||||
test('AU a distinct authority resolver applies a plain near-loop hire, while flagged and credential hires stay human',async t=>{
|
||||
const f=fixture(t),doc=JSON.parse(readFileSync(f.file,'utf8'));
|
||||
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:'near the loop',authorityResolver:'cto',notices:true};writeJson(f.file,doc);
|
||||
const live=loadBusiness('demo',{dir:f.root,rolesDir:f.rolesDir});
|
||||
const business={...live,roles:Object.fromEntries(Object.entries(live.roles).map(([n,e])=>[n,{...live.definitions[e.definition],...e}]))};
|
||||
business.roles.cto.authority={withinRole:['decision.resolve.authority'],crossRole:[]};f.b.reloadBusiness('demo',business);
|
||||
const resolver=f.b.bindLaunch({business:'demo',role:'cto',run:'cto-run',harness:'pi'});f.call(resolver,'role.claim');
|
||||
const d=await f.raise();assert.equal(d.route_to,'cto');
|
||||
await assert.rejects(Promise.resolve().then(()=>f.call(f.caps.chief,'decision.resolve',{id:d.id,choice:'yes'})),{code:'not-resolver'});
|
||||
await f.call(resolver,'decision.resolve',{id:d.id,choice:'yes'});
|
||||
assert.equal(f.writes(),1);assert.equal(JSON.parse(readFileSync(f.file)).roles.scout.hireDecision,d.id);
|
||||
for(const change of [{name:'flagged',role:{inline:{...INLINE,tools:['bash']}}},{name:'credential',role:{inline:{...INLINE,credentials:[{service:'gitea',scopes:['read:repository']}]}}}]) {
|
||||
const decision=await f.raise(change);assert.equal(decision.route_to,'human');
|
||||
}
|
||||
});
|
||||
|
||||
test('AU R5 a change naming the authority resolver stays with a person; no agent resolves itself',async t=>{
|
||||
const f=fixture(t),doc=JSON.parse(readFileSync(f.file,'utf8'));
|
||||
doc.definitions={judge:{rev:1,role:{tools:['read'],network:'none',authority:{withinRole:['decision.resolve.authority'],crossRole:[]},credentials:[]},instructions:'Resolve independent authority decisions.'}};
|
||||
doc.roles.judge={definition:'org:judge',vars:{harness:'pi',model:'claude-opus-5-5'}};
|
||||
doc.plugins['limit-check']=['token-limit'];doc.launch.limits={tokens:{providers:{zai:{limit:100000,since:'2026-01-01T00:00:00.000Z'}}}};
|
||||
doc.autonomy={authority:'near the loop',authorityResolver:'judge',notices:true};writeJson(f.file,doc);
|
||||
const loaded=loadBusiness('demo',{dir:f.root,rolesDir:f.rolesDir});
|
||||
f.b.reloadBusiness('demo',{...loaded,roles:Object.fromEntries(Object.entries(loaded.roles).map(([n,e])=>[n,{...loaded.definitions[e.definition],...e}]))});
|
||||
const judge=f.b.bindLaunch({business:'demo',role:'judge',run:'judge-run',harness:'pi'});f.call(judge,'role.claim');
|
||||
const d=await f.raise({op:'change',name:'judge',role:{inline:{...INLINE,authority:{withinRole:['decision.resolve.authority'],crossRole:[]},instructions:'Narrow this resolver'}}});
|
||||
assert.ok(d.hire.flagged.some(x=>x.value==='decision.resolve.authority'));assert.equal(d.route_to,'human');
|
||||
await assert.rejects(Promise.resolve().then(()=>f.call(judge,'decision.resolve',{id:d.id,choice:'yes'})));
|
||||
assert.equal(f.writes(),0);
|
||||
});
|
||||
@@ -12,15 +12,15 @@ export const HUMAN_YES = { op: 'resolved', by: 'jason', choice: 'yes', via: 'cli
|
||||
|
||||
// A credential.mint decision with `grant` in its raise body. `close` is the decision_events row, or null
|
||||
// for an open decision.
|
||||
export function grantDecision(store, { business = 'demo', action = 'credential.mint', route = 'human', domain = 'delivery', grant, close = HUMAN_YES }) {
|
||||
export function grantDecision(store, { business = 'demo', raiser = 'chief', action = 'credential.mint', route = 'human', domain = 'delivery', grant, close = HUMAN_YES }) {
|
||||
const id = randomUUID();
|
||||
store.run(
|
||||
'INSERT INTO decisions(id,at,business,raised_by_role,raised_by_run,class,action,route_to,question,options,recommendation,blocking) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)',
|
||||
id, AT, business, 'chief', 'chief-run', 'gated', action, route, 'Grant?', OPTIONS, 'yes', 0,
|
||||
id, AT, business, raiser, raiser+'-run', 'gated', action, route, 'Grant?', OPTIONS, 'yes', 0,
|
||||
);
|
||||
store.run(
|
||||
"INSERT INTO events(id,at,business,kind,actor_role,actor_run,subject,body) VALUES(?,?,?,'action.allowed',?,?,?,?)",
|
||||
randomUUID(), AT, business, 'chief', 'chief-run', id,
|
||||
randomUUID(), AT, business, raiser, raiser+'-run', id,
|
||||
JSON.stringify({ decision: id, action, operation: 'decision.raise', ...(domain === null ? {} : { domain }), approvalChoice: 'yes', ...(grant ? { grant } : {}) }),
|
||||
);
|
||||
if (close)
|
||||
|
||||
@@ -346,3 +346,10 @@ for(const level of ['root','project'])test(`A4 ${level} fire removes its implici
|
||||
const human=b.bus.broker.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});
|
||||
assert.throws(()=>b.bus.broker.request(human,{verb:'instance.launch',args:{instance}}));
|
||||
});
|
||||
|
||||
test('AU resolver guard runs inside the model revocation queue before any remote or local effect',async t=>{
|
||||
const f=await fixture(t);await f.manager.reconcile();await f.manager.ensure('demo/coder');
|
||||
const before=f.credentials.modelRecord('demo/coder'),calls=f.state.calls.length;
|
||||
await assert.rejects(f.manager.prepareRevoke('demo/coder',()=>{throw Object.assign(new Error('not-resolver'),{code:'not-resolver'});}),{code:'not-resolver'});
|
||||
assert.deepEqual(f.credentials.modelRecord('demo/coder'),before);assert.equal(f.state.calls.length,calls);
|
||||
});
|
||||
@@ -12,6 +12,8 @@ import { fixture } from './spec-fixtures.mjs';
|
||||
test('Broker keeps the sandbox client import closure: only the listed imports, no lazy project import', () => {
|
||||
const source = readFileSync(new URL('../src/broker.mjs', import.meta.url), 'utf8');
|
||||
assert.deepEqual([...source.matchAll(/^import .*;$/gm)].map(m => m[0]), [
|
||||
"import { autonomyEvent, decisionResolution } from './autonomy-state.mjs';",
|
||||
"import { autonomyClass, authorityBudgetCovered, checkAutonomy, decisionAutonomy, resolverSubject } from '../../organization/src/autonomy.mjs';",
|
||||
// Rows330/331: pure verb/refusal vocabulary, never the host management implementation.
|
||||
"import { ownRails } from '../../organization/src/rails.mjs';",
|
||||
"import { refusalGuidance } from './refusals.mjs';",
|
||||
@@ -45,7 +47,8 @@ test('Broker keeps the sandbox client import closure: only the listed imports, n
|
||||
]);
|
||||
assert.doesNotMatch(source, /\bimport\s*\(/);
|
||||
assert.doesNotMatch(readFileSync(new URL('../src/proxy-page-vocabulary.mjs', import.meta.url), 'utf8'), /\bimport\b/);
|
||||
assert.doesNotMatch(readFileSync(new URL('../src/landing.mjs', import.meta.url), 'utf8'), /\bimport\b/);
|
||||
assert.doesNotMatch(readFileSync(new URL('../src/autonomy-state.mjs', import.meta.url), 'utf8'), /\bimport\b/);
|
||||
assert.doesNotMatch(readFileSync(new URL('../../organization/src/autonomy.mjs', import.meta.url), 'utf8'), /\bimport\b/);
|
||||
assert.doesNotMatch(readFileSync(new URL('../../organization/src/vocabulary.mjs', import.meta.url), 'utf8'), /\bimport\b/);
|
||||
assert.doesNotMatch(readFileSync(new URL('../../plugins/src/kinds.mjs', import.meta.url), 'utf8'), /\bimport\b/);
|
||||
});
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
});
|
||||
@@ -90,7 +90,7 @@ const ask = (x, state, extra = {}) => x.call('pm', 'request.question', { request
|
||||
options: [{ key: 'web', text: 'Web' }, { key: 'cli', text: 'CLI' }], ...extra });
|
||||
|
||||
test('the console verb list includes the artifact read and existing owner controls', () => {
|
||||
assert.deepEqual([...CONSOLE_VERBS], ['system-types.console', 'project.remote.get.console', 'project.remote.set.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'hire.preview.console', 'hire.resolve.console', 'hire.start.console', 'hire.templates.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
|
||||
assert.deepEqual([...CONSOLE_VERBS], ['system-types.console', 'project.remote.get.console', 'project.remote.set.console', 'autonomy.get.console', 'autonomy.set.console', 'autonomy.review.console', 'decision.confirm.console', 'decision.reverse.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'hire.preview.console', 'hire.resolve.console', 'hire.start.console', 'hire.templates.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
|
||||
'request.get.console', 'request.projects.console', 'request.answer.console', 'request.attach.console', 'request.cancel.console']);
|
||||
});
|
||||
|
||||
|
||||
@@ -96,6 +96,9 @@ test('row 65 runtime: harnessLevels answers each module in the tree, and null fo
|
||||
assert.deepEqual(levels('pi'), ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']);
|
||||
assert.deepEqual(levels('claude-code'), ['low', 'medium', 'high', 'xhigh', 'max']);
|
||||
for (const name of ['cursor', '', '../pi']) assert.equal(levels(name), null, name);
|
||||
// AU (#1873): each module's own countsAccount answer, read once with its levels.
|
||||
assert.deepEqual([levels.countsAccount('pi'), levels.countsAccount('claude-code')], [false, true]);
|
||||
for (const name of ['cursor', '', '../pi']) assert.equal(levels.countsAccount(name), null, name);
|
||||
assert.deepEqual(logged, []);
|
||||
});
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Broker } from '../src/broker.mjs';
|
||||
import { initProject, readSpec, writeRecord, listProjectFiles, readProjectFile, writeHumanFile, ProjectError } from '../../project/src/index.mjs';
|
||||
export const git = (repo, ...args) => execFileSync('git', ['-c', 'core.hooksPath=/dev/null', '-c', 'maintenance.auto=false', '-c', 'gc.auto=0', '-C', repo, ...args], { encoding: 'utf8' }).trim();
|
||||
const options = [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }];
|
||||
export async function fixture(t, { adapter = true, roles = {}, arbiters = { technical: 'chief', delivery: 'chief' }, access = null } = {}) {
|
||||
export async function fixture(t, { adapter = true, roles = {}, arbiters = { technical: 'chief', delivery: 'chief' }, access = null, autonomyHost = null } = {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'bus-spec-'));
|
||||
const store = new Store(root);
|
||||
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
|
||||
@@ -20,7 +20,7 @@ export async function fixture(t, { adapter = true, roles = {}, arbiters = { tech
|
||||
...roles,
|
||||
} };
|
||||
const { repo, commit } = await initProject({ business, project: 'stack', dataRoot: root });
|
||||
const broker = new Broker({ store, businesses: { demo: business }, access, project: adapter ? {
|
||||
const broker = new Broker({ store, businesses: { demo: business }, access, autonomy: autonomyHost, project: adapter ? {
|
||||
readSpec: context => readSpec({ ...context, dataRoot: root }),
|
||||
writeRecord: context => writeRecord({ ...context, dataRoot: root }),
|
||||
listFiles: context => listProjectFiles({ ...context, dataRoot: root }),
|
||||
|
||||
@@ -546,3 +546,18 @@ test('review: saturated and adjacent positions refuse without a write', async (t
|
||||
assert.equal(syncCode(() => f.person('tasks.edit', {task_ref:'task:1', group:'adjacent', position:10})), 'task-position-full');
|
||||
assert.equal(f.store.get('SELECT count(*) n FROM store_tasks').n, before + 2);
|
||||
});
|
||||
|
||||
test('AU near cross-role task scope restores prior fields and refuses overwriting a later edit',async t=>{
|
||||
const f=await org(t);f.broker.reloadBusiness('acme',business('acme',{autonomy:{'cross-role':'near the loop',notices:true}}));
|
||||
const owner=f.broker.bindConsole({business:'acme',human:'jason',subject:{provider:'test',iss:'https://issuer.test',sub:'owner'}});
|
||||
const made=f.person('tasks.create',{title:'Before',requirement:'REQ-TASK-1'});
|
||||
const d=f.approve('coder','task.scope.change',made.task_ref);
|
||||
await f.call('coder','task.scope.change',{task_ref:made.task_ref,title:'After',decision:d});
|
||||
const reversed=await f.broker.request(owner,{verb:'decision.reverse.console',args:{id:d}});
|
||||
assert.equal(reversed.reversed,true);assert.equal(f.read()[0].fields.title,'Before');
|
||||
const later=f.approve('coder','task.scope.change',made.task_ref);
|
||||
await f.call('coder','task.scope.change',{task_ref:made.task_ref,title:'Second',decision:later});
|
||||
f.person('tasks.edit',{task_ref:made.task_ref,title:'Owner edit'});
|
||||
await assert.rejects(f.broker.request(owner,{verb:'decision.reverse.console',args:{id:later}}),{code:'decision-stale'});
|
||||
assert.equal(f.read()[0].fields.title,'Owner edit');
|
||||
});
|
||||
@@ -0,0 +1,2 @@
|
||||
// Compatibility path for organization autonomy policy.
|
||||
export * from "../../organization/src/autonomy.mjs";
|
||||
@@ -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";
|
||||
@@ -0,0 +1,2 @@
|
||||
// Old path of packages/organization/src/repo-remote.mjs (rename design r2, stage B).
|
||||
export * from "../../organization/src/repo-remote.mjs";
|
||||
@@ -0,0 +1,2 @@
|
||||
// Old path of packages/organization/src/system-config-problem.mjs (rename design r2, stage B).
|
||||
export * from "../../organization/src/system-config-problem.mjs";
|
||||
+67
-2
@@ -28,6 +28,9 @@ mosaic inbox [--business <id>] [--json]
|
||||
mosaic inbox --read <id>... | --read-all [--business <id>]
|
||||
mosaic decide <decision> <option> [--note <text>] [--yes] [--business <id>]
|
||||
mosaic tasks [--business <id>] [--json]
|
||||
mosaic tasks create --title <text> --requirement <REQ-X-N> [--project <name>] [--state <state>] [--assignees <role,...>] [--priority 0-5] [--due <iso>|none] --business <id> [--json]
|
||||
mosaic tasks edit task:<n> --expect <digest> [--title <text>] [--state <state>] [--assignees <role,...>] [--priority 0-5] [--due <iso>|none] --business <id> [--json]
|
||||
mosaic tasks archive task:<n> --expect <digest> --business <id> [--json]
|
||||
mosaic agents [--business <id>] [--json] [--tester]
|
||||
mosaic trail <task|decision> [--business <id>] [--json]
|
||||
mosaic talk <role> <text> [--wait <seconds> | --follow] [--business <id>]
|
||||
@@ -298,9 +301,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`,
|
||||
@@ -1269,8 +1279,15 @@ its checkout; `mosaic org project repo init ID PROJECT --local-only
|
||||
URL` selects remote mode instead. These commands never push or overwrite an
|
||||
existing repository. Local-only acceptance records the risk of losing the only
|
||||
copy. Read back with `mosaic org queue show ID [--json]` or `mosaic org project
|
||||
repo show ID PROJECT [--json]`. See `packages/organization/README.md` for the
|
||||
layout and scope. On existing CLI commands, `--org ID` is an alias for `--business ID`;
|
||||
repo show ID PROJECT [--json]`. `mosaic org project repo set ID PROJECT
|
||||
(--local-only --accept-local-loss-risk | --remote URL) --if-revision N [--json]`
|
||||
changes a project's repository later; it is refused with `project-repo-bound`
|
||||
(exit 2) while an approved or active copy binding names the project. A remote is https or ssh; its checkout's
|
||||
`origin` is set and checked once with `git ls-remote`, without credentials, and
|
||||
the receipt says `reachable`, `needs-credential` or `unreachable`. An ssh check
|
||||
does not verify the host key and says so. A copy
|
||||
credential binding supplies credentials for https Gitea remotes only. See
|
||||
`packages/organization/README.md` for the URL rules, layout and scope. On existing CLI commands, `--org ID` is an alias for `--business ID`;
|
||||
if both are supplied they must name the same ID.
|
||||
|
||||
`mosaic org delete ID --confirm ID --if-revision N [--json]` stops the
|
||||
@@ -1290,6 +1307,54 @@ 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.
|
||||
|
||||
`mosaic org restore ID --if-revision N` makes the organization active and starts
|
||||
its bus, as `mosaic bus start ID --dev-local-owner --detach` does (E1, P1-4). It
|
||||
makes that verb's checks before the restore, so a stamped build or an instance
|
||||
that refuses `--dev-local-owner` refuses the restore before anything changes
|
||||
(exit 3). Starting a bus is a human action, so inside an agent run `org restore`
|
||||
refuses like `bus start` (exit 3) before any config is read. The receipt's `bus`
|
||||
names the outcome: `started` with the host pid, or `failed` with the message and
|
||||
the verb that starts it (after `mosaic bus recover ID` when a host that died left a
|
||||
stale lock). A bus that does not start leaves the restore in place;
|
||||
the command prints the receipt and exits 3. `org restore ID --if-revision N
|
||||
--no-bus` restores without starting the bus, as before, and stays open to agents.
|
||||
|
||||
The hire form and the autonomy panel from a terminal (E2, #1873, P1-3):
|
||||
|
||||
```
|
||||
mosaic org role-templates ORG [--json]
|
||||
mosaic org hire|change ORG --name NAME --template TEMPLATE --harness H --model M [--thinking T] [--level org|project|root] [--project P] --reason TEXT [--json]
|
||||
mosaic org fire ORG --name NAME [--level org|project|root] [--project P] --reason TEXT [--json]
|
||||
mosaic org autonomy ORG [--json]
|
||||
mosaic org autonomy ORG set --revision REV [--cross-role|--merge|--policy|--authority in|near|out] [--notices on|off] [--reverse-window-hours 1-168] [--authority-resolver NAME|""] [--json]
|
||||
mosaic org autonomy ORG review cross-role|merge|policy|authority [--json]
|
||||
```
|
||||
|
||||
Each sends the page's own request to the running Console's org route, as
|
||||
`org reload` does, so the Console runs the same owner, `--send`, origin, body
|
||||
and session checks and the same bus verbs. `hire` and `change` read the role
|
||||
templates first and send the chosen template's name and sha256. `autonomy set`
|
||||
needs the 64-hex `--revision` that `mosaic org autonomy ORG` prints and sends every
|
||||
setting the panel sends, changing only the named ones; a stale revision is
|
||||
refused `revision-conflict`. `--authority-resolver ""` clears the resolver, as
|
||||
emptying the panel's field does, and `--reverse-window-hours` takes 1 to 168.
|
||||
These verbs need a named instance whose Console runs with `--dev-local-owner`;
|
||||
the default instance is refused with exit 3. A Console refusal exits 3 with its code and
|
||||
message, an answer that never came exits 1, an unknown template exits 2. All of
|
||||
these verbs, reads included, refuse inside an agent run (exit 3) after their
|
||||
usage checks (exit 4) and before any config is read.
|
||||
|
||||
`mosaic tasks create|edit|archive` are the task board's three writes. The
|
||||
arguments pass the board's own check, then go to the bus as the person
|
||||
(`tasks.create`, `tasks.edit`, `tasks.archive`), so the bus runs its human,
|
||||
field, digest and archive checks. Edit and archive need the task's digest from
|
||||
`mosaic tasks --json` as `--expect`; a stale one is refused `task-conflict`
|
||||
(exit 2) and nothing is written. They refuse inside an agent run.
|
||||
|
||||
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
|
||||
|
||||
@@ -55,6 +55,7 @@ import { agentsRootCommand, AGENTS_ROOT_USAGE, projectAgentsCommand, PROJECT_AGE
|
||||
import { ORG_CONFIG, runConfigCommand } from "../../organization/src/config-command.mjs";
|
||||
import { devCommand, DEV_USAGE, fixtureLaunchEnv, fixtureLauncherOptions } from "./dev-fixture.mjs";
|
||||
import { runCommand, RUN_USAGE } from "./run-evidence.mjs";
|
||||
import { tasksWriteCommand, TASKS_WRITE_OPS, TASKS_WRITE_USAGE } from "./tasks-write.mjs";
|
||||
|
||||
export const USAGE = `usage:
|
||||
${ACCOUNTS_USAGE}
|
||||
@@ -63,7 +64,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}
|
||||
@@ -81,6 +83,7 @@ export const USAGE = `usage:
|
||||
mosaic inbox --read <id>... | --read-all [--business <id>]
|
||||
mosaic decide <decision> <option> [--note <text>] [--yes] [--business <id>]
|
||||
mosaic tasks [--business <id>] [--json] [--tester]
|
||||
${TASKS_WRITE_USAGE}
|
||||
mosaic agents [--business <id>] [--json] [--tester]
|
||||
mosaic trail <task|decision> [--business <id>] [--json] [--tester]
|
||||
mosaic talk <role> <text> [--wait <seconds> | --follow] [--business <id>] [--tester]
|
||||
@@ -519,7 +522,8 @@ async function processingCommand(verb, rest, io, deps) {
|
||||
if (!result || typeof result !== 'object' || (typeof result.halted !== 'boolean' && !result.invalid))
|
||||
throw new CliError('processing outcome unknown; use mosaic processing status before trying again', 1);
|
||||
if (verb === 'halt' && result.quiescent !== true)
|
||||
throw new CliError(`halt gate is on for instance ${system.dataRoot}; ${result.remaining ?? 'unknown'} stops unconfirmed. Use mosaic processing status`, 1);
|
||||
// #1873: the refusal names the instance, never its data root; a count that is not a count is unknown.
|
||||
throw new CliError(`halt gate is on for this instance; ${Number.isSafeInteger(result.remaining) && result.remaining >= 0 ? result.remaining : 'unknown'} stops unconfirmed. Use mosaic processing status`, 1);
|
||||
if (parsed.flags.has('--json')) { io.stdout.write(JSON.stringify(result, null, 2) + '\n'); return; }
|
||||
const status = verb === 'resume' ? 'resumed' : result.halted ? 'halted' : result.invalid ? 'uninitialized' : 'running';
|
||||
io.stdout.write(`processing ${status} for instance ${system.dataRoot}\n`);
|
||||
@@ -568,10 +572,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;
|
||||
}
|
||||
@@ -584,7 +599,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;
|
||||
}
|
||||
@@ -597,6 +612,9 @@ async function consoleCommand(rest, io, deps) {
|
||||
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")) {
|
||||
@@ -617,6 +635,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);
|
||||
@@ -702,6 +723,9 @@ async function bus(rest, io, deps) {
|
||||
if (["stop", "restart", "status", "recover"].includes(sub)) {
|
||||
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.
|
||||
// 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);
|
||||
@@ -749,6 +773,8 @@ export async function main(argv, io = { env: process.env, stdin: process.stdin,
|
||||
// A2 (#1873): the owner's root and project agent levels. `mosaic agents` without `root` stays the bus read.
|
||||
if (verb === "agents" && rest[0] === "root") return agentsRootCommand(rest.slice(1), io);
|
||||
if (verb === "project" && rest[0] === "agents") return projectAgentsCommand(rest.slice(1), io);
|
||||
// E2 (#1873, P1-3): the task board's create, edit and archive, as the person (tasks-write.mjs).
|
||||
if (verb === "tasks" && TASKS_WRITE_OPS.includes(rest[0])) return tasksWriteCommand(rest, io, (p) => callFor(p, io, deps));
|
||||
if (["inbox", "decide", "tasks", "agents", "trail"].includes(verb)) return human(verb, rest, io, deps);
|
||||
if (verb === "project" && rest[0] === "north-star") return northStarCommand(rest.slice(1), io, (p) => callFor(p, io, deps));
|
||||
if (verb === "project" && rest[0] === "interview") return interviewCommand(rest.slice(1), io);
|
||||
@@ -774,7 +800,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));
|
||||
|
||||
@@ -13,6 +13,7 @@ import { BusinessError, accessFilePath, configDir, loadAccess, loadBusiness, loa
|
||||
import { loadRootAgents } from "../../organization/src/agent-levels.mjs";
|
||||
import { materializeCopies, withCopies } from "../../organization/src/agent-copies.mjs";
|
||||
import { organizationWriter } from "../../organization/src/organization.mjs";
|
||||
import { systemConfigProblem } from "../../organization/src/system-config-problem.mjs";
|
||||
import { businessFilePath } from "../../organization/src/organization-config.mjs";
|
||||
import { busOrganization } from "../../bus/src/organization-context.mjs";
|
||||
import { BusError } from "../../bus/src/broker.mjs";
|
||||
@@ -41,13 +42,40 @@ export function orgPaths(root) {
|
||||
threads: join(root, "threads"), hostDir };
|
||||
}
|
||||
|
||||
// #1873: mosaic-config's stderr can quote the config's path, the data root or a config value, so
|
||||
// it is never relayed. An instance refusal keeps its code and the instance names, which passed
|
||||
// the instance pattern; a missing config keeps its bootstrap hint; anything else reads as
|
||||
// `mosaic org config` reads it: a known field and its constraint, the config named by label.
|
||||
const INSTANCE = "[a-z][a-z0-9]{1,15}";
|
||||
function instanceProblem(problem, label) {
|
||||
const body = problem.replace(/^mosaic-config: /, "");
|
||||
let m = new RegExp(`^instance-root-mismatch: .+ is claimed by instance (${INSTANCE})(?:, not (${INSTANCE})|; select it with --instance \\1)$`).exec(body);
|
||||
if (m) return `instance-root-mismatch: the data root is claimed by instance ${m[1]}${m[2] ? `, not ${m[2]}` : `; select it with --instance ${m[1]}`}`;
|
||||
m = new RegExp(`^instance-root-unclaimed: .+ holds no instance\\.json naming (${INSTANCE}) \\(scripts/bootstrap\\.sh --instance \\1 claims a new root\\)$`).exec(body);
|
||||
if (m) return `instance-root-unclaimed: the data root holds no instance.json naming ${m[1]} (scripts/bootstrap.sh --instance ${m[1]} claims a new root)`;
|
||||
if (body.startsWith("instance-root-unclaimed: ")) return "instance-root-unclaimed: the data root's instance.json is not a readable, valid marker";
|
||||
m = new RegExp(`^instance-config-mismatch: --instance (${INSTANCE}) names `).exec(body);
|
||||
if (m) return `instance-config-mismatch: --instance ${m[1]} names its own config, but MOSAIC_CONFIG names another`;
|
||||
if (body.startsWith("instance-config-mismatch: ")) return "instance-config-mismatch: --instance names its own config, but MOSAIC_CONFIG names another";
|
||||
if (body.startsWith("instance-name-invalid: ")) return `instance-name-invalid: an instance name must match /^${INSTANCE}$/`;
|
||||
if (body.startsWith("configuration not found: ")) {
|
||||
m = new RegExp(`\\(run (scripts/bootstrap\\.sh(?: --instance ${INSTANCE})?) to create it\\)$`).exec(body);
|
||||
return `configuration not found: ${label}${m ? ` (run ${m[1]} to create it)` : ""}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function loadSystem({ env = process.env, instance = null } = {}) {
|
||||
const proc = spawnSync(process.execPath, [join(REPO, "scripts", "mosaic-config.mjs"), "validate", ...(instance === null ? [] : ["--instance", instance])], {
|
||||
encoding: "utf8",
|
||||
maxBuffer: 1024 * 1024,
|
||||
env,
|
||||
});
|
||||
if (proc.status !== 0) throw new CliError(`system config problem (mosaic-config exit ${proc.status}): ${proc.stderr.trim()}`, 3);
|
||||
if (proc.status !== 0) {
|
||||
const label = instance !== null && instance !== "dev" ? `the config of instance ${instance}` : env.MOSAIC_CONFIG ? "the system config MOSAIC_CONFIG names" : "the default system config";
|
||||
const known = instanceProblem(String(proc.stderr ?? "").trim(), label);
|
||||
throw new CliError(known ? `system config problem (mosaic-config exit ${proc.status}): ${known}` : systemConfigProblem(proc, env, label), 3);
|
||||
}
|
||||
try {
|
||||
return JSON.parse(proc.stdout);
|
||||
} catch {
|
||||
@@ -90,7 +118,7 @@ function trackerFor(system, b, warn, dataRoot) {
|
||||
if (dataRoot && managedState({ dataRoot, business: b.id, project: id, entry }) === 'reserved') continue;
|
||||
if (!existsSync(projectFilePath(entry.root))) continue;
|
||||
const project = loadProject(entry.root);
|
||||
if (project.id !== id) throw new CliError(`project file ${project.file} has id that does not match the declared project key`, 3);
|
||||
if (project.id !== id) throw new CliError(`project file of project ${id} has id that does not match the declared project key`, 3);
|
||||
const vars = resolveInstance({ system, business: b, project, instance: first }).vars;
|
||||
if (vars["tracker.project"] !== undefined) named.push({ id, vars });
|
||||
}
|
||||
@@ -127,17 +155,20 @@ function accessFor(system, env, warn) {
|
||||
try {
|
||||
context = accessContextFor(system, env);
|
||||
} catch (e) {
|
||||
warn(`access.json is present but its context can't be read, so no granted user binds: ${e.message}`);
|
||||
// #1873: the error's message can quote a path; only a plain code is relayed.
|
||||
const code = typeof e?.code === "string" && /^[A-Za-z][A-Za-z0-9_-]{0,63}$/.test(e.code) ? ` (${e.code})` : "";
|
||||
warn(`access.json is present but its context can't be read, so no granted user binds${code}`);
|
||||
return null;
|
||||
}
|
||||
// sage-3153: one line at boot when the loader refuses the file, so the owner sees why no user
|
||||
// binds. The loader's messages can quote the file, so the line names only the path and a code
|
||||
// from its exit code: 4 is a file it could not open as a regular file, anything else invalid.
|
||||
// binds. The loader's messages can quote the file, so the line names only the file, by label, and
|
||||
// a code from its exit code: 4 is a file it could not open as a regular file, anything else
|
||||
// invalid. #1873: no host path.
|
||||
try {
|
||||
loadAccess(file, context);
|
||||
} catch (e) {
|
||||
const code = e instanceof BusinessError && e.exitCode !== 4 ? "access-invalid" : "access-unreadable";
|
||||
warn(`access file refused at boot (${code}), no granted user binds until it is fixed: ${file}`);
|
||||
warn(`access file refused at boot (${code}), no granted user binds until it is fixed: access.json in the config directory`);
|
||||
}
|
||||
return { file, context };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
// D1 (#1873): the supervisor of a detached dev-local-owner Console. `mosaic console start
|
||||
// --dev-local-owner --detach` starts this process in its own session (org-console.mjs
|
||||
// detachedSupervisor). It starts the Console exactly as a foreground start does, with --supervised,
|
||||
// and relaunches it when the owner restarts it (F3). Its one stdout line is the start's reply:
|
||||
// { receipt, warnings } or { error, exitCode, warnings }. Later warnings go to stderr, the log.
|
||||
import { consoleStart } from './org-console.mjs';
|
||||
import { CliError } from './errors.mjs';
|
||||
|
||||
const { configDir, dataRoot, instance, port, bind, allowHosts } = JSON.parse(process.argv[2]);
|
||||
const warnings = [];
|
||||
let started = false;
|
||||
const warn = s => { if (started) process.stderr.write(s); else warnings.push(s); };
|
||||
// The starting CLI can be gone before the reply (Ctrl-C, a wrapper's timeout). The Console is
|
||||
// started and its state names it, so a reply nobody reads is dropped and supervision goes on.
|
||||
process.stdout.on('error', () => {});
|
||||
const reply = value => new Promise(resolve => process.stdout.write(`${JSON.stringify(value)}\n`, () => resolve()));
|
||||
// A stop signal during the start (the CLI's start timeout signals this process group) is kept and
|
||||
// acted on once the start settles, so a failed start still releases the start lock.
|
||||
let stopAsked = false;
|
||||
const early = () => { stopAsked = true; };
|
||||
for (const signal of ['SIGINT', 'SIGTERM']) process.on(signal, early);
|
||||
let receipt;
|
||||
try {
|
||||
receipt = await consoleStart({ configDir, dataRoot, instance, env: process.env }, { devLocalOwner: true, port, bind, allowHosts, warn });
|
||||
} catch (e) {
|
||||
const exitCode = e instanceof CliError ? e.exitCode : 1;
|
||||
if (!(e instanceof CliError)) process.stderr.write(`${e?.stack ?? e}\n`);
|
||||
await reply({ ...(e instanceof CliError ? { error: e.message } : {}), exitCode, warnings });
|
||||
process.exit(exitCode);
|
||||
}
|
||||
started = true;
|
||||
// A stop signal ends supervision cleanly, as in the foreground (cli.mjs consoleCommand).
|
||||
const stop = () => receipt.stop();
|
||||
for (const signal of ['SIGINT', 'SIGTERM']) { process.off(signal, early); process.on(signal, stop); }
|
||||
if (stopAsked) stop();
|
||||
await reply({ receipt, warnings });
|
||||
const code = await receipt.done;
|
||||
if (code !== 0) process.stderr.write(`mosaic: console stopped unexpectedly (${code})\n`);
|
||||
process.exit(code);
|
||||
@@ -367,7 +367,12 @@ export async function startHost({ boot, business, notifier = null, options = nul
|
||||
close(1);
|
||||
});
|
||||
|
||||
return Object.freeze({ path: ready.path, dataRoot, business, bindLaunch, op, beforeClose, close, done, pids: Object.freeze({ broker: broker.pid, notifier: notify?.pid ?? null }) });
|
||||
// #1873 CB3: the copies the broker's boot proved live, names only; anything malformed counts as none.
|
||||
const copyBound = {};
|
||||
if (ready.copyBound && typeof ready.copyBound === "object" && !Array.isArray(ready.copyBound))
|
||||
for (const [instance, services] of Object.entries(ready.copyBound))
|
||||
if (Array.isArray(services) && services.every((s) => typeof s === "string")) copyBound[instance] = Object.freeze([...services]);
|
||||
return Object.freeze({ path: ready.path, dataRoot, business, bindLaunch, op, beforeClose, close, done, copyBound: Object.freeze(copyBound), pids: Object.freeze({ broker: broker.pid, notifier: notify?.pid ?? null }) });
|
||||
}
|
||||
|
||||
// `mosaic bus stop`: SIGTERM to the recorded host, after checking that the
|
||||
|
||||
@@ -145,8 +145,9 @@ import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, renameSync,
|
||||
import { dirname, join, relative } from "node:path";
|
||||
import { configDir, resolveInstance, systemVars } from "../../organization/src/index.mjs";
|
||||
import { organizationWriter } from "../../organization/src/organization.mjs";
|
||||
import { quietMessage } from "../../organization/src/org-registry.mjs";
|
||||
import { businessFilePath } from "../../organization/src/organization-config.mjs";
|
||||
import { copyOf, copyRefusal, loadWithCopies, projectRefusal, withCopies } from "../../organization/src/agent-copies.mjs";
|
||||
import { copyOf, copyRefusal, loadWithCopies, projectRefusal, promoteCopies, withCopies } from "../../organization/src/agent-copies.mjs";
|
||||
import { selectModelProvider } from "../../plugins/src/model-provider-selection.mjs";
|
||||
import { THREAD_DEFINITIONS } from "../../organization/src/vocabulary.mjs";
|
||||
import { limitHit } from "../../organization/src/launch.mjs";
|
||||
@@ -201,13 +202,24 @@ class Refusal extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
// #1873: a refusal's reason never quotes a host path. A Node system error's message names one,
|
||||
// so it reads as the call and its code (org-registry quietMessage). Any other message is the
|
||||
// host's own text or text relayed from a module (a preflight, the loader, a bundle or checkout
|
||||
// error); relay() replaces every absolute or home path and file: URL in it with "a host path".
|
||||
const HOST_PATH = /(^|[\s'"(=:,[<])(?:~?\/(?!\/)|file:)[^\s'"),\]>;]*/gi;
|
||||
export const relay = (text) => String(text).replace(HOST_PATH, (_, before) => `${before}a host path`);
|
||||
const reason = (e) => quietMessage(e, "a host file") ?? relay(e?.message ?? e);
|
||||
const relayed = (failed) => failed && (typeof failed.message === "string" ? { ...failed, message: relay(failed.message) } : failed);
|
||||
|
||||
// A harness module runs with the host's authority, and anything it throws refuses the launch
|
||||
// and names the module rather than escaping the host.
|
||||
// and names the module rather than escaping the host. What it threw is named by its system code
|
||||
// or its class, never its message, which the host didn't write (#1873).
|
||||
function call(name, what, fn) {
|
||||
try {
|
||||
return fn();
|
||||
} catch (e) {
|
||||
throw new Refusal("harness-failed", `harness ${name} ${what}: ${e.message}`);
|
||||
const thrown = quietMessage(e, "a host file") ?? `threw ${/^[A-Za-z][A-Za-z0-9]{0,39}$/.test(String(e?.name)) ? e.name : "a value"} without a system code`;
|
||||
throw new Refusal("harness-failed", `harness ${name} ${what}: ${thrown}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -252,6 +264,9 @@ export function createLauncher({
|
||||
if (testProxy !== null && system.proxy)
|
||||
throw Object.assign(new Error("testProxy skips P2 admission, so it is refused with system.proxy set"), { code: "test-proxy-refused" });
|
||||
const dataRoot = system.dataRoot;
|
||||
// #1873 CB3: a copy the bus's boot proved live at its service launches as bound; the bus decides
|
||||
// which, and admits each launch again on its own launch authority. The rest stay as booted.
|
||||
if (copies && host.copyBound) copies = promoteCopies(copies, host.copyBound);
|
||||
function admission(generation = null) {
|
||||
try { return assertProcessing(dataRoot, generation); }
|
||||
catch (e) {
|
||||
@@ -272,7 +287,8 @@ export function createLauncher({
|
||||
try {
|
||||
leftover = readSessions(dataRoot).filter((s) => s.business === b.id);
|
||||
} catch (e) {
|
||||
throw new CliError(`${e.message}; check which sessions still run, then move the file aside`, 3);
|
||||
// readSessions names the file by its place under the data root (#1873); anything else is unreadable.
|
||||
throw new CliError(`${e?.name === "SeatError" ? e.message : "sessions file is unreadable"}; check which sessions still run, then move the file aside`, 3);
|
||||
}
|
||||
const registry = new Registry(dataRoot, { readOnly: !launches });
|
||||
// Row 269: earlier-host runs still live after recover(). check() sees each while it lives.
|
||||
@@ -386,7 +402,7 @@ export function createLauncher({
|
||||
// org-defscan (#1873): the entry the contract came from, taken before any await lets another check replace b.
|
||||
definitionEntry = String(resolved.definition).startsWith("org:") ? b.definitions?.[resolved.definition] : undefined;
|
||||
} catch (e) {
|
||||
throw new Refusal(e.code === "plugin-disabled" ? "plugin-disabled" : "unsupported-harness", e.code === "plugin-disabled" ? e.plugin : e.message);
|
||||
throw new Refusal(e.code === "plugin-disabled" ? "plugin-disabled" : "unsupported-harness", e.code === "plugin-disabled" ? e.plugin : relay(e.message));
|
||||
}
|
||||
// Resolve the business's current provider modules with a fresh loader; a refused
|
||||
// load never inherits a previous business's authority.
|
||||
@@ -409,7 +425,7 @@ export function createLauncher({
|
||||
return sessionModel(resolved, b.launch.models, which, { proxy: Boolean(system.proxy || proxyContext), resolver });
|
||||
} catch (e) {
|
||||
const code = e instanceof BundleError && (e.code === "no-route" || e.code.startsWith("proxy-") || e.code.startsWith("model-")) ? e.code : "unsupported-harness";
|
||||
throw new Refusal(code, e.message);
|
||||
throw new Refusal(code, relay(e.message));
|
||||
}
|
||||
};
|
||||
const primary = routeOf("primary");
|
||||
@@ -425,7 +441,7 @@ export function createLauncher({
|
||||
try {
|
||||
readSkills(resolved.skills, skillsDir);
|
||||
} catch (e) {
|
||||
if (e instanceof BundleError) throw new Refusal(e.code === "skill-not-enabled" ? "skill-not-enabled" : "skill-refused", e.message);
|
||||
if (e instanceof BundleError) throw new Refusal(e.code === "skill-not-enabled" ? "skill-not-enabled" : "skill-refused", relay(e.message));
|
||||
throw e;
|
||||
}
|
||||
// Both of a launch's harnesses must be switched on, whichever it takes.
|
||||
@@ -437,7 +453,9 @@ export function createLauncher({
|
||||
const harnessOf = async (m) => {
|
||||
const loader = makeLoader({ dataRoot:system.instanceRoot ?? dataRoot, log: (entry) => record({ ...entry, instance }) });
|
||||
const { refused } = await loader.load({ harness: [m.harness] });
|
||||
if (refused.length) throw new Refusal("harness-refused", `harness ${m.harness}: ${refused.map((r) => `${r.code}: ${r.message}`).join("; ")}`);
|
||||
// #1873: a loader message can quote the module's path or its load error; only a
|
||||
// missing-export one, which names the export, is relayed. Any other refusal names its code.
|
||||
if (refused.length) throw new Refusal("harness-refused", `harness ${m.harness}: ${refused.map((r) => r.code === "missing-export" ? `${r.code}: ${relay(r.message)}` : String(r.code)).join("; ")}`);
|
||||
const module = loader.modules("harness")[0];
|
||||
const levels = call(m.harness, "levels", () => module.levels());
|
||||
if (!levels.includes(m.thinking)) throw new Refusal("thinking-unsupported", `harness ${m.harness} takes thinking ${levels.join(", ")}, not ${m.thinking}`);
|
||||
@@ -484,7 +502,7 @@ export function createLauncher({
|
||||
}
|
||||
}
|
||||
const unavailable = sandboxProblem({ bwrap });
|
||||
if (unavailable) throw new Refusal("sandbox-unavailable", unavailable);
|
||||
if (unavailable) throw new Refusal("sandbox-unavailable", relay(unavailable));
|
||||
const hit=limitHit(registry.sessions,b.id,b.launch.limits,model);
|
||||
if(hit){const error=new Refusal("capacity-full",JSON.stringify(hit));error.limit=hit;throw error;}
|
||||
// Row 125 (#1619, REQ-LIMIT-4): the limit checks. An answer that isn't an allow refuses,
|
||||
@@ -532,7 +550,8 @@ export function createLauncher({
|
||||
// and the helper returns the last one they saw.
|
||||
const pickRoute = async (proxyOf) => {
|
||||
let sessionProxy = await proxyOf(primary);
|
||||
const failed = call(primary.harness, "preflight", () => harness.preflight({ env: sessionProxy ? sessionEnv(env, "preflight", sessionProxy) : env, proxy: sessionProxy }));
|
||||
// #1873: a preflight's message is the module's own text, relayed with host paths replaced.
|
||||
const failed = relayed(call(primary.harness, "preflight", () => harness.preflight({ env: sessionProxy ? sessionEnv(env, "preflight", sessionProxy) : env, proxy: sessionProxy })));
|
||||
if (failed) {
|
||||
if (!fallback) throw new Refusal(failed.code, failed.message);
|
||||
// The fallback only on a failed primary preflight, and only at launch (row 65). Any refusal of
|
||||
@@ -542,7 +561,7 @@ export function createLauncher({
|
||||
throw e;
|
||||
});
|
||||
sessionProxy = await proxyOf(fallback);
|
||||
const also = call(fallback.harness, "preflight", () => second.preflight({ env: sessionProxy ? sessionEnv(env, "preflight", sessionProxy) : env, proxy: sessionProxy }));
|
||||
const also = relayed(call(fallback.harness, "preflight", () => second.preflight({ env: sessionProxy ? sessionEnv(env, "preflight", sessionProxy) : env, proxy: sessionProxy })));
|
||||
if (also) throw new Refusal(also.code, `fallback ${fallback.harness}: ${also.message} (primary ${primary.harness}: ${failed.message})`);
|
||||
[model, harness, route, routeReason] = [fallback, second, "fallback", failed.code];
|
||||
}
|
||||
@@ -664,7 +683,7 @@ export function createLauncher({
|
||||
return ensureCheckouts({ workspace, projects: codeProjects, env });
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof CheckoutError) throw new Refusal("checkout-failed", `${e.project}: ${e.message}`);
|
||||
if (e instanceof CheckoutError) throw new Refusal("checkout-failed", `${e.project}: ${relay(e.message)}`);
|
||||
throw e;
|
||||
}
|
||||
for (const c of checkouts) {
|
||||
@@ -811,7 +830,19 @@ export function createLauncher({
|
||||
// asynchronous preparation read. Admission is the launch boundary; bindLaunch remains a fence.
|
||||
// #1873 A3: `revision` is the organization file check() resolved the bundle from; a file that
|
||||
// changed since (a narrow, a define's vars, an exclude) refuses, and the next launch resolves anew.
|
||||
const finalAdmission = await host.op({ op: "request.admission", business: b.id, projects: Object.keys(codeProjects), instance, revision, thread, session });
|
||||
// #1873 CB3: a bound copy's launch authority, after the bus re-resolved its binding and proved the
|
||||
// slot live at the service. The final admission and bindLaunch each check it is still current.
|
||||
let copyAuthority = null;
|
||||
if (copyOf(b, instance)?.bound?.length) {
|
||||
// The bus's refusal is the launch's: its code goes to the log and the thread as given.
|
||||
let prepared;
|
||||
try { prepared = await host.op({ op: "copyLaunch", business: b.id, instance, revision }); }
|
||||
catch (e) { throw new Refusal(typeof e?.code === "string" && /^[a-z][a-z0-9-]*$/.test(e.code) ? e.code : "credential-unavailable", `${instance}'s bound services can't be proved live`); }
|
||||
if (typeof prepared?.authority !== "string") throw new Refusal("credential-unavailable", `${instance}'s bound services can't be proved live`);
|
||||
copyAuthority = prepared.authority;
|
||||
admission(admissionGeneration);
|
||||
}
|
||||
const finalAdmission = await host.op({ op: "request.admission", business: b.id, projects: Object.keys(codeProjects), instance, revision, thread, session, ...(copyAuthority ? { authority: copyAuthority } : {}) });
|
||||
if (finalAdmission?.ready !== true) throw new Refusal(finalAdmission?.code ?? "project-pending");
|
||||
const managed = registerRuntime(dataRoot, { kind: 'session', business: b.id, run });
|
||||
let child;
|
||||
@@ -840,7 +871,7 @@ export function createLauncher({
|
||||
admission(admissionGeneration);
|
||||
if (!outerStart) throw new Error("the session process exited at once");
|
||||
lock?.update({ run, runPid: child.pid, runStartTime: outerStart });
|
||||
const launch = await host.bindLaunch({ business: b.id, role: instance, run, harness: model.harness, provider: model.provider, model: model.model, account, transcript, pid: child.pid, startTime: outerStart, ...(thread ? { thread, session } : {}), ...(admittedProxy ? { modelKeyId } : {}) });
|
||||
const launch = await host.bindLaunch({ business: b.id, role: instance, run, harness: model.harness, provider: model.provider, model: model.model, account, transcript, pid: child.pid, startTime: outerStart, ...(thread ? { thread, session } : {}), ...(admittedProxy ? { modelKeyId } : {}), ...(copyAuthority ? { copyAuthority, revision } : {}) });
|
||||
bound = true;
|
||||
admission(admissionGeneration);
|
||||
entry = {
|
||||
@@ -861,7 +892,7 @@ export function createLauncher({
|
||||
unregisterRuntime(dataRoot, managed.id);
|
||||
if (e instanceof Refusal) throw e;
|
||||
if (e.code?.startsWith('proxy-')) throw new Refusal(e.code, e.code);
|
||||
throw new Refusal("launch-failed", e.message);
|
||||
throw new Refusal("launch-failed", reason(e));
|
||||
}
|
||||
children.set(run, exited);
|
||||
const outputWatch = admittedProxy ? watchModelOutput({ runDir, armAtFirstTurn: true, keyId: modelKeyId, seconds: system.proxy.firstOutputSeconds ?? 120,
|
||||
@@ -1041,7 +1072,7 @@ export function createLauncher({
|
||||
if (refusedThreads.get(key) === code) return;
|
||||
refusedThreads.set(key, code);
|
||||
log(`thread ${t.thread} session ${t.session} (${t.instance}): start refused: ${code}`);
|
||||
record({ event: "refused", by: b.human, instance: t.instance, thread: t.thread, session: t.session, code, why: e.message, ...(e.limit?{limit:e.limit}:{}) });
|
||||
record({ event: "refused", by: b.human, instance: t.instance, thread: t.thread, session: t.session, code, why: reason(e), ...(e.limit?{limit:e.limit}:{}) });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1143,7 +1174,7 @@ export function createLauncher({
|
||||
if (clearLogged.get(c.op) === code) return;
|
||||
clearLogged.set(c.op, code);
|
||||
log(`clear ${c.op}: successor ${c.successor} (${c.instance}): start ${CLEAR_HELD.has(code) ? "held" : "refused"}: ${code}`);
|
||||
record({ event: "refused", by: b.human, instance: c.instance, thread: c.thread, session: c.successor, code, why: e.message, ...(e.limit ? { limit: e.limit } : {}) });
|
||||
record({ event: "refused", by: b.human, instance: c.instance, thread: c.thread, session: c.successor, code, why: reason(e), ...(e.limit ? { limit: e.limit } : {}) });
|
||||
await host.op({ op: "sessionClearStart", record: { ...where, start: CLEAR_HELD.has(code) ? "held" : "refused", refusal: code } });
|
||||
return;
|
||||
}
|
||||
@@ -1203,7 +1234,7 @@ export function createLauncher({
|
||||
if (refusedLand.get(h.decision) === code) return;
|
||||
refusedLand.set(h.decision, code);
|
||||
log(`decision ${h.decision} (${h.instance}): landing start refused: ${code}`);
|
||||
record({ event: "refused", by: b.human, instance: h.instance, decision: h.decision, code, why: e.message, ...(e.limit ? { limit: e.limit } : {}) });
|
||||
record({ event: "refused", by: b.human, instance: h.instance, decision: h.decision, code, why: reason(e), ...(e.limit ? { limit: e.limit } : {}) });
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1248,7 +1279,7 @@ export function createLauncher({
|
||||
try {
|
||||
done = await act(take);
|
||||
} catch (e) {
|
||||
done = { refusal: e instanceof Refusal ? e.code : "launch-failed", why: e.message, limit: e.limit };
|
||||
done = { refusal: e instanceof Refusal ? e.code : "launch-failed", why: reason(e), limit: e.limit };
|
||||
}
|
||||
const { why, limit, ...closing } = done;
|
||||
if (closing.refusal) record({ event: "refused", by: r.by, instance: r.instance, request: r.request, code: closing.refusal, ...(why ? { why } : {}), ...(limit ? { limit } : {}) });
|
||||
@@ -1356,18 +1387,19 @@ export function createLauncher({
|
||||
const bot = out.bot ? { bot: out.bot } : {};
|
||||
// The old instance workspace, then (row 269) the instance's session workspaces, each moved
|
||||
// whole. A retry moves whichever is still in place.
|
||||
for (const from of [workspaceDir(dataRoot, b.id, r.instance), join(dataRoot, "session-workspaces", b.id, r.instance)]) {
|
||||
for (const [from, label] of [[workspaceDir(dataRoot, b.id, r.instance), "the instance workspace"], [join(dataRoot, "session-workspaces", b.id, r.instance), "the session workspaces"]]) {
|
||||
if (!existsSync(from)) continue;
|
||||
const to = join(dirname(from), ".retired", `${r.instance}-${transaction}`);
|
||||
try {
|
||||
mkdirSync(dirname(to), { recursive: true, mode: 0o700 });
|
||||
chmodSync(dirname(to), 0o700);
|
||||
if (existsSync(to)) throw new Error(`${to} exists`);
|
||||
if (existsSync(to)) throw new Error("its .retired name already exists");
|
||||
renameSync(from, to);
|
||||
chmodSync(to, 0o700);
|
||||
} catch (e) {
|
||||
log(`request ${r.request} (${r.instance}): workspace rename failed: ${e.message}; left at ${from}`);
|
||||
return { transaction, ...bot, refusal: "workspace-rename-failed", why: `${e.message}; left at ${from}` };
|
||||
// #1873: the reason names the workspace by label and the error by code, never a path.
|
||||
return { transaction, ...bot, refusal: "workspace-rename-failed", why: `${quietMessage(e, label) ?? `${label}: ${relay(e.message)}`}; left in place` };
|
||||
}
|
||||
record({ event: "workspace-retired", instance: r.instance, request: r.request, transaction, path: to });
|
||||
}
|
||||
@@ -1438,7 +1470,7 @@ export function createLauncher({
|
||||
} catch (e) {
|
||||
if (!(e instanceof Refusal)) throw e;
|
||||
await host.op({ op: "refuse", cap, code: e.code }).catch(() => {});
|
||||
record({ event: "refused", by: who.role, instance, code: e.code, why: e.message, ...(e.limit?{limit:e.limit}:{}) });
|
||||
record({ event: "refused", by: who.role, instance, code: e.code, why: reason(e), ...(e.limit?{limit:e.limit}:{}) });
|
||||
return reply({ ok: false, error: e.code });
|
||||
}
|
||||
try {
|
||||
@@ -1453,7 +1485,7 @@ export function createLauncher({
|
||||
const code = e instanceof Refusal ? e.code : "launch-failed";
|
||||
log(`launch of ${instance} for ${who.role} failed: ${e.message}`);
|
||||
await host.op({ op: "refuse", cap, code }).catch(() => {});
|
||||
record({ event: "refused", by: who.role, instance, code, why: e.message, ...(e.limit?{limit:e.limit}:{}) });
|
||||
record({ event: "refused", by: who.role, instance, code, why: reason(e), ...(e.limit?{limit:e.limit}:{}) });
|
||||
return reply({ ok: false, error: code });
|
||||
}
|
||||
});
|
||||
@@ -1467,8 +1499,8 @@ export function createLauncher({
|
||||
return await start({ instance: b.launch.by, launchedBy: b.human, ...(await check(b.launch.by, { pm: true })) });
|
||||
} catch (e) {
|
||||
if (!(e instanceof Refusal)) throw e;
|
||||
record({ event: "refused", by: b.human, instance: b.launch.by, code: e.code, why: e.message, ...(e.limit?{limit:e.limit}:{}) });
|
||||
const error = new CliError(`PM launch refused: ${e.code}${e.message !== e.code ? ` (${e.message})` : ""}`, 3);
|
||||
record({ event: "refused", by: b.human, instance: b.launch.by, code: e.code, why: reason(e), ...(e.limit?{limit:e.limit}:{}) });
|
||||
const error = new CliError(`PM launch refused: ${e.code}${reason(e) !== e.code ? ` (${reason(e)})` : ""}`, 3);
|
||||
error.code = e.code;
|
||||
throw error;
|
||||
}
|
||||
@@ -1519,7 +1551,7 @@ export function createLauncher({
|
||||
await recover();
|
||||
const path = launchSocketPath(dataRoot);
|
||||
if (existsSync(path)) {
|
||||
if (!lstatSync(path).isSocket()) throw new CliError(`${path} exists and isn't a socket`, 3);
|
||||
if (!lstatSync(path).isSocket()) throw new CliError("launch socket path exists and isn't a socket", 3);
|
||||
rmSync(path);
|
||||
}
|
||||
server = createServer((socket) => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import { processState } from '../../seat/src/process-state.mjs';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { constants, closeSync, lstatSync, mkdirSync, openSync, readFileSync, fstatSync, unlinkSync, writeFileSync } from 'node:fs';
|
||||
import { constants, closeSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, fstatSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
||||
import { connect } from 'node:net';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
@@ -151,6 +151,27 @@ export async function busRestart(ctx, id) {
|
||||
return busStart(ctx, id, { detach: true, ...options });
|
||||
}
|
||||
|
||||
// D2 security review: clear only the entries busRecover inspected. Each is renamed aside (atomic)
|
||||
// and the renamed entry is checked by device and inode; an entry swapped after the inspection is
|
||||
// put back with everything moved so far, and the recovery refuses before any receipt or deletion.
|
||||
// The deletion then names only the aside entries, never the live paths.
|
||||
export function setAside(entries) {
|
||||
const tag = randomBytes(4).toString('hex'), moved = [];
|
||||
const restore = () => {
|
||||
// link() refuses an existing path, so a put-back never replaces whatever now holds the name; an
|
||||
// entry that cannot go back stays under its .recovering name for a person to inspect.
|
||||
for (const m of moved.slice().reverse()) { try { linkSync(m.aside, m.path); unlinkSync(m.aside); } catch { /* left aside */ } }
|
||||
};
|
||||
for (const entry of entries) {
|
||||
const aside = `${entry.path}.recovering-${tag}`;
|
||||
let current;
|
||||
try { renameSync(entry.path, aside); moved.push({ path: entry.path, aside }); current = lstatSync(aside); }
|
||||
catch { restore(); fail('bus recovery state changed'); }
|
||||
if (current.dev !== entry.st.dev || current.ino !== entry.st.ino) { restore(); fail('bus recovery state changed'); }
|
||||
}
|
||||
return { restore, remove: () => { for (const m of moved.slice().reverse()) unlinkSync(m.aside); } };
|
||||
}
|
||||
|
||||
// Recover only while the org lifecycle fence excludes new starts and deletion.
|
||||
export async function busRecover(ctx, id) {
|
||||
return withOrgLifecycle(ctx, id, async () => {
|
||||
@@ -177,10 +198,12 @@ export async function busRecover(ctx, id) {
|
||||
const h = state.value;
|
||||
if (!Number.isSafeInteger(h?.pid) || h.pid <= 0 || typeof h.startTime !== 'string' || !/^\d+$/.test(h.startTime) || typeof h.startedAt !== 'string' || !Number.isFinite(Date.parse(h.startedAt)) || h.business !== id) fail('invalid or mismatched bus host state');
|
||||
let p; try { p = processState(h.pid); } catch { fail('bus host identity is uncertain'); }
|
||||
// A live pid with the recorded start time is the recorded process: never proof of death. Its argv
|
||||
// only picks the message (D2 security review: a mismatching argv used to read as a dead host).
|
||||
if (p.alive && p.startTime === h.startTime) {
|
||||
const argv = cmdlineOf(h.pid); if (!argv || !argv.length) fail('bus host identity is uncertain');
|
||||
const i = argv.indexOf(CLI);
|
||||
const argv = cmdlineOf(h.pid), i = argv?.indexOf(CLI) ?? -1;
|
||||
if (i >= 0 && argv[i + 1] === 'bus' && argv[i + 2] === 'start' && argv[i + 3] === id) fail(`bus host pid ${h.pid} is alive`);
|
||||
fail('bus host identity is uncertain');
|
||||
}
|
||||
if (lock && (lock.value?.pid !== h.pid || lock.value?.startTime !== h.startTime || lock.value?.business !== id || lock.value?.startedAt !== h.startedAt)) fail('bus host lock does not match host state');
|
||||
if (writer) {
|
||||
@@ -198,14 +221,43 @@ export async function busRecover(ctx, id) {
|
||||
if (occupied) fail('bus socket answers or is uncertain');
|
||||
}
|
||||
const entries = [state, lock, writer, socket].filter(Boolean);
|
||||
for (const entry of entries) {
|
||||
const current = lstatSync(entry.path);
|
||||
if (current.dev !== entry.st.dev || current.ino !== entry.st.ino) fail('bus recovery state changed');
|
||||
}
|
||||
const aside = setAside(entries);
|
||||
const at = new Date().toISOString();
|
||||
const receipt = join(org.paths.hostDir, `recovery-${at.replace(/[:.]/g, '-')}-${randomBytes(4).toString('hex')}.json`);
|
||||
writeFileSync(receipt, JSON.stringify({ action: 'bus.recover', at, org: id, stopped: true, host: h, lock: lock?.value ?? null, writer: writer?.value ?? null, socket: Boolean(socket) }, null, 2) + '\n', { mode: 0o600, flag: 'wx' });
|
||||
for (const entry of entries.slice().reverse()) unlinkSync(entry.path);
|
||||
return { org: id, recovered: true, pid: h.pid, receipt };
|
||||
try { writeFileSync(receipt, JSON.stringify({ action: 'bus.recover', at, org: id, stopped: true, host: h, lock: lock?.value ?? null, writer: writer?.value ?? null, socket: Boolean(socket) }, null, 2) + '\n', { mode: 0o600, flag: 'wx' }); }
|
||||
catch (e) { aside.restore(); throw e; }
|
||||
aside.remove();
|
||||
const result = { org: id, recovered: true, pid: h.pid, receipt };
|
||||
// The dead host's start options, for busRevive. Not part of the printed receipt.
|
||||
const o = h.options && typeof h.options === 'object' ? h.options : {};
|
||||
Object.defineProperty(result, 'options', { value: Object.freeze({ devLocalOwner: o.devLocalOwner === true, pm: o.pm === true, noLaunch: o.noLaunch === true }) });
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
// D2 (P0-3): bring back a bus whose host was killed, as a Console unit stop does to the hosts the
|
||||
// Console started. busRecover is the proof: it refuses a live or unprovable host, a lock that does
|
||||
// not match the dead host, a live writer and an answering socket. The host restarts on the options
|
||||
// the dead host recorded, never wider; `pm: false` also keeps the chief from launching.
|
||||
export function reviveStartOptions(recorded, { pm = true } = {}) {
|
||||
const o = recorded && typeof recorded === 'object' ? recorded : {};
|
||||
return { detach: true, devLocalOwner: o.devLocalOwner === true, pm: pm === true && o.pm === true, noLaunch: o.noLaunch === true };
|
||||
}
|
||||
export async function busRevive(ctx, id, { pm = true } = {}) {
|
||||
await target(ctx, id);
|
||||
const recovered = await busRecover(ctx, id);
|
||||
const options = reviveStartOptions(recovered.options, { pm });
|
||||
const cleared = { pid: recovered.pid, receipt: recovered.receipt };
|
||||
let started;
|
||||
try { started = await busStart(ctx, id, options); }
|
||||
catch (e) {
|
||||
// The dead host's state is gone by now: the caller must not report "nothing changed".
|
||||
if (e && typeof e === 'object') { try { Object.defineProperty(e, 'recovered', { value: cleared }); } catch { /* frozen: the caller sees a plain failure */ } }
|
||||
throw e;
|
||||
}
|
||||
const receipt = { ...started, recovered: cleared };
|
||||
Object.defineProperty(receipt, 'done', { value: started.done });
|
||||
// The chief was not relaunched although the dead host was started with --pm.
|
||||
Object.defineProperty(receipt, 'pmDropped', { value: recovered.options.pm === true && !options.pm });
|
||||
return receipt;
|
||||
}
|
||||
@@ -3,13 +3,14 @@ import { randomBytes } from 'node:crypto';
|
||||
import { processState } from '../../seat/src/process-state.mjs';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { request as httpRequest } from 'node:http';
|
||||
import { constants, closeSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, rmSync, renameSync, writeFileSync } from 'node:fs';
|
||||
import { constants, closeSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, rmSync, renameSync, writeFileSync, writeSync } from 'node:fs';
|
||||
import { basename, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { listOrgs, resolveOrg } from '../../organization/src/org-registry.mjs';
|
||||
import { cmdlineOf, startTimeOf } from './host.mjs';
|
||||
import { CliError } from './errors.mjs';
|
||||
import { devLanOptions } from './dev-local-owner.mjs';
|
||||
import { refuseInsideAgent } from './transport.mjs';
|
||||
|
||||
const WEBUI = fileURLToPath(new URL('../../webui/src/cli.mjs', import.meta.url));
|
||||
const forbidden = new Set([7332, 7340, 7341, 33223, 17450, 17443, 44307]);
|
||||
@@ -63,8 +64,25 @@ function takeLock(ctx) {
|
||||
checkDir(ctx);
|
||||
const path = join(directory(ctx), 'start.lock');
|
||||
let fd; try { fd = openSync(path, 'wx', 0o600); } catch { fail('console start lock exists'); }
|
||||
// D1: the lock names its holder, so a detached start that timed out can clear the lock of the
|
||||
// supervisor it killed (clearDeadLock) and no other.
|
||||
try { writeSync(fd, JSON.stringify({ pid: process.pid, startTime: startTimeOf(process.pid) })); } catch { /* an unnamed lock is only never cleared */ }
|
||||
return () => { closeSync(fd); rmSync(path, { force: true }); };
|
||||
}
|
||||
// Removes start.lock only when it names this exact process (pid and start time) and that process
|
||||
// is gone. A lock that is unreadable, unnamed or another holder's stays.
|
||||
function clearDeadLock(ctx, pid, startTime) {
|
||||
if (!startTime) return;
|
||||
const path = join(directory(ctx), 'start.lock');
|
||||
let owner;
|
||||
try {
|
||||
const st = lstatSync(path);
|
||||
if (!st.isFile() || st.uid !== process.getuid()) return;
|
||||
owner = JSON.parse(readFileSync(path, 'utf8'));
|
||||
} catch { return; }
|
||||
if (owner?.pid !== pid || owner.startTime !== startTime || startTimeOf(pid) === startTime) return;
|
||||
rmSync(path, { force: true });
|
||||
}
|
||||
// The exit code a supervised Console uses to ask for a relaunch (EX_TEMPFAIL). Only the Console's
|
||||
// own restart route (POST /api/console/restart, a human action) exits with it.
|
||||
export const RESTART_EXIT = 75;
|
||||
@@ -153,15 +171,77 @@ function supervise(ctx, first, args, lan, warn) {
|
||||
})();
|
||||
return { done, stop };
|
||||
}
|
||||
// D1 (#1873): `--dev-local-owner --detach` runs the foreground supervision above in a process of
|
||||
// its own session (console-supervisor.mjs), so Restart Console works on a detached dev Console. The
|
||||
// supervisor takes the start lock and writes the console state as a foreground start does. This
|
||||
// process waits for the supervisor's one-line reply, passes its warnings on and leaves it running.
|
||||
const SUPERVISOR = fileURLToPath(new URL('./console-supervisor.mjs', import.meta.url));
|
||||
async function detachedSupervisor(ctx, { port, bind, allowHosts, warn }) {
|
||||
checkDir(ctx);
|
||||
lastStamp = Math.max(Date.now(), lastStamp + 1);
|
||||
const name = `supervisor-${process.pid}-${lastStamp}.log`;
|
||||
const fd = openSync(join(directory(ctx), name), constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
|
||||
let child;
|
||||
try {
|
||||
child = spawn(process.execPath, [SUPERVISOR, JSON.stringify({ configDir: ctx.configDir, dataRoot: ctx.dataRoot, instance: ctx.instance, port, bind, allowHosts })],
|
||||
{ env: ctx.env ?? process.env, detached: true, stdio: ['ignore', 'pipe', fd] });
|
||||
} finally { closeSync(fd); }
|
||||
const supervisorStart = child.pid ? startTimeOf(child.pid) : null;
|
||||
const done = new Promise(resolve => { child.once('error', () => resolve(1)); child.once('exit', code => resolve(code ?? 1)); });
|
||||
// The log is named under the data root, never by host path (PR #1916).
|
||||
const unclear = `console failed to start; see console/${name} under the data root`;
|
||||
let text = '', timer;
|
||||
child.stdout.setEncoding('utf8');
|
||||
const line = await Promise.race([
|
||||
new Promise(resolve => {
|
||||
child.stdout.on('data', c => { text += c; const n = text.indexOf('\n'); if (n >= 0) resolve(text.slice(0, n)); else if (text.length > 65536) resolve(null); });
|
||||
child.stdout.once('end', () => resolve(null));
|
||||
}),
|
||||
new Promise(resolve => { timer = setTimeout(() => resolve(undefined), 60000); }),
|
||||
]);
|
||||
clearTimeout(timer);
|
||||
child.stdout.destroy();
|
||||
// The supervisor leads its own process group, with the Console it may have started: a timed-out
|
||||
// start signals that group, then kills it if it is still there after 10 seconds. A supervisor
|
||||
// killed while it held the start lock never released it, so the lock it named is cleared.
|
||||
if (line === undefined) {
|
||||
const group = signal => { try { process.kill(-child.pid, signal); } catch { /* already gone */ } };
|
||||
group('SIGTERM');
|
||||
let grace; const exited = await Promise.race([done.then(() => true), new Promise(r => { grace = setTimeout(() => r(false), 10000); })]);
|
||||
clearTimeout(grace);
|
||||
if (!exited) { group('SIGKILL'); await done; }
|
||||
clearDeadLock(ctx, child.pid, supervisorStart);
|
||||
fail(unclear);
|
||||
}
|
||||
let reply = null;
|
||||
try { reply = JSON.parse(line); } catch { /* no reply */ }
|
||||
for (const w of Array.isArray(reply?.warnings) ? reply.warnings : []) if (typeof w === 'string') warn(w);
|
||||
child.unref();
|
||||
if (typeof reply?.receipt?.url !== 'string' || !Number.isSafeInteger(reply.receipt.pid)) {
|
||||
const code = [1, 2, 3, 4].includes(reply?.exitCode) ? reply.exitCode : 1;
|
||||
throw new CliError(typeof reply?.error === 'string' ? reply.error : unclear, code);
|
||||
}
|
||||
const result = { ...reply.receipt, supervisor: child.pid };
|
||||
Object.defineProperty(result, 'done', { value: done });
|
||||
Object.defineProperty(result, 'stop', { value: () => child.kill('SIGTERM') });
|
||||
return result;
|
||||
}
|
||||
// `bind` and `allowHosts` are --dev-insecure-bind and --dev-allow-host (org-lan-bind): the dev
|
||||
// owner Console on the LAN with no sign-in. Neither is set for a loopback Console.
|
||||
// In the foreground the Console runs with --supervised: its restart route exits RESTART_EXIT and
|
||||
// this process relaunches it in place (F3). A detached Console has no supervisor.
|
||||
// this process relaunches it in place (F3). A detached dev-local-owner Console runs under a
|
||||
// detached supervisor (D1); any other detached Console has none.
|
||||
export async function consoleStart(ctx, { devLocalOwner = false, detach = false, port = 'auto', bind = null, allowHosts = [], warn = s => process.stderr.write(s) } = {}) {
|
||||
const value = port === 'auto' ? 0 : Number(port);
|
||||
if (!(port === 'auto' || (typeof port === 'string' && /^\d+$/.test(port) && value >= 1 && value <= 65535 && !forbidden.has(value)))) fail('invalid or forbidden console port');
|
||||
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. 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;
|
||||
try {
|
||||
@@ -238,9 +318,10 @@ function consoleTarget(ctx) {
|
||||
const address = s.bind && s.bind !== '0.0.0.0' ? s.bind : '127.0.0.1';
|
||||
return { address, port, host: `${address}:${port}` };
|
||||
}
|
||||
function consoleCall(target, { method = 'GET', path, timeoutMs }) {
|
||||
function consoleCall(target, { method = 'GET', path, timeoutMs, body = {} }) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const headers = { host: target.host, ...(method === 'POST' ? { origin: `http://${target.host}`, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json', 'content-length': 2 } : {}) };
|
||||
const payload = method === 'POST' ? JSON.stringify(body) : undefined;
|
||||
const headers = { host: target.host, ...(method === 'POST' ? { origin: `http://${target.host}`, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload) } : {}) };
|
||||
let sent = false;
|
||||
const req = httpRequest({ host: target.address, port: target.port, method, path, headers, timeout: timeoutMs }, res => {
|
||||
let text = '';
|
||||
@@ -252,14 +333,14 @@ function consoleCall(target, { method = 'GET', path, timeoutMs }) {
|
||||
req.on('timeout', () => req.destroy(Object.assign(new Error('timed out'), { code: 'timeout' })));
|
||||
req.on('finish', () => { sent = true; });
|
||||
req.on('error', e => reject(Object.assign(e, { sent })));
|
||||
req.end(method === 'POST' ? '{}' : undefined);
|
||||
req.end(payload);
|
||||
});
|
||||
}
|
||||
// A refused connection never reached the Console. Anything else after the request left may have
|
||||
// changed something, so it is outcome-unknown and nothing is retried.
|
||||
async function consolePost(target, path, { timeoutMs, unknown }) {
|
||||
async function consolePost(target, path, { timeoutMs, unknown, body }) {
|
||||
let answer;
|
||||
try { answer = await consoleCall(target, { method: 'POST', path, timeoutMs }); }
|
||||
try { answer = await consoleCall(target, { method: 'POST', path, timeoutMs, body }); }
|
||||
catch (e) {
|
||||
if (e.code === 'ECONNREFUSED' && !e.sent) fail('the Console refused the connection; see mosaic console status');
|
||||
throw new CliError(`outcome unknown: ${unknown}`, 1);
|
||||
@@ -294,3 +375,19 @@ export async function consoleBusRestart(ctx, id, { timeoutMs = 120000 } = {}) {
|
||||
if (answer.status !== 200 || answer.body?.outcome !== 'done' || answer.body.step !== 'bus-restart') throw refusal(answer, 'org reload');
|
||||
return answer.body;
|
||||
}
|
||||
// E2 (#1873, P1-3): the owner's org page actions from a terminal (org-owner.mjs): the hire form and
|
||||
// the autonomy page, each through the running Console's own org route, sent as the page sends it.
|
||||
// The route applies its owner, --send, origin, body and live-session checks and asks the org's own
|
||||
// bus; nothing here decides or writes anything. A GET that fails changed nothing, so it is exit 1
|
||||
// with no outcome-unknown wording; a POST that fails after it left is outcome-unknown.
|
||||
export async function consoleOrgRequest(ctx, id, { method = 'GET', path, body, timeoutMs = 30000, unknown }) {
|
||||
if (typeof id !== 'string' || !ORG_ID.test(id)) throw new CliError('invalid organization id', 4);
|
||||
const target = consoleTarget(ctx);
|
||||
const route = `/api/orgs/${id}/${path}`;
|
||||
if (method === 'POST') return consolePost(target, route, { timeoutMs, unknown, body });
|
||||
try { return await consoleCall(target, { path: route, timeoutMs }); }
|
||||
catch (e) {
|
||||
if (e.code === 'ECONNREFUSED' && !e.sent) fail('the Console refused the connection; see mosaic console status');
|
||||
throw new CliError('the Console did not answer; see mosaic console status', 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
// E2 (#1873, P1-3, org ruling 6): the owner's org page actions from a terminal. Each verb sends the
|
||||
// request the WebUI page sends, to the running Console's own org route (org-console.mjs
|
||||
// consoleOrgRequest), so the same route checks the owner, --send, the origin, the body and the live
|
||||
// session, and the same bus verb runs on the organization's own bus:
|
||||
// - role-templates, hire, fire, change: the inbox hire form (public/bus.js hireSubmit), the routes
|
||||
// /api/orgs/ID/decisions/hire/templates and /start (decision-http.mjs), the bus verbs
|
||||
// hire.templates.console and hire.start.console.
|
||||
// - autonomy, autonomy set, autonomy review: the org Autonomy panel (public/autonomy.js), the
|
||||
// route /api/orgs/ID/autonomy[/review] (autonomy-http.mjs), the bus verbs autonomy.get.console,
|
||||
// autonomy.set.console (with its revision check) and autonomy.review.console.
|
||||
// No new authority: a Console that is not the owner's, not started with --send, or not signed in
|
||||
// refuses exactly as it refuses the page. Usage errors (exit 4) come first; then every verb refuses
|
||||
// inside an agent run, before any config is read, as `org archive|delete` do (agent guard #1942).
|
||||
import { configDir } from '../../organization/src/organization-config.mjs';
|
||||
import { DEV_OWNER, devOwnerInstance } from './dev-local-owner.mjs';
|
||||
import { loadSystem } from './config.mjs';
|
||||
import { CliError } from './errors.mjs';
|
||||
import { consoleOrgRequest } from './org-console.mjs';
|
||||
import { refuseInsideAgent } from './transport.mjs';
|
||||
|
||||
export const ORG_OWNER_VERBS = Object.freeze(['role-templates', 'hire', 'fire', 'change', 'autonomy']);
|
||||
export const ORG_OWNER_USAGE = 'mosaic org role-templates ID [--json] | hire|change ID --name NAME --template TEMPLATE --harness HARNESS --model MODEL [--thinking LEVEL] [--level org|project|root] [--project PROJECT] --reason TEXT [--json] | fire ID --name NAME [--level org|project|root] [--project PROJECT] --reason TEXT [--json] | autonomy ID [--json] | autonomy ID set --revision REV [--cross-role|--merge|--policy|--authority in|near|out] [--notices on|off] [--reverse-window-hours N] [--authority-resolver NAME|""] [--json] | autonomy ID review cross-role|merge|policy|authority [--json]';
|
||||
|
||||
const usage = why => new CliError(`${why}\nusage: ${ORG_OWNER_USAGE}`, 4);
|
||||
const ORG_ID = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
||||
const CLASSES = Object.freeze(['cross-role', 'merge', 'policy', 'authority']);
|
||||
const LEVELS = Object.freeze({ in: 'in the loop', near: 'near the loop', out: 'out of the loop' });
|
||||
const HIRE_VALUES = ['--name', '--template', '--harness', '--model', '--thinking', '--level', '--project', '--reason'];
|
||||
const AUTONOMY_VALUES = ['--revision', ...CLASSES.map(c => `--${c}`), '--notices', '--reverse-window-hours', '--authority-resolver'];
|
||||
|
||||
function parse(args, values) {
|
||||
const pos = [], got = {}, flags = new Set();
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const a = args[i];
|
||||
if (a === '--json') { if (flags.has(a)) throw usage('duplicate --json'); flags.add(a); }
|
||||
else if (values.includes(a)) {
|
||||
if (Object.hasOwn(got, a)) throw usage(`duplicate ${a}`);
|
||||
if (i + 1 >= args.length || args[i + 1].startsWith('--')) throw usage(`${a} needs one value`);
|
||||
got[a] = args[++i];
|
||||
} else if (a.startsWith('--')) throw usage(`unknown option ${a}`);
|
||||
else pos.push(a);
|
||||
}
|
||||
return { pos, values: got, json: flags.has('--json') };
|
||||
}
|
||||
|
||||
// The hire form's body (bus.js hireSubmit), from flags. The bus's proposal checks (hire-decisions.mjs
|
||||
// prepare) run on it unchanged; these checks only refuse what the form itself would not send.
|
||||
function hireBody(op, v) {
|
||||
const need = key => { if (!v[key]?.trim()) throw usage(`${op} needs ${key}`); return v[key]; };
|
||||
const level = v['--level'] ?? 'org';
|
||||
if (!['org', 'project', 'root'].includes(level)) throw usage('--level takes org, project or root');
|
||||
if (level === 'project' ? !v['--project']?.trim() : v['--project'] !== undefined) throw usage(level === 'project' ? '--level project needs --project' : '--project goes with --level project');
|
||||
const reason = need('--reason');
|
||||
if (reason.length > 500) throw usage('--reason takes at most 500 characters');
|
||||
if (op === 'fire') for (const k of ['--template', '--harness', '--model', '--thinking']) if (v[k] !== undefined) throw usage(`fire takes no ${k}`);
|
||||
const hire = { op, level, name: need('--name'), reason, ...(level === 'project' ? { project: v['--project'] } : {}) };
|
||||
if (op === 'fire') return { hire, template: null };
|
||||
const template = need('--template');
|
||||
const thinking = v['--thinking']?.trim();
|
||||
return { hire: { ...hire, route: { harness: need('--harness'), model: need('--model'), ...(thinking ? { thinking } : {}) } }, template };
|
||||
}
|
||||
|
||||
function autonomyChanges(v) {
|
||||
const revision = v['--revision'];
|
||||
if (revision === undefined) throw usage('autonomy set needs --revision, the revision mosaic org autonomy ID shows');
|
||||
// The panel's revision is the bus's 64-hex digest of the organization (autonomy.set.console).
|
||||
if (!/^[a-f0-9]{64}$/.test(revision)) throw usage('--revision takes the 64-hex revision mosaic org autonomy ID shows');
|
||||
const changes = {};
|
||||
for (const c of CLASSES) {
|
||||
const value = v[`--${c}`];
|
||||
if (value === undefined) continue;
|
||||
if (!Object.hasOwn(LEVELS, value)) throw usage(`--${c} takes in, near or out`);
|
||||
changes[c] = LEVELS[value];
|
||||
}
|
||||
if (v['--notices'] !== undefined) {
|
||||
if (!['on', 'off'].includes(v['--notices'])) throw usage('--notices takes on or off');
|
||||
changes.notices = v['--notices'] === 'on';
|
||||
}
|
||||
if (v['--reverse-window-hours'] !== undefined) {
|
||||
if (!/^[1-9][0-9]{0,2}$/.test(v['--reverse-window-hours']) || Number(v['--reverse-window-hours']) > 168) throw usage('--reverse-window-hours takes a whole number of hours from 1 to 168');
|
||||
changes.reverseWindowHours = Number(v['--reverse-window-hours']);
|
||||
}
|
||||
if (v['--authority-resolver'] !== undefined) changes.authorityResolver = v['--authority-resolver'];
|
||||
if (!Object.keys(changes).length) throw usage('autonomy set needs a setting to change');
|
||||
return { revision, changes };
|
||||
}
|
||||
|
||||
// The panel's save sends every class with the level it shows, notices, the reverse window (24 when
|
||||
// none is set) and the resolver when one is set (autonomy.js). The terminal sends the same document
|
||||
// with the named settings changed, so an unchanged field keeps the value the page would send.
|
||||
export function autonomyDocument(current, changes) {
|
||||
const shown = current?.autonomy ?? {};
|
||||
const doc = Object.fromEntries(CLASSES.map(c => [c, current?.levels?.[c]]));
|
||||
doc.notices = shown.notices === true;
|
||||
doc.reverseWindowHours = shown.reverseWindowHours ?? 24;
|
||||
if (shown.authorityResolver) doc.authorityResolver = shown.authorityResolver;
|
||||
const out = { ...doc, ...changes };
|
||||
// An empty --authority-resolver clears it: the panel leaves an emptied field out of its save.
|
||||
if (out.authorityResolver === '') delete out.authorityResolver;
|
||||
return out;
|
||||
}
|
||||
|
||||
// Autonomy codes carry no message from the route; these are the panel's own words (autonomy.js).
|
||||
const AUTONOMY_GUIDANCE = {
|
||||
'revision-conflict': 'the organization changed; read it again with mosaic org autonomy ID',
|
||||
'autonomy-precondition': 'this move needs a separate resolver, a tested reverse, owner notices and an enforced budget covering new agents for authority; out of the loop needs a near-loop decision followed by your review of its trail',
|
||||
'policy-reverse-unavailable': 'policy has no near-loop setting until a reverse exists',
|
||||
'authority-limits-unavailable': 'authority cannot move out of the loop',
|
||||
'owner-only': 'only the owner changes autonomy',
|
||||
'read-only': 'start the Console with --send to make changes',
|
||||
'sign-in-required': 'sign in to the Console',
|
||||
};
|
||||
// A refusal is exit 3 and changed nothing. outcome-unknown (and a 5xx the route did not name) may
|
||||
// have changed something, so it is exit 1 and nothing is retried.
|
||||
function refused(answer, what, unknown, guidance = {}) {
|
||||
const raw = answer.body?.error;
|
||||
const code = typeof raw === 'string' && /^[a-z-]{1,64}$/.test(raw) ? raw : `http ${answer.status}`;
|
||||
if (code === 'outcome-unknown' || (answer.status >= 500 && !raw)) return new CliError(`outcome unknown: ${unknown}`, 1);
|
||||
const said = typeof answer.body?.message === 'string' ? answer.body.message.slice(0, 500) : guidance[code];
|
||||
return new CliError(`${what} refused (${code})${said ? `: ${said}` : ''}`, 3);
|
||||
}
|
||||
|
||||
function ok(answer) { return answer.status === 200 && answer.body && typeof answer.body === 'object'; }
|
||||
|
||||
export async function orgOwnerCommand(args, io, deps = {}) {
|
||||
const verb = args[0];
|
||||
const autonomy = verb === 'autonomy';
|
||||
const p = parse(args.slice(1), autonomy ? AUTONOMY_VALUES : verb === 'role-templates' ? [] : HIRE_VALUES);
|
||||
const id = p.pos[0];
|
||||
if (id === undefined) throw usage(`org ${verb} takes an organization id`);
|
||||
if (!ORG_ID.test(id)) throw usage('invalid organization id');
|
||||
let run;
|
||||
if (verb === 'role-templates') {
|
||||
if (p.pos.length !== 1) throw usage('role-templates takes one organization id');
|
||||
run = async ctx => {
|
||||
const answer = await consoleOrgRequest(ctx, id, { method: 'POST', path: 'decisions/hire/templates', body: { business: id }, unknown: 'the templates read did not finish; nothing was changed' });
|
||||
if (!ok(answer) || !Array.isArray(answer.body.templates)) throw answer.status === 200 ? new CliError('the Console answered without templates', 1) : refused(answer, 'role-templates', 'the templates read did not finish; nothing was changed');
|
||||
return { value: answer.body, text: b => b.templates.length ? b.templates.map(t => `${t.name} ${String(t.sha256 ?? '').slice(0, 12)}\n`).join('') : 'role templates: none\n' };
|
||||
};
|
||||
} else if (['hire', 'fire', 'change'].includes(verb)) {
|
||||
if (p.pos.length !== 1) throw usage(`${verb} takes one organization id`);
|
||||
const { hire, template } = hireBody(verb, p.values);
|
||||
run = async ctx => {
|
||||
let body = hire;
|
||||
// The form chooses a template from the ones it read and sends its name and sha256.
|
||||
if (template !== null) {
|
||||
const read = await consoleOrgRequest(ctx, id, { method: 'POST', path: 'decisions/hire/templates', body: { business: id }, unknown: 'the templates read did not finish; nothing was changed' });
|
||||
if (!ok(read) || !Array.isArray(read.body.templates)) throw refused(read, verb, 'the templates read did not finish; nothing was changed');
|
||||
const chosen = read.body.templates.find(t => t?.name === template);
|
||||
if (!chosen) throw new CliError(`${verb}: no role template ${JSON.stringify(template)}; see mosaic org role-templates ${id}`, 2);
|
||||
const { route, ...rest } = hire;
|
||||
body = { ...rest, role: { template: chosen.name, sha256: chosen.sha256 }, route };
|
||||
}
|
||||
const unknown = `the ${verb} may have been recorded; check mosaic inbox --business ${id} and mosaic trail before trying again`;
|
||||
const answer = await consoleOrgRequest(ctx, id, { method: 'POST', path: 'decisions/hire/start', body: { business: id, hire: body }, timeoutMs: 120000, unknown });
|
||||
if (!ok(answer)) throw refused(answer, verb, unknown);
|
||||
if (typeof answer.body.id !== 'string' || answer.body.op !== 'resolved') throw new CliError(`outcome unknown: ${unknown}`, 1);
|
||||
return { value: answer.body, text: b => `recorded decision ${b.id}: ${verb} ${hire.name} (${b.op} ${b.choice})\n` };
|
||||
};
|
||||
} else if (autonomy) {
|
||||
const step = p.pos[1] ?? null;
|
||||
if (step === null) {
|
||||
if (p.pos.length !== 1 || Object.keys(p.values).length) throw usage('autonomy ID reads; use autonomy ID set to change it');
|
||||
run = async ctx => {
|
||||
const answer = await consoleOrgRequest(ctx, id, { path: 'autonomy' });
|
||||
if (!ok(answer)) throw refused(answer, 'autonomy', 'the autonomy read did not finish; nothing was changed', AUTONOMY_GUIDANCE);
|
||||
return { value: answer.body, text: formatAutonomy };
|
||||
};
|
||||
} else if (step === 'set') {
|
||||
if (p.pos.length !== 2) throw usage('autonomy ID set takes options only');
|
||||
const { revision, changes } = autonomyChanges(p.values);
|
||||
run = async ctx => {
|
||||
const read = await consoleOrgRequest(ctx, id, { path: 'autonomy' });
|
||||
if (!ok(read)) throw refused(read, 'autonomy set', 'the autonomy read did not finish; nothing was changed', AUTONOMY_GUIDANCE);
|
||||
const unknown = `the setting may have been saved; check mosaic org autonomy ${id} before trying again`;
|
||||
const answer = await consoleOrgRequest(ctx, id, { method: 'POST', path: 'autonomy', body: { revision, autonomy: autonomyDocument(read.body, changes) }, unknown });
|
||||
if (!ok(answer)) throw refused(answer, 'autonomy set', unknown, AUTONOMY_GUIDANCE);
|
||||
return { value: answer.body, text: b => `saved autonomy for ${id}: revision ${b.revision ?? 'unknown'}${b.transaction ? `, receipt ${b.transaction}` : ''}\n` };
|
||||
};
|
||||
} else if (step === 'review') {
|
||||
if (p.pos.length !== 3 || !CLASSES.includes(p.pos[2])) throw usage(`autonomy ID review takes one class: ${CLASSES.join(', ')}`);
|
||||
if (Object.keys(p.values).length) throw usage('autonomy ID review takes no options but --json');
|
||||
run = async ctx => {
|
||||
const unknown = `the review may have been recorded; check mosaic trail before trying again`;
|
||||
const answer = await consoleOrgRequest(ctx, id, { method: 'POST', path: 'autonomy/review', body: { decisionClass: p.pos[2] }, unknown });
|
||||
if (!ok(answer)) throw refused(answer, 'autonomy review', unknown, AUTONOMY_GUIDANCE);
|
||||
return { value: answer.body, text: () => `recorded your trail review for ${p.pos[2]} on ${id}\n` };
|
||||
};
|
||||
} else throw usage(`autonomy takes set or review, not ${JSON.stringify(step)}`);
|
||||
} else throw usage(`unknown org verb ${verb}`);
|
||||
// agent-guard (#1873): after the usage checks, before any config is read; this process's own
|
||||
// environment as well as io.env, as org archive|delete check.
|
||||
refuseInsideAgent(process.env);
|
||||
refuseInsideAgent(io.env);
|
||||
let instance;
|
||||
try { instance = devOwnerInstance(io.env); }
|
||||
catch (e) { throw new CliError(`${e.message}; these verbs need a named instance whose Console runs with --dev-local-owner`, 3); }
|
||||
const system = deps.system ?? loadSystem({ env: io.env, instance });
|
||||
const ctx = { configDir: configDir(io.env), dataRoot: system.dataRoot, instance, principal: DEV_OWNER };
|
||||
const { value, text } = await run(ctx);
|
||||
io.stdout.write(p.json ? `${JSON.stringify(value, null, 2)}\n` : text(value));
|
||||
}
|
||||
|
||||
function formatAutonomy(b) {
|
||||
const a = b.autonomy ?? {};
|
||||
return [
|
||||
`revision ${b.revision}${b.writable ? '' : ' (read-only: this Console takes no changes)'}`,
|
||||
...CLASSES.map(c => `${c}: ${b.levels?.[c] ?? 'unknown'}`),
|
||||
`notices: ${a.notices === true ? 'on' : 'off'}`,
|
||||
`reverse window: ${a.reverseWindowHours === undefined ? 'not set (the page offers 24 hours)' : `${a.reverseWindowHours} hours`}`,
|
||||
`authority resolver: ${a.authorityResolver ?? 'none'}`,
|
||||
].join('\n') + '\n';
|
||||
}
|
||||
@@ -1,24 +1,40 @@
|
||||
import { readFile, writeFile } from 'node:fs/promises';
|
||||
import { configDir } from '../../organization/src/organization-config.mjs';
|
||||
import { listOrgs, orgLock } from '../../organization/src/org-registry.mjs';
|
||||
import { templateRender, validateOrg, createOrg, showOrg, updateOrg, queueInit, queueShow, projectRepoInit, projectRepoShow, deleteOrg, archiveOrg, restoreOrg } from '../../organization/src/org-service.mjs';
|
||||
import { templateRender, validateOrg, createOrg, showOrg, updateOrg, queueInit, queueShow, projectRepoInit, projectRepoShow, projectRepoSet, deleteOrg, archiveOrg, restoreOrg } from '../../organization/src/org-service.mjs';
|
||||
import { installedSystemTypes } from '../../plugins/src/system-types.mjs';
|
||||
import { DEV_OWNER, devOwnerInstance } from './dev-local-owner.mjs';
|
||||
import { loadSystem } from './config.mjs';
|
||||
import { CliError } from './errors.mjs';
|
||||
import { busStopForDelete } from './org-bus.mjs';
|
||||
import { busStart, busStopForDelete } from './org-bus.mjs';
|
||||
import { assertBuildStart, assertDevInstance } from './deployment.mjs';
|
||||
import { consoleBusRestart } from './org-console.mjs';
|
||||
import { refuseInsideAgent } from './transport.mjs';
|
||||
import { definitionsCommand, DEFINITIONS_USAGE } from './org-definitions.mjs';
|
||||
export const ORG_USAGE = 'mosaic org system-types installed | system-types enabled ID | lock show|clear ID | reload ID | archive ID --if-revision N [--json] | restore ID --if-revision N [--json] | delete ID --confirm ID [--if-revision N] [--json] | template render ID [--fixture local] --output FILE | validate FILE | create ID --from FILE | show ID [--json] | list [--json] | update ID --from FILE --if-revision N | queue init ID (--local-only --accept-local-loss-risk | --remote URL) | queue show ID [--json] | project repo init ID PROJECT (--local-only --accept-local-loss-risk | --remote URL) | project repo show ID PROJECT [--json] | ' + DEFINITIONS_USAGE.slice('mosaic org '.length) + ' | config validate ID | config resolve ID INSTANCE [--project ID]';
|
||||
function parse(args) { const pos = [], values = {}, flags = new Set(); for (let i = 0; i < args.length; i++) { const a = args[i]; if (['--json', '--local-only', '--accept-local-loss-risk'].includes(a)) { if (flags.has(a)) throw new CliError(`duplicate ${a}`, 4); flags.add(a); } else if (['--fixture', '--output', '--from', '--if-revision', '--remote', '--confirm'].includes(a)) { if (values[a] !== undefined || !args[i+1] || args[i+1].startsWith('--')) throw new CliError(`${a} needs one value`, 4); values[a] = args[++i]; } else if (a.startsWith('--')) throw new CliError(`unknown option ${a}`, 4); else pos.push(a); } return { pos, values, flags }; }
|
||||
import { orgOwnerCommand, ORG_OWNER_USAGE, ORG_OWNER_VERBS } from './org-owner.mjs';
|
||||
export const ORG_USAGE = 'mosaic org system-types installed | system-types enabled ID | lock show|clear ID | reload ID | archive ID --if-revision N [--json] | restore ID --if-revision N [--no-bus] [--json] | delete ID --confirm ID [--if-revision N] [--json] | template render ID [--fixture local] --output FILE | validate FILE | create ID --from FILE | show ID [--json] | list [--json] | update ID --from FILE --if-revision N | queue init ID (--local-only --accept-local-loss-risk | --remote URL) | queue show ID [--json] | project repo init ID PROJECT (--local-only --accept-local-loss-risk | --remote URL) | project repo show ID PROJECT [--json] | project repo set ID PROJECT (--local-only --accept-local-loss-risk | --remote URL) --if-revision N [--json] | ' + DEFINITIONS_USAGE.slice('mosaic org '.length) + ' | ' + ORG_OWNER_USAGE.slice('mosaic org '.length) + ' | config validate ID | config resolve ID INSTANCE [--project ID]';
|
||||
function parse(args) { const pos = [], values = {}, flags = new Set(); for (let i = 0; i < args.length; i++) { const a = args[i]; if (['--json', '--local-only', '--accept-local-loss-risk', '--no-bus'].includes(a)) { if (flags.has(a)) throw new CliError(`duplicate ${a}`, 4); flags.add(a); } else if (['--fixture', '--output', '--from', '--if-revision', '--remote', '--confirm'].includes(a)) { if (values[a] !== undefined || !args[i+1] || args[i+1].startsWith('--')) throw new CliError(`${a} needs one value`, 4); values[a] = args[++i]; } else if (a.startsWith('--')) throw new CliError(`unknown option ${a}`, 4); else pos.push(a); } return { pos, values, flags }; }
|
||||
// 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 the organization's bus (E1, P1-4),
|
||||
// so it is refused the same way unless --no-bus is given; `restore --no-bus` starts nothing.
|
||||
function refuseAgentRun(io) { refuseInsideAgent(process.env); refuseInsideAgent(io.env); }
|
||||
// E1 (P1-4): the bus a restore starts is the one `mosaic bus start ID --dev-local-owner --detach`
|
||||
// starts, after that verb's checks. The checks run before the restore, so a refusal changes nothing.
|
||||
function restoreBusCheck(system, env) {
|
||||
try { assertBuildStart({ devOwner: true }); assertDevInstance(system, env); }
|
||||
catch (e) { throw new CliError(`restore starts the bus as the local dev owner, and ${e.message}; restore with --no-bus, then start the bus with mosaic bus start`, 3); }
|
||||
}
|
||||
const busStartVerb = id => `mosaic bus start ${id} --dev-local-owner --detach`;
|
||||
export async function orgCommand(args, io, deps = {}) {
|
||||
try {
|
||||
// #1873 T1: the definitions verbs take their own options.
|
||||
if (args[0] === 'definitions') return await definitionsCommand(args.slice(1), io, deps);
|
||||
// E2 (#1873, P1-3): the hire form and the autonomy panel, through the running Console (org-owner.mjs).
|
||||
if (ORG_OWNER_VERBS.includes(args[0])) return await orgOwnerCommand(args, io, deps);
|
||||
const p = parse(args); const words = p.pos; let result; let accepted = [];
|
||||
const allowed = (flags, values) => { for (const f of p.flags) if (!flags.includes(f)) throw new CliError(`unexpected ${f}`, 4); for (const f of Object.keys(p.values)) if (!values.includes(f)) throw new CliError(`unexpected ${f}`, 4); };
|
||||
const need = key => { if (!p.values[key]) throw new CliError(`${key} is required`, 4); return p.values[key]; };
|
||||
@@ -26,14 +42,35 @@ 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', ...(words[0] === 'restore' ? ['--no-bus'] : [])], ['--if-revision']); lifecycle = { ifRevision: revision(p.values['--if-revision'], true) }; if (words[0] === 'archive' || !p.flags.has('--no-bus')) 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 (words[0] === 'archive' && words.length === 2) result = await archiveOrg(ctx, words[1], { ...lifecycle, busStop: busStopForDelete });
|
||||
else if (words[0] === 'restore' && words.length === 2) {
|
||||
const startBus = !p.flags.has('--no-bus');
|
||||
if (startBus) restoreBusCheck(system, io.env);
|
||||
result = await restoreOrg(ctx, words[1], lifecycle);
|
||||
// The restore stands whatever the bus does. A bus that does not start is said so, with the verb
|
||||
// that starts it, and the command exits 3 after printing the restore receipt.
|
||||
if (startBus) {
|
||||
try { const started = await (deps.busStart ?? busStart)({ ...ctx, env: io.env }, words[1], { detach: true, devLocalOwner: true, pm: false }); result = { ...result, bus: { outcome: 'started', pid: started.pid } }; }
|
||||
catch (e) {
|
||||
// A host that died left its lock behind; `bus start` refuses it until `bus recover` clears it.
|
||||
const message = e instanceof CliError ? e.message : 'the bus did not start', dead = /^stale /.test(message);
|
||||
result = { ...result, bus: { outcome: 'failed', message, next: dead ? `mosaic bus recover ${words[1]}; ${busStartVerb(words[1])}` : busStartVerb(words[1]) } };
|
||||
io.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
||||
throw new CliError(`${words[1]} was restored, but its bus did not start: ${message}; ${dead ? `clear the dead host with mosaic bus recover ${words[1]}, then start it with` : 'start it with'} ${busStartVerb(words[1])}`, 3);
|
||||
}
|
||||
}
|
||||
}
|
||||
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 ?? {}); }
|
||||
@@ -43,6 +80,9 @@ export async function orgCommand(args, io, deps = {}) {
|
||||
const project = words[0] === 'project', op = words[project ? 2 : 1], id = words[project ? 3 : 2], name = project ? words[4] : null;
|
||||
if (op === 'show') { allowed(['--json'], []); result = project ? await projectRepoShow(ctx, id, name) : await queueShow(ctx, id); }
|
||||
else if (op === 'init') { allowed(['--local-only', '--accept-local-loss-risk'], ['--remote']); if (p.flags.has('--local-only') === Boolean(p.values['--remote'])) throw new CliError('select exactly one of --local-only or --remote', 4); const opts = { mode: p.flags.has('--local-only') ? 'local-only' : 'remote', remote: p.values['--remote'], acceptLocalLossRisk: p.flags.has('--accept-local-loss-risk') }; result = project ? await projectRepoInit(ctx, id, name, opts) : await queueInit(ctx, id, opts); }
|
||||
// #1873 P1-7: change the project's repository (remote URL, or remote to local-only and back).
|
||||
// The same URL rules and check as init; origin follows; nothing is pushed.
|
||||
else if (op === 'set' && project) { allowed(['--json', '--local-only', '--accept-local-loss-risk'], ['--remote', '--if-revision']); if (p.flags.has('--local-only') === Boolean(p.values['--remote'])) throw new CliError('select exactly one of --local-only or --remote', 4); const ifRevision = revision(p.values['--if-revision'], true); result = await projectRepoSet(ctx, id, name, { mode: p.flags.has('--local-only') ? 'local-only' : 'remote', remote: p.values['--remote'], acceptLocalLossRisk: p.flags.has('--accept-local-loss-risk'), ifRevision }); }
|
||||
else throw new CliError(ORG_USAGE, 4);
|
||||
} else throw new CliError(ORG_USAGE, 4);
|
||||
io.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
||||
|
||||
@@ -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,104 @@
|
||||
// E2 (#1873, P1-3, org ruling 6): the task board's writes from a terminal. `mosaic tasks create`,
|
||||
// `edit` and `archive` send the board's three ops (workspace-tasks.mjs) to the organization's bus as
|
||||
// the person, through the human transport (U4a: the bus's tasks.create, tasks.edit and tasks.archive
|
||||
// are the person's writes "from the terminal (human-cli)"). The arguments pass the board's own
|
||||
// check, parseTaskWrite, before anything is sent, so the terminal takes exactly what the board
|
||||
// takes; the bus then runs its own human, field, digest and archive checks, as it does for the
|
||||
// board. Writes take no --tester and refuse inside an agent run (callFor), and the bus refuses any
|
||||
// caller that is not the person (human-required).
|
||||
import { parseTaskWrite } from '../../webui/src/workspace-tasks.mjs';
|
||||
import { CliError } from './errors.mjs';
|
||||
import { orgSelector } from './org-selector.mjs';
|
||||
import { refuseInsideAgent } from './transport.mjs';
|
||||
|
||||
export const TASKS_WRITE_OPS = Object.freeze(['create', 'edit', 'archive']);
|
||||
export const TASKS_WRITE_USAGE = 'mosaic tasks create --title TEXT --requirement REQ-X-N [--project NAME] [--state STATE] [--assignees ROLE,...] [--priority 0-5] [--due ISO|none] --business <id> [--json]\n' +
|
||||
' mosaic tasks edit task:N --expect DIGEST [--title TEXT] [--state STATE] [--assignees ROLE,...] [--priority 0-5] [--due ISO|none] --business <id> [--json]\n' +
|
||||
' mosaic tasks archive task:N --expect DIGEST --business <id> [--json]';
|
||||
|
||||
const usage = why => new CliError(`${why}\nusage: ${TASKS_WRITE_USAGE}`, 4);
|
||||
// Each op's options, as the board's op takes its keys (workspace-tasks.mjs OPS).
|
||||
const OPTIONS = {
|
||||
create: ['--title', '--requirement', '--project', '--state', '--assignees', '--priority', '--due'],
|
||||
edit: ['--expect', '--title', '--state', '--assignees', '--priority', '--due'],
|
||||
archive: ['--expect'],
|
||||
};
|
||||
const KEY = { '--title': 'title', '--requirement': 'requirement', '--project': 'group', '--state': 'state', '--assignees': 'assignees', '--priority': 'priority', '--due': 'due_date', '--expect': 'expect' };
|
||||
// The board's answer when the bus refuses, by code (workspace-tasks.mjs REFUSALS), so the terminal
|
||||
// reads the same next step.
|
||||
const GUIDANCE = {
|
||||
'human-required': 'the bus takes task changes only from a person; nothing was written',
|
||||
'tracker-not-store': 'this organization keeps its tasks in Vikunja; nothing was written',
|
||||
'task-conflict': 'the task changed after you read it; nothing was written. Read it again with mosaic tasks --json',
|
||||
'task-archived': 'the task is archived; nothing was written',
|
||||
'task-already-archived': 'the task was already archived; nothing was written',
|
||||
'task-not-found': 'the task is not on this organization\'s bus; nothing was written',
|
||||
'task-position-full': 'this project has no room for another task position; nothing was written',
|
||||
'invalid-state': 'the bus does not have that state; nothing was written',
|
||||
'unknown-role': 'an assignee is not a role in this organization; nothing was written',
|
||||
'invalid-request': 'the bus refused the change as invalid; nothing was written',
|
||||
'credential-leak': 'the change holds part of a stored credential, so the bus refused it; nothing was written',
|
||||
};
|
||||
|
||||
export function parseTasksWrite(args) {
|
||||
const op = args[0];
|
||||
if (!TASKS_WRITE_OPS.includes(op)) throw usage(`tasks takes create, edit or archive, not ${JSON.stringify(op)}`);
|
||||
const selector = orgSelector(usage);
|
||||
const values = {}, positional = [];
|
||||
let json = false;
|
||||
for (let i = 1; i < args.length; i++) {
|
||||
const a = args[i];
|
||||
if (selector.owns(a)) { selector.take(a, args[i + 1]); i++; }
|
||||
else if (a === '--json') { if (json) throw usage('duplicate --json'); json = true; }
|
||||
else if (OPTIONS[op].includes(a)) {
|
||||
if (Object.hasOwn(values, a)) throw usage(`duplicate ${a}`);
|
||||
if (i + 1 >= args.length || args[i + 1].startsWith('--')) throw usage(`${a} needs one value`);
|
||||
values[a] = args[++i];
|
||||
} else if (a.startsWith('--')) throw usage(`tasks ${op} does not take ${a}`);
|
||||
else positional.push(a);
|
||||
}
|
||||
// The organization is named before anything else is checked, so a missing --business is a usage
|
||||
// error (exit 4) inside an agent run too, as for the org owner verbs.
|
||||
if (!selector.given) throw usage('name the organization with --business');
|
||||
if (positional.length !== (op === 'create' ? 0 : 1)) throw usage(op === 'create' ? 'tasks create takes no task' : `tasks ${op} takes one task, task:N`);
|
||||
const body = op === 'create' ? {} : { task_ref: positional[0] };
|
||||
for (const [flag, raw] of Object.entries(values)) {
|
||||
const key = KEY[flag];
|
||||
if (key === 'assignees') body[key] = raw === '' ? [] : raw.split(',').map(r => r.trim());
|
||||
else if (key === 'priority') body[key] = /^[0-9]$/.test(raw) ? Number(raw) : raw;
|
||||
else if (key === 'due_date') body[key] = raw === 'none' ? null : raw;
|
||||
else body[key] = raw;
|
||||
}
|
||||
// The board's own check. Its org is the page's; here the organization is checked by the selector
|
||||
// and the bus, so a fixed valid id stands in for it.
|
||||
let write;
|
||||
try { write = parseTaskWrite({ op, org: 'terminal', ...body }); }
|
||||
catch (e) { if (typeof e?.status === 'number') throw usage(`tasks ${op}: ${e.message}`); throw e; }
|
||||
return { op, verb: write.verb, args: write.args, json, business: selector.given ? selector.value : undefined };
|
||||
}
|
||||
|
||||
export async function tasksWriteCommand(args, io, callFor) {
|
||||
const write = parseTasksWrite(args);
|
||||
// agent-guard (#1873): this process's own environment as well as io.env (callFor checks io.env),
|
||||
// as the org owner verbs check.
|
||||
refuseInsideAgent(process.env);
|
||||
const parsed = { positional: [], flags: new Set(), values: write.business === undefined ? {} : { '--business': write.business } };
|
||||
const call = callFor(parsed);
|
||||
let result;
|
||||
try { result = await call(write.verb, write.args); }
|
||||
catch (e) {
|
||||
if (e?.code === 'outcome-unknown' || e?.exitCode === 1) throw new CliError(`outcome unknown: the task change may or may not have been written; read it with mosaic tasks --json before trying again`, 1);
|
||||
if (typeof e?.code === 'string' && Object.hasOwn(GUIDANCE, e.code)) {
|
||||
const refused = new CliError(`tasks ${write.op} refused (${e.code}): ${GUIDANCE[e.code]}`, e.exitCode ?? 2);
|
||||
refused.code = e.code;
|
||||
throw refused;
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
const taskRef = typeof result?.task_ref === 'string' ? result.task_ref : write.args.task_ref ?? null;
|
||||
const digest = typeof result?.digest === 'string' ? result.digest : null;
|
||||
// The board's answer shape (workspace-tasks.mjs), with the organization the call went to.
|
||||
const answer = { ok: true, org: parsed.business ?? write.business ?? null, op: write.op, task_ref: taskRef, digest };
|
||||
const done = { create: 'created', edit: 'changed', archive: 'archived' }[write.op];
|
||||
io.stdout.write(write.json ? `${JSON.stringify(answer, null, 2)}\n` : `${done} ${taskRef}${digest ? `, digest ${digest}` : ''}\n`);
|
||||
}
|
||||
@@ -91,7 +91,9 @@ test("R6 B3 a boot with a file R1's loader refuses still boots, binds no user an
|
||||
const s = await boot(t, file);
|
||||
assert.equal(s.config.access.file, s.path, why);
|
||||
assert.equal(s.bind("alice"), "access-revoked", why);
|
||||
assert.deepEqual(s.warnings, [TRACKER, `access file refused at boot (${code}), no granted user binds until it is fixed: ${s.path}`], why);
|
||||
// #1873: the warning names access.json by its place, never by host path.
|
||||
assert.deepEqual(s.warnings, [TRACKER, `access file refused at boot (${code}), no granted user binds until it is fixed: access.json in the config directory`], why);
|
||||
assert.ok(!s.warnings.join("\n").includes(s.path), why);
|
||||
assert.doesNotMatch(s.warnings.join("\n"), /secret-marker/, why);
|
||||
}
|
||||
});
|
||||
@@ -9,7 +9,7 @@ import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { businessDoc, launchDoc, tokenFile, writeJson } from "../../organization/tests/helpers.mjs";
|
||||
@@ -23,6 +23,11 @@ import { bootConfig, loadSystem } from "../src/config.mjs";
|
||||
import { startTimeOf } from "../src/host.mjs";
|
||||
import { createLauncher } from "../src/launcher.mjs";
|
||||
import { fakeHarnesses, fixture, tmp, withoutHostCredentials } from "./helpers.mjs";
|
||||
import { fakeGiteaApi } from "../../bus/tests/fake-gitea-api.mjs";
|
||||
import { send as giteaSend } from "../../bus/src/gitea-grant.mjs";
|
||||
import { organizationWriter } from "../../organization/src/organization.mjs";
|
||||
import { activateOrg, reserveOrg } from "../../organization/src/org-registry.mjs";
|
||||
import { copyBotLogin } from "../../organization/src/copy-bindings.mjs";
|
||||
|
||||
const FAKE = fileURLToPath(new URL("../../harness/tests/fixtures/fake-adapter.mjs", import.meta.url));
|
||||
const skip = sandboxProblem() ?? false;
|
||||
@@ -81,10 +86,10 @@ function prepare(t, edit = (doc) => doc) {
|
||||
return { root, f, adapter };
|
||||
}
|
||||
|
||||
async function start(t, { f, adapter }, { businessId = "acme", orgRoot } = {}) {
|
||||
async function start(t, { f, adapter }, { businessId = "acme", orgRoot, copyTransport = null, beforeBind = null } = {}) {
|
||||
const system = loadSystem({ env: f.env });
|
||||
const { trackers, ...boot } = bootConfig({ system, businessId, env: f.env, ...(orgRoot ? { orgRoot } : {}) });
|
||||
const runtime = await startBroker(boot);
|
||||
const runtime = await startBroker({ ...boot, ...(copyTransport ? { copyTransport } : {}) });
|
||||
const dataRoot = boot.dataRoot;
|
||||
mkdirSync(join(dataRoot, "bus-host"), { recursive: true, mode: 0o700 });
|
||||
const ops = {
|
||||
@@ -104,6 +109,7 @@ async function start(t, { f, adapter }, { businessId = "acme", orgRoot } = {}) {
|
||||
lifecycleTake: (m) => runtime.lifecycleTake(m.record),
|
||||
lifecycleDelete: (m) => runtime.lifecycleDelete(m.record),
|
||||
lifecycleDone: (m) => runtime.lifecycleDone(m.record),
|
||||
copyLaunch: (m) => runtime.copyLaunch(m),
|
||||
};
|
||||
const refusing = (what, fn) => async (m) => {
|
||||
try {
|
||||
@@ -123,8 +129,11 @@ async function start(t, { f, adapter }, { businessId = "acme", orgRoot } = {}) {
|
||||
if (!Object.hasOwn(ops, m.op)) throw new BusError("invalid-host-request");
|
||||
return ops[m.op](m);
|
||||
}),
|
||||
bindLaunch: refusing("launch bind", (r) => runtime.bindLaunch(r)),
|
||||
bindLaunch: beforeBind
|
||||
? async (r) => { await beforeBind(r); return refusing("launch bind", (x) => runtime.bindLaunch(x))(r); }
|
||||
: refusing("launch bind", (r) => runtime.bindLaunch(r)),
|
||||
beforeClose: (fn) => void hooks.push(fn),
|
||||
copyBound: runtime.copyBound,
|
||||
};
|
||||
const logs = [];
|
||||
const launcher = createLauncher({
|
||||
@@ -593,3 +602,157 @@ test("A3: a second organization boots its own copies from the same root file and
|
||||
assert.equal(sessions(b.dataRoot).includes(run.run), false);
|
||||
assert.equal(b.running("gate").length, 0);
|
||||
});
|
||||
|
||||
// #1873 CB3: the researcher copy (research, gitea alone) bound and granted on a fake Gitea, through
|
||||
// the real bus, its boot check and the launcher. `sent` is every request the bus sends to the service.
|
||||
async function boundCopy(t) {
|
||||
const api = await fakeGiteaApi(t, tmp(t, "cb3-api-"));
|
||||
const p = prepare(t, (doc) => {
|
||||
doc.projects.stack.remote = `${api.url}/owner/stack`;
|
||||
return doc;
|
||||
});
|
||||
const ctx = { configDir: join(p.root, "config"), dataRoot: p.f.dataRoot, instance: "test" };
|
||||
chmodSync(ctx.configDir, 0o700);
|
||||
await reserveOrg(ctx, "acme");
|
||||
await activateOrg(ctx, "acme");
|
||||
const sent = [];
|
||||
const copyTransport = { ca: api.ca, send: (req) => { sent.push(`${req.method} ${req.path.split("?")[0]}`); return giteaSend(req); } };
|
||||
const repo = api.addRepo("owner", "stack");
|
||||
const writer = organizationWriter({ dir: join(p.root, "config"), rolesDir: p.f.roles });
|
||||
const rec = () => writer.read("acme").document.copyBindings?.researcher;
|
||||
const day = (n) => new Date(Date.now() + n * 86400000).toISOString().slice(0, 10);
|
||||
// Bind (the owner's console) and issue (the human), on a bus that is already up.
|
||||
const bind = async (s) => {
|
||||
const owner = s.runtime.broker.bindConsole({ business: "acme", human: p.f.doc.human, subject: { provider: "test", iss: "https://issuer.test", sub: "owner" } });
|
||||
await s.runtime.broker.request(owner, { verb: "hire.start.console", args: { op: "change", level: "root", name: "researcher", reason: "Bind the copy",
|
||||
binding: { services: { gitea: { scopes: ["read:repository", "read:user"], projects: ["stack"] } } } } });
|
||||
const r = rec();
|
||||
const login = copyBotLogin(r.orgIncarnation, r.copyId, "gitea");
|
||||
api.addUser(login);
|
||||
repo.collaborators.push(login); repo.permissions[login] = "read";
|
||||
await s.call("credentials.issue", { agent: "researcher", service: "gitea", date: day(30), token: api.addToken(login, "t1", ["read:repository", "read:user"]).sha1 });
|
||||
return login;
|
||||
};
|
||||
return { ...p, api, sent, copyTransport, rec, bind };
|
||||
}
|
||||
|
||||
const launchEvents = (s) => existsSync(launchLogFile(s.dataRoot, "acme"))
|
||||
? readFileSync(launchLogFile(s.dataRoot, "acme"), "utf8").trim().split("\n").map((l) => JSON.parse(l)) : [];
|
||||
// One message to a new researcher thread, and the launcher's tick that takes it: the launch or the refusal.
|
||||
async function threadLaunch(s, name) {
|
||||
const project = s.call("project.list").find((x) => x.name === "stack work")?.id ?? s.call("project.create", { name: "stack work", repo: "stack" }).id;
|
||||
const thread = s.call("thread.create", { project, name, instance: "researcher" }).id;
|
||||
s.call("thread.send", { thread, body: "hello" });
|
||||
await s.launcher.tick();
|
||||
return until(() => launchEvents(s).find((e) => e.thread === thread && ["launch", "refused"].includes(e.event)));
|
||||
}
|
||||
|
||||
test("CB3: a binding approved after boot answers restart-required; after a restart the bus proves it live, and the launcher carries one launch authority to bind", { skip, timeout: 120000 }, async (t) => {
|
||||
const p = await boundCopy(t);
|
||||
const first = await start(t, p, { copyTransport: p.copyTransport });
|
||||
assert.deepEqual(first.runtime.copyBound, {});
|
||||
const login = await p.bind(first);
|
||||
assert.equal(p.rec().state, "bound");
|
||||
// The issue's own check (CB2) is the last request this bus sends for the copy.
|
||||
const issued = p.sent.length;
|
||||
assert.ok(issued > 0);
|
||||
// Bound and granted after this boot: restart-required at the verb, at the bus's prepare, and at its admission.
|
||||
assert.equal(refusedAt(first, "researcher").code, "restart-required");
|
||||
const revision = organizationWriter({ dir: join(p.root, "config"), rolesDir: p.f.roles }).read("acme").revision;
|
||||
// A copy launch carries the revision its bundle came from; without one it is not a request.
|
||||
await assert.rejects(first.runtime.copyLaunch({ business: "acme", instance: "researcher" }), (e) => e.code === "invalid-request");
|
||||
await assert.rejects(first.runtime.copyLaunch({ business: "acme", instance: "researcher", revision }), (e) => e.code === "restart-required");
|
||||
assert.equal(first.runtime.admission({ business: "acme", projects: [], instance: "researcher", revision }).code, "copy-authority-changed");
|
||||
assert.deepEqual(first.runtime.admission({ business: "acme", projects: [], instance: "researcher", revision, authority: "0".repeat(64) }), { ready: false, revision: null, code: "restart-required" });
|
||||
assert.equal(p.sent.length, issued);
|
||||
await first.close();
|
||||
|
||||
// The next boot proves the slot live as the copy's account, and the launcher sees it bound.
|
||||
const s = await start(t, p, { copyTransport: p.copyTransport });
|
||||
assert.deepEqual(s.runtime.copyBound, { researcher: ["gitea"] });
|
||||
assert.ok(p.sent.slice(issued).includes("GET /api/v1/token"));
|
||||
assert.ok(p.api.requests.filter((e) => e.path === "/api/v1/token").every((e) => e.as === login));
|
||||
// Final admission with an empty project list still needs the authority.
|
||||
const rev2 = organizationWriter({ dir: join(p.root, "config"), rolesDir: p.f.roles }).read("acme").revision;
|
||||
assert.equal(s.runtime.admission({ business: "acme", projects: [], instance: "researcher", revision: rev2 }).code, "copy-authority-changed");
|
||||
const { authority } = await s.runtime.copyLaunch({ business: "acme", instance: "researcher", revision: rev2 });
|
||||
assert.match(authority, /^[0-9a-f]{64}$/);
|
||||
assert.deepEqual(s.runtime.admission({ business: "acme", projects: [], instance: "researcher", revision: rev2, authority }), { ready: true, revision: null, code: null });
|
||||
// Any other instance has no authority to carry.
|
||||
assert.deepEqual(await s.runtime.copyLaunch({ business: "acme", instance: "gate" }), { authority: null, services: [] });
|
||||
|
||||
const before = p.sent.length;
|
||||
const launched = await threadLaunch(s, "one");
|
||||
assert.equal(launched.event, "launch", JSON.stringify(launched));
|
||||
// The launcher's prepare proved the slot live again at launch.
|
||||
assert.ok(p.sent.slice(before).includes("GET /api/v1/token"));
|
||||
assert.equal(s.running("researcher").length, 1);
|
||||
assert.equal(p.sent.filter((x) => x.includes("/api/v1/repos/")).length, 0);
|
||||
});
|
||||
|
||||
// A reissue is a new generation: the slot the launch proved is not the slot it would use.
|
||||
const reissue = (p, s, login, name) => s.call("credentials.issue", { agent: "researcher", service: "gitea",
|
||||
date: new Date(Date.now() + 30 * 86400000).toISOString().slice(0, 10), token: p.api.addToken(login, name, ["read:repository", "read:user"]).sha1 });
|
||||
const restarted = async (t, p, opts = {}) => {
|
||||
const first = await start(t, p, { copyTransport: p.copyTransport });
|
||||
const login = await p.bind(first);
|
||||
await first.close();
|
||||
return { login, s: await start(t, p, { copyTransport: p.copyTransport, ...opts }) };
|
||||
};
|
||||
const repoCalls = (p) => p.sent.filter((x) => x.includes("/api/v1/repos/")).length;
|
||||
|
||||
test("CB3 race: a reissue while the launcher's live proof is in flight refuses the launch restart-required; nothing runs and no credential call is made", { skip, timeout: 120000 }, async (t) => {
|
||||
const p = await boundCopy(t);
|
||||
const { login, s } = await restarted(t, p);
|
||||
let go, arrived;
|
||||
const held = new Promise((r) => { arrived = r; });
|
||||
const unrule = p.api.rule((e) => e.path === "/api/v1/token", async () => { unrule(); arrived(); await new Promise((r) => { go = r; }); return { pass: true }; });
|
||||
const launching = threadLaunch(s, "raced");
|
||||
await held;
|
||||
await reissue(p, s, login, "t2");
|
||||
go();
|
||||
const event = await launching;
|
||||
assert.equal(event.event, "refused", JSON.stringify(event));
|
||||
assert.equal(event.code, "restart-required");
|
||||
assert.equal(s.running("researcher").length, 0);
|
||||
assert.equal(repoCalls(p), 0);
|
||||
});
|
||||
|
||||
test("CB3 race: a reissue between the launcher's admission and bind refuses at bind; the spawned run is stopped and makes no credential call", { skip, timeout: 120000 }, async (t) => {
|
||||
const p = await boundCopy(t);
|
||||
let s, login, raced = 0;
|
||||
({ login, s } = await restarted(t, p, { beforeBind: async (r) => { if (r.role === "researcher" && raced++ === 0) await reissue(p, s, login, "t3"); } }));
|
||||
const event = await threadLaunch(s, "bindrace");
|
||||
assert.equal(raced, 1);
|
||||
await until(() => s.running("researcher").length === 0);
|
||||
assert.equal(repoCalls(p), 0);
|
||||
const events = launchEvents(s).filter((e) => e.instance === "researcher");
|
||||
// The launcher reports any bind refusal as launch-failed, naming the bus's code.
|
||||
assert.equal(event.event, "refused", JSON.stringify(event));
|
||||
assert.ok(events.some((e) => e.event === "refused" && e.why === "launch bind refused: restart-required"), JSON.stringify(events));
|
||||
assert.ok(!events.some((e) => e.event === "launch"), JSON.stringify(events));
|
||||
});
|
||||
|
||||
test("CB3 race: an organization file change between the launcher's admission and bind refuses at bind, even with the copy authority unchanged; the spawned run is stopped and makes no credential call", { skip, timeout: 120000 }, async (t) => {
|
||||
const p = await boundCopy(t);
|
||||
const file = join(p.root, "config", "businesses", "acme.json");
|
||||
let s, raced = 0, authority = null;
|
||||
// Same document, other bytes: the revision moves, the launch authority does not.
|
||||
({ s } = await restarted(t, p, { beforeBind: async (r) => {
|
||||
if (r.role !== "researcher" || raced++ !== 0) return;
|
||||
authority = r.copyAuthority;
|
||||
writeFileSync(file, JSON.stringify(JSON.parse(readFileSync(file, "utf8"))) + "\n", { mode: 0o600 });
|
||||
} }));
|
||||
const event = await threadLaunch(s, "revrace");
|
||||
assert.equal(raced, 1);
|
||||
assert.match(authority, /^[0-9a-f]{64}$/);
|
||||
await until(() => s.running("researcher").length === 0);
|
||||
assert.equal(repoCalls(p), 0);
|
||||
// The refusal was the revision's: on the file as it stands the same authority comes back.
|
||||
const fresh = organizationWriter({ dir: join(p.root, "config"), rolesDir: p.f.roles }).read("acme").revision;
|
||||
assert.equal((await s.runtime.copyLaunch({ business: "acme", instance: "researcher", revision: fresh })).authority, authority);
|
||||
const events = launchEvents(s).filter((e) => e.instance === "researcher");
|
||||
assert.equal(event.event, "refused", JSON.stringify(event));
|
||||
assert.ok(events.some((e) => e.event === "refused" && e.why === "launch bind refused: organization-changed"), JSON.stringify(events));
|
||||
assert.ok(!events.some((e) => e.event === "launch"), JSON.stringify(events));
|
||||
});
|
||||
@@ -0,0 +1,287 @@
|
||||
// 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'],
|
||||
// E1 (P1-4): restore starts the organization's bus unless --no-bus is given.
|
||||
['org', 'restore', 'alpha', '--if-revision', '1'],
|
||||
// E2 (#1873, P1-3): the hire form and the autonomy panel from a terminal, reads included.
|
||||
['org', 'role-templates', 'alpha'],
|
||||
['org', 'hire', 'alpha', '--name', 'ada', '--template', 'worker', '--harness', 'pi', '--model', 'glm', '--reason', 'r'],
|
||||
['org', 'change', 'alpha', '--name', 'ada', '--template', 'worker', '--harness', 'pi', '--model', 'glm', '--reason', 'r'],
|
||||
['org', 'fire', 'alpha', '--name', 'ada', '--reason', 'r'],
|
||||
['org', 'autonomy', 'alpha'],
|
||||
['org', 'autonomy', 'alpha', 'set', '--revision', 'a'.repeat(64), '--merge', 'near'],
|
||||
['org', 'autonomy', 'alpha', 'review', 'merge'],
|
||||
];
|
||||
|
||||
// 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', 'restore', 'alpha'], ['org', 'restore', 'alpha', '--if-revision', '1', '--no-bus', '--no-bus'], ['org', 'delete', 'alpha'], ['org', 'delete', 'alpha', '--confirm', 'alpha', '--if-revision', 'x'], ['org', 'hire', 'alpha', '--name', 'ada'], ['org', 'fire', 'alpha'], ['org', 'autonomy', 'alpha', 'set', '--merge', 'near'], ['org', 'autonomy', 'alpha', 'review'], ['org', 'role-templates']]) {
|
||||
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');
|
||||
// E1 (P1-4): restore starts the bus, so an agent run is refused before anything changes, and
|
||||
// `--no-bus` (which starts nothing) is the only restore an agent run may make.
|
||||
const archivedRevision = JSON.parse(archived.stdout).revision;
|
||||
for (const marker of ['CLAUDECODE', 'MOSAIC_AGENT_RUN']) {
|
||||
const r = await mosaic({ ...env, [marker]: '1' }, ['org', 'restore', 'alpha', '--if-revision', String(archivedRevision), '--json']);
|
||||
assert.equal(r.status, 3, r.stderr); assert.match(r.stderr, REFUSED(marker)); assert.equal(r.stdout, '');
|
||||
}
|
||||
const listed = await mosaic(env, ['org', 'list', '--json']); assert.equal(listed.status, 0, listed.stderr);
|
||||
assert.equal(JSON.parse(listed.stdout).find(o => o.id === 'alpha').state, 'archived', 'the refused restore changed nothing');
|
||||
const quiet = await mosaic(agent, ['org', 'restore', 'alpha', '--if-revision', String(archivedRevision), '--no-bus', '--json']);
|
||||
assert.equal(quiet.status, 0, quiet.stderr); assert.equal(JSON.parse(quiet.stdout).bus, null);
|
||||
assert.equal((await busStatus(ctx, 'alpha')).healthy, false, 'restore --no-bus starts nothing');
|
||||
// A person's restore starts the bus as the dev owner, with no agent marker in its host.
|
||||
const again = await mosaic(env, ['org', 'archive', 'alpha', '--if-revision', String((await show()).revision), '--json']);
|
||||
assert.equal(again.status, 0, again.stderr);
|
||||
const restored = await mosaic(env, ['org', 'restore', 'alpha', '--if-revision', String(JSON.parse(again.stdout).revision), '--json']);
|
||||
assert.equal(restored.status, 0, restored.stderr);
|
||||
const restoredBus = JSON.parse(restored.stdout).bus;
|
||||
assert.equal(restoredBus.outcome, 'started');
|
||||
const up = await busStatus(ctx, 'alpha'); assert.equal(up.healthy, true); assert.equal(up.pid, restoredBus.pid);
|
||||
const hostNames = readFileSync(`/proc/${up.pid}/environ`, 'utf8').split('\0').map(l => l.split('=')[0]);
|
||||
for (const marker of AGENT_MARKERS) assert.ok(!hostNames.includes(marker), marker);
|
||||
await busStop({ ...ctx, env }, 'alpha');
|
||||
// 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);
|
||||
});
|
||||
@@ -71,7 +71,7 @@ function world(t, { profile = "library" } = {}) {
|
||||
script(join(bin, "stack-copy"), `echo "stack-copy $*" >>'${log}'
|
||||
[ "$1" = copy ] && [ "$3" = --copies ] || exit 4
|
||||
mkdir -p "$4/$2/scripts" && : >"$4/$2/scripts/mosaic" && echo "copied: $4/$2"
|
||||
for file in scripts/case2-persona-import.mjs packages/harness/src/bundle.mjs packages/harness/src/tools.mjs packages/organization/src/rails.mjs packages/bus/src/refusals.mjs packages/business/src/vocabulary.mjs packages/business/src/selector.mjs packages/organization/src/vocabulary.mjs packages/organization/src/selector.mjs packages/plugins/model-provider/cliproxy/index.mjs; do
|
||||
for file in scripts/case2-persona-import.mjs packages/harness/src/bundle.mjs packages/harness/src/tools.mjs packages/organization/src/rails.mjs packages/organization/src/autonomy.mjs packages/bus/src/refusals.mjs packages/business/src/vocabulary.mjs packages/business/src/selector.mjs packages/organization/src/vocabulary.mjs packages/organization/src/selector.mjs packages/plugins/model-provider/cliproxy/index.mjs; do
|
||||
mkdir -p "$4/$2/$(dirname "$file")" && cp '${REPO}'"$file" "$4/$2/$file" || exit 1
|
||||
done
|
||||
mkdir -p "$4/$2/node_modules" && cp -R '${REPO}node_modules/yaml' "$4/$2/node_modules/yaml"`);
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
// E2 (#1873, P1-3, org ruling 6): the WebUI's task board, hire form and autonomy panel from a
|
||||
// terminal. Each test sends the CLI verb and the page's own request for the same input, and checks
|
||||
// that both reach the same bus verb with the same arguments and get the same answer, a refusal
|
||||
// included. Hire and autonomy run the real scripts/mosaic against an in-process dev-owner Console
|
||||
// whose organization transports are fakes, so no bus host starts; tasks run against a real broker
|
||||
// bound to the person, as the human transport binds it.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { chmodSync, mkdirSync, mkdtempSync, 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 { processState } from '../../seat/src/process-state.mjs';
|
||||
import { startServer } from '../../webui/src/serve.mjs';
|
||||
import { createDevOwner } from '../../webui/src/dev-owner.mjs';
|
||||
import { BusReadError } from '../../webui/src/bus.mjs';
|
||||
import { parseTaskWrite } from '../../webui/src/workspace-tasks.mjs';
|
||||
import { close } from '../../webui/tests/fixture.mjs';
|
||||
import { main } from '../src/cli.mjs';
|
||||
import { CliError } from '../src/errors.mjs';
|
||||
import { orgCommand } from '../src/org.mjs';
|
||||
import { broker, humanEnv, io } from './helpers.mjs';
|
||||
|
||||
const REPO = new URL('../../../', import.meta.url).pathname;
|
||||
const MOSAIC = join(REPO, 'scripts/mosaic');
|
||||
const FORBIDDEN = [7332, 7340, 7341, 33223, 17450, 17443, 44307, 7350];
|
||||
const SUBJECT = { provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner' };
|
||||
const SHA = 'a'.repeat(64);
|
||||
|
||||
async function instance(t) {
|
||||
const home = mkdtempSync(join(tmpdir(), 'e2-'));
|
||||
const env = humanEnv({ HOME: home, MOSAIC_CONFIG: join(home, '.config/mosaic-e2test/config.json') });
|
||||
const setup = spawnSync(join(REPO, 'scripts/bootstrap.sh'), ['--instance', 'e2test'], { env, encoding: 'utf8' });
|
||||
assert.equal(setup.status, 0, setup.stderr);
|
||||
const ctx = { configDir: join(home, '.config/mosaic-e2test'), dataRoot: join(home, '.mosaic-e2test'), instance: 'e2test', env };
|
||||
await reserveOrg(ctx, 'alpha'); await activateOrg(ctx, 'alpha');
|
||||
t.after(() => rmSync(home, { recursive: true, force: true }));
|
||||
return { ctx, env };
|
||||
}
|
||||
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 }); });
|
||||
});
|
||||
}
|
||||
function recordConsole(ctx, url) {
|
||||
const dir = join(ctx.dataRoot, 'console');
|
||||
mkdirSync(dir, { recursive: true, mode: 0o700 }); chmodSync(dir, 0o700);
|
||||
const file = join(dir, 'console.json');
|
||||
writeFileSync(file, JSON.stringify({ pid: process.pid, startTime: processState(process.pid).startTime, url }), { mode: 0o600 }); chmodSync(file, 0o600);
|
||||
}
|
||||
// The page's own request: a same-origin fetch from the Console's page.
|
||||
function page(port, method, path, body) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const payload = body === undefined ? undefined : JSON.stringify(body);
|
||||
const headers = { host: `127.0.0.1:${port}`, ...(payload === undefined ? {} : { origin: `http://127.0.0.1:${port}`, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload) }) };
|
||||
request({ host: '127.0.0.1', port, method, path, headers }, res => {
|
||||
let text = ''; res.setEncoding('utf8'); res.on('data', c => { text += c; });
|
||||
res.on('end', () => resolve({ status: res.statusCode, body: JSON.parse(text) }));
|
||||
}).on('error', reject).end(payload);
|
||||
});
|
||||
}
|
||||
// The bus's revision is a 64-hex digest; REV(n) stands in for the nth.
|
||||
const REV = n => n.toString(16).padStart(64, '0');
|
||||
const LOOP = { 'cross-role': 'in the loop', merge: 'in the loop', policy: 'in the loop', authority: 'in the loop' };
|
||||
// One organization, alpha, whose bus transports record each call. `send` false is a Console
|
||||
// started without --send.
|
||||
function organizations({ send = true } = {}) {
|
||||
const calls = [];
|
||||
let state = { revision: REV(3), levels: { ...LOOP }, autonomy: { notices: false } };
|
||||
const org = {
|
||||
busHealthy: true,
|
||||
send: send ? async () => ({}) : null,
|
||||
decisionWrite: async (verb, args, opts) => {
|
||||
calls.push({ verb, args, opts });
|
||||
if (verb === 'hire.templates.console') return { templates: [{ name: 'worker', sha256: SHA }] };
|
||||
if (args.name === 'blocked') throw new BusReadError('definition-not-hireable');
|
||||
return { id: `hire-${calls.length}`, op: 'resolved', choice: 'yes' };
|
||||
},
|
||||
requestRead: async (verb, args, opts) => { calls.push({ verb, args, opts }); return structuredClone(state); },
|
||||
autonomyWrite: async (verb, args, opts) => {
|
||||
calls.push({ verb, args, opts });
|
||||
if (verb === 'autonomy.review.console') return { reviewed: args.decisionClass };
|
||||
if (args.revision !== state.revision) throw new BusReadError('revision-conflict');
|
||||
const { notices, reverseWindowHours, authorityResolver, ...levels } = args.autonomy;
|
||||
state = { revision: REV(parseInt(state.revision, 16) + 1), levels, autonomy: { notices, reverseWindowHours, ...(authorityResolver ? { authorityResolver } : {}) } };
|
||||
return { revision: state.revision, transaction: `tx-${parseInt(state.revision, 16)}` };
|
||||
},
|
||||
};
|
||||
return { calls, organizations: { list: async () => [{ id: 'alpha' }], resolve: async id => (id === 'alpha' ? org : null) } };
|
||||
}
|
||||
async function consoleFor(t, ctx, options) {
|
||||
const f = organizations(options);
|
||||
const server = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso: createDevOwner(), send: async () => ({}), organizations: f.organizations, log: () => {} });
|
||||
t.after(() => close(server));
|
||||
const port = server.address().port;
|
||||
assert.ok(!FORBIDDEN.includes(port));
|
||||
recordConsole(ctx, `http://127.0.0.1:${port}/`);
|
||||
return { ...f, port, server };
|
||||
}
|
||||
|
||||
const HIRE = ['--name', 'ada', '--template', 'worker', '--harness', 'pi', '--model', 'glm', '--reason', 'more hands'];
|
||||
const GUARDED = [
|
||||
['org', 'role-templates', 'alpha'],
|
||||
['org', 'hire', 'alpha', ...HIRE],
|
||||
['org', 'change', 'alpha', ...HIRE],
|
||||
['org', 'fire', 'alpha', '--name', 'ada', '--reason', 'done'],
|
||||
['org', 'autonomy', 'alpha'],
|
||||
['org', 'autonomy', 'alpha', 'set', '--revision', REV(3), '--merge', 'near'],
|
||||
['org', 'autonomy', 'alpha', 'review', 'merge'],
|
||||
];
|
||||
|
||||
test('hire and autonomy verbs: usage errors first (exit 4), then refused inside an agent run before any config is read', async () => {
|
||||
// The config path is not an instance config, so a read would refuse with another message.
|
||||
const env = { ...humanEnv({ MOSAIC_CONFIG: '/nonexistent/e2/config.json' }), CLAUDECODE: '1' };
|
||||
for (const args of GUARDED) {
|
||||
const r = await mosaic(env, args);
|
||||
assert.equal(r.status, 3, `${args.join(' ')}: ${r.stderr}`);
|
||||
assert.match(r.stderr, /outside any agent run \(found CLAUDECODE/, args.join(' '));
|
||||
assert.equal(r.stdout, '');
|
||||
}
|
||||
for (const args of [
|
||||
['org', 'hire', 'alpha', '--name', 'ada'],
|
||||
['org', 'fire', 'alpha', '--name', 'ada', '--reason', 'done', '--model', 'glm'],
|
||||
['org', 'hire', 'alpha', ...HIRE, '--level', 'project'],
|
||||
['org', 'autonomy', 'alpha', 'set', '--merge', 'near'],
|
||||
['org', 'autonomy', 'alpha', 'set', '--revision', REV(3), '--merge', 'sideways'],
|
||||
['org', 'autonomy', 'alpha', 'set', '--revision', '3', '--merge', 'near'],
|
||||
['org', 'autonomy', 'alpha', 'set', '--revision', REV(3), '--reverse-window-hours', '200'],
|
||||
['org', 'autonomy', 'alpha', 'review', 'everything'],
|
||||
['org', 'role-templates', 'Not An Id'],
|
||||
]) {
|
||||
const r = await mosaic(env, args);
|
||||
assert.equal(r.status, 4, `${args.join(' ')}: ${r.stderr}`);
|
||||
assert.doesNotMatch(r.stderr, /agent run/);
|
||||
}
|
||||
// This process's own environment counts too, as for org archive|delete: the marker is only in
|
||||
// process.env here, never in io.env.
|
||||
const saved = process.env.CLAUDECODE;
|
||||
process.env.CLAUDECODE = '1';
|
||||
try {
|
||||
for (const args of GUARDED) {
|
||||
const x = io(humanEnv({ MOSAIC_CONFIG: '/nonexistent/e2/config.json' }));
|
||||
delete x.env.CLAUDECODE;
|
||||
await assert.rejects(orgCommand(args.slice(1), x, {}), e => e instanceof CliError && e.exitCode === 3 && /outside any agent run/.test(e.message), args.join(' '));
|
||||
}
|
||||
} finally { if (saved === undefined) delete process.env.CLAUDECODE; else process.env.CLAUDECODE = saved; }
|
||||
// The default instance is a one-line refusal (exit 3), not an uncaught error.
|
||||
const d = io(humanEnv());
|
||||
delete d.env.MOSAIC_CONFIG;
|
||||
await assert.rejects(orgCommand(['autonomy', 'alpha'], d, {}), e => e instanceof CliError && e.exitCode === 3 && /requires a named instance.*--dev-local-owner/.test(e.message));
|
||||
});
|
||||
|
||||
test('role-templates, hire, change and fire send the hire form\'s request and get its answer, refusals included', { timeout: 120000 }, async t => {
|
||||
const { ctx, env } = await instance(t);
|
||||
const c = await consoleFor(t, ctx);
|
||||
const opts = { subject: SUBJECT, business: 'alpha' };
|
||||
|
||||
let r = await mosaic(env, ['org', 'role-templates', 'alpha', '--json']);
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
const pageTemplates = await page(c.port, 'POST', '/api/orgs/alpha/decisions/hire/templates', { business: 'alpha' });
|
||||
assert.deepEqual(JSON.parse(r.stdout), pageTemplates.body);
|
||||
assert.deepEqual(c.calls.splice(0), [{ verb: 'hire.templates.console', args: {}, opts }, { verb: 'hire.templates.console', args: {}, opts }]);
|
||||
r = await mosaic(env, ['org', 'role-templates', 'alpha']);
|
||||
assert.equal(r.stdout, `worker ${SHA.slice(0, 12)}\n`);
|
||||
c.calls.splice(0);
|
||||
|
||||
// The form's body for this input (bus.js hireSubmit): the template it read, by name and sha256.
|
||||
const form = (op, name, extra = {}) => ({ op, level: 'org', name, reason: 'more hands', ...extra, ...(op === 'fire' ? {} : { role: { template: 'worker', sha256: SHA }, route: { harness: 'pi', model: 'glm' } }) });
|
||||
for (const op of ['hire', 'change']) {
|
||||
r = await mosaic(env, ['org', op, 'alpha', ...HIRE, '--json']);
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
const cli = c.calls.splice(0);
|
||||
assert.deepEqual(cli.map(x => x.verb), ['hire.templates.console', 'hire.start.console']);
|
||||
const pageAnswer = await page(c.port, 'POST', '/api/orgs/alpha/decisions/hire/start', { business: 'alpha', hire: form(op, 'ada') });
|
||||
const pageCall = c.calls.splice(0);
|
||||
assert.deepEqual(cli[1], pageCall[0]);
|
||||
assert.deepEqual(cli[1].args, form(op, 'ada'));
|
||||
assert.equal(pageAnswer.status, 200);
|
||||
// Each decision gets its own id; the rest of the answer is the page's.
|
||||
assert.deepEqual({ ...JSON.parse(r.stdout), id: 'x' }, { ...pageAnswer.body, id: 'x' });
|
||||
}
|
||||
r = await mosaic(env, ['org', 'fire', 'alpha', '--name', 'ada', '--reason', 'more hands', '--level', 'project', '--project', 'site']);
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.match(r.stdout, /^recorded decision hire-1: fire ada \(resolved yes\)\n$/);
|
||||
assert.deepEqual(c.calls.splice(0), [{ verb: 'hire.start.console', args: { op: 'fire', level: 'project', name: 'ada', reason: 'more hands', project: 'site' }, opts }]);
|
||||
|
||||
// A bus refusal reaches the terminal with the Console's code and message, as the form shows it.
|
||||
r = await mosaic(env, ['org', 'hire', 'alpha', ...HIRE.map(a => (a === 'ada' ? 'blocked' : a))]);
|
||||
const pageRefusal = await page(c.port, 'POST', '/api/orgs/alpha/decisions/hire/start', { business: 'alpha', hire: form('hire', 'blocked') });
|
||||
assert.equal(pageRefusal.status, 409);
|
||||
assert.equal(r.status, 3, r.stderr);
|
||||
assert.ok(r.stderr.includes(`hire refused (definition-not-hireable): ${pageRefusal.body.message}`), r.stderr);
|
||||
c.calls.splice(0);
|
||||
// A template the Console did not offer is refused before anything is raised.
|
||||
r = await mosaic(env, ['org', 'hire', 'alpha', ...HIRE.map(a => (a === 'worker' ? 'ghost' : a))]);
|
||||
assert.equal(r.status, 2, r.stderr);
|
||||
assert.match(r.stderr, /no role template "ghost"/);
|
||||
assert.deepEqual(c.calls.splice(0).map(x => x.verb), ['hire.templates.console']);
|
||||
|
||||
// A Console without --send: the page's refusal, and nothing reaches the bus.
|
||||
await close(c.server);
|
||||
const ro = await consoleFor(t, ctx, { send: false });
|
||||
r = await mosaic(env, ['org', 'hire', 'alpha', ...HIRE]);
|
||||
const pageRo = await page(ro.port, 'POST', '/api/orgs/alpha/decisions/hire/templates', { business: 'alpha' });
|
||||
assert.equal(r.status, 3, r.stderr);
|
||||
assert.match(r.stderr, new RegExp(`hire refused \\(${pageRo.body.error}\\)`));
|
||||
assert.equal(pageRo.body.error, 'read-only');
|
||||
assert.deepEqual(ro.calls, []);
|
||||
// An organization the Console does not know is refused as the page is.
|
||||
r = await mosaic(env, ['org', 'role-templates', 'beta']);
|
||||
assert.equal(r.status, 3, r.stderr);
|
||||
assert.match(r.stderr, /role-templates refused \(org-unavailable\)/);
|
||||
});
|
||||
|
||||
test('autonomy reads, saves with the panel\'s revision check and records a review through the panel\'s route', { timeout: 120000 }, async t => {
|
||||
const { ctx, env } = await instance(t);
|
||||
const c = await consoleFor(t, ctx);
|
||||
const opts = { subject: SUBJECT };
|
||||
|
||||
let r = await mosaic(env, ['org', 'autonomy', 'alpha', '--json']);
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.deepEqual(JSON.parse(r.stdout), (await page(c.port, 'GET', '/api/orgs/alpha/autonomy')).body);
|
||||
r = await mosaic(env, ['org', 'autonomy', 'alpha']);
|
||||
assert.equal(r.stdout, `revision ${REV(3)}\ncross-role: in the loop\nmerge: in the loop\npolicy: in the loop\nauthority: in the loop\nnotices: off\nreverse window: not set (the page offers 24 hours)\nauthority resolver: none\n`);
|
||||
c.calls.splice(0);
|
||||
|
||||
// The panel's save for "merge near the loop" (autonomy.js): every class as shown, notices, the
|
||||
// window it offers (24) and no resolver.
|
||||
const panel = { ...LOOP, merge: 'near the loop', notices: false, reverseWindowHours: 24 };
|
||||
// Imported here so that the other tests still run, and fail on their own, on a base without it.
|
||||
const { autonomyDocument } = await import('../src/org-owner.mjs');
|
||||
assert.deepEqual(autonomyDocument({ revision: REV(3), levels: LOOP, autonomy: { notices: false } }, { merge: 'near the loop' }), panel);
|
||||
// An emptied resolver field: the panel leaves it out of the save, and so does the terminal.
|
||||
assert.deepEqual(autonomyDocument({ revision: REV(3), levels: LOOP, autonomy: { notices: false, authorityResolver: 'ops' } }, { authorityResolver: '' }), { ...LOOP, notices: false, reverseWindowHours: 24 });
|
||||
assert.equal(autonomyDocument({ revision: REV(3), levels: LOOP, autonomy: { notices: false, authorityResolver: 'ops' } }, {}).authorityResolver, 'ops');
|
||||
r = await mosaic(env, ['org', 'autonomy', 'alpha', 'set', '--revision', REV(3), '--merge', 'near', '--json']);
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.deepEqual(JSON.parse(r.stdout), { revision: REV(4), transaction: 'tx-4', writable: true });
|
||||
assert.deepEqual(c.calls.splice(0), [{ verb: 'autonomy.get.console', args: {}, opts }, { verb: 'autonomy.set.console', args: { revision: REV(3), autonomy: panel }, opts }]);
|
||||
// The page's save of the same document at revision 4 lands the same way.
|
||||
const pageSave = await page(c.port, 'POST', '/api/orgs/alpha/autonomy', { revision: REV(4), autonomy: panel });
|
||||
assert.deepEqual(pageSave.body, { revision: REV(5), transaction: 'tx-5', writable: true });
|
||||
assert.deepEqual(c.calls.splice(0), [{ verb: 'autonomy.set.console', args: { revision: REV(4), autonomy: panel }, opts }]);
|
||||
|
||||
// A stale revision is the panel's revision-conflict, and nothing changes.
|
||||
r = await mosaic(env, ['org', 'autonomy', 'alpha', 'set', '--revision', REV(3), '--policy', 'near']);
|
||||
const pageStale = await page(c.port, 'POST', '/api/orgs/alpha/autonomy', { revision: REV(3), autonomy: panel });
|
||||
assert.deepEqual(pageStale, { status: 409, body: { error: 'revision-conflict' } });
|
||||
assert.equal(r.status, 3, r.stderr);
|
||||
assert.match(r.stderr, /autonomy set refused \(revision-conflict\): the organization changed; read it again with mosaic org autonomy ID/);
|
||||
assert.equal((await page(c.port, 'GET', '/api/orgs/alpha/autonomy')).body.revision, REV(5));
|
||||
c.calls.splice(0);
|
||||
|
||||
r = await mosaic(env, ['org', 'autonomy', 'alpha', 'review', 'merge']);
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.equal(r.stdout, 'recorded your trail review for merge on alpha\n');
|
||||
await page(c.port, 'POST', '/api/orgs/alpha/autonomy/review', { decisionClass: 'merge' });
|
||||
const [cli, pageReview] = c.calls.splice(0);
|
||||
assert.deepEqual(cli, pageReview);
|
||||
assert.deepEqual(cli, { verb: 'autonomy.review.console', args: { decisionClass: 'merge' }, opts });
|
||||
|
||||
// Without --send the read still answers and both writes are the page's read-only refusal.
|
||||
await close(c.server);
|
||||
const ro = await consoleFor(t, ctx, { send: false });
|
||||
r = await mosaic(env, ['org', 'autonomy', 'alpha', '--json']);
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
assert.equal(JSON.parse(r.stdout).writable, false);
|
||||
for (const args of [['set', '--revision', REV(3), '--merge', 'near'], ['review', 'merge']]) {
|
||||
r = await mosaic(env, ['org', 'autonomy', 'alpha', ...args]);
|
||||
assert.equal(r.status, 3, r.stderr);
|
||||
assert.match(r.stderr, /refused \(read-only\): start the Console with --send/);
|
||||
}
|
||||
assert.deepEqual((await page(ro.port, 'POST', '/api/orgs/alpha/autonomy/review', { decisionClass: 'merge' })).body, { error: 'read-only' });
|
||||
assert.deepEqual(ro.calls.map(x => x.verb), ['autonomy.get.console', 'autonomy.get.console']);
|
||||
});
|
||||
|
||||
// mosaic tasks create|edit|archive with the person's transport over a real broker.
|
||||
async function run(argv, s) {
|
||||
const x = io();
|
||||
try { await main(argv, x, { system: { dataRoot: s.dataRoot }, transport: s.transport }); return { code: 0, out: x.out.text, err: '' }; }
|
||||
catch (e) { if (!(e instanceof CliError)) throw e; return { code: e.exitCode, out: x.out.text, err: e.message }; }
|
||||
}
|
||||
|
||||
test('tasks create, edit and archive send the board\'s writes to the bus as the person', async t => {
|
||||
const b = broker(t);
|
||||
const s = { ...b, dataRoot: mkdtempSync(join(tmpdir(), 'e2t-')) };
|
||||
t.after(() => rmSync(s.dataRoot, { recursive: true, force: true }));
|
||||
// The board's request for the same input, through the board's own check.
|
||||
const board = body => { const w = parseTaskWrite({ org: 'demo', ...body }); return { verb: w.verb, args: w.args }; };
|
||||
|
||||
let r = await run(['tasks', 'create', '--title', 'Plan the store', '--requirement', 'REQ-TASK-1', '--assignees', 'coder', '--priority', '2', '--business', 'demo', '--json'], s);
|
||||
assert.equal(r.code, 0, r.err);
|
||||
const created = JSON.parse(r.out);
|
||||
assert.deepEqual(s.calls.splice(0).map(({ verb, args }) => ({ verb, args })), [board({ op: 'create', title: 'Plan the store', requirement: 'REQ-TASK-1', assignees: ['coder'], priority: 2 })]);
|
||||
assert.deepEqual({ ...created, digest: 'd' }, { ok: true, org: 'demo', op: 'create', task_ref: 'task:1', digest: 'd' });
|
||||
const row = () => s.read('tasks').find(x => x.task_ref === 'task:1');
|
||||
assert.equal(created.digest, row().digest);
|
||||
assert.equal(row().fields.title, 'Plan the store');
|
||||
assert.deepEqual(row().fields.assignees, ['coder']);
|
||||
|
||||
r = await run(['tasks', 'edit', 'task:1', '--expect', created.digest, '--state', 'in-progress', '--due', 'none', '--business', 'demo'], s);
|
||||
assert.equal(r.code, 0, r.err);
|
||||
assert.equal(r.out, `changed task:1, digest ${row().digest}\n`);
|
||||
assert.deepEqual(s.calls.splice(0).map(({ verb, args }) => ({ verb, args })), [board({ op: 'edit', task_ref: 'task:1', expect: created.digest, state: 'in-progress', due_date: null })]);
|
||||
assert.equal(row().fields.state, 'in-progress');
|
||||
|
||||
// A stale digest is the board's task-conflict, and nothing is written.
|
||||
const before = row();
|
||||
r = await run(['tasks', 'archive', 'task:1', '--expect', created.digest, '--business', 'demo'], s);
|
||||
assert.equal(r.code, 2, r.err);
|
||||
assert.match(r.err, /^tasks archive refused \(task-conflict\): the task changed after you read it/);
|
||||
assert.deepEqual(row(), before);
|
||||
s.calls.splice(0);
|
||||
|
||||
r = await run(['tasks', 'archive', 'task:1', '--expect', before.digest, '--business', 'demo'], s);
|
||||
assert.equal(r.code, 0, r.err);
|
||||
assert.deepEqual(s.calls.splice(0).map(({ verb, args }) => ({ verb, args })), [board({ op: 'archive', task_ref: 'task:1', expect: before.digest })]);
|
||||
assert.match(r.out, /^archived task:1, digest [0-9a-f]{64}\n$/);
|
||||
|
||||
// What the board refuses with 400 the terminal refuses as usage (exit 4), with the board's words,
|
||||
// before anything is sent.
|
||||
for (const [argv, body] of [
|
||||
[['tasks', 'create', '--title', 'x', '--business', 'demo'], { op: 'create', title: 'x' }],
|
||||
[['tasks', 'edit', 'task:1', '--expect', 'nope', '--title', 'y', '--business', 'demo'], { op: 'edit', task_ref: 'task:1', expect: 'nope', title: 'y' }],
|
||||
[['tasks', 'archive', 'task:1', '--business', 'demo'], { op: 'archive', task_ref: 'task:1' }],
|
||||
]) {
|
||||
let boardError;
|
||||
try { parseTaskWrite({ org: 'demo', ...body }); } catch (e) { boardError = e; }
|
||||
assert.equal(boardError?.status, 400, JSON.stringify(body));
|
||||
r = await run(argv, s);
|
||||
assert.equal(r.code, 4, argv.join(' '));
|
||||
assert.ok(r.err.includes(boardError.message), `${r.err} / ${boardError.message}`);
|
||||
}
|
||||
assert.deepEqual(s.calls, []);
|
||||
// Terminal-only slips the board cannot make are usage too: an option-looking word is not a value,
|
||||
// and the organization is named (exit 4 even inside an agent run).
|
||||
for (const [argv, env] of [
|
||||
[['tasks', 'create', '--title', '--json', '--requirement', 'REQ-TASK-1', '--business', 'demo'], humanEnv()],
|
||||
[['tasks', 'create', '--title', 'x', '--requirement', 'REQ-TASK-1'], { ...humanEnv(), CLAUDECODE: '1' }],
|
||||
]) {
|
||||
const y = io(env);
|
||||
await assert.rejects(main(argv, y, { system: { dataRoot: s.dataRoot }, transport: s.transport }), e => e instanceof CliError && e.exitCode === 4, argv.join(' '));
|
||||
}
|
||||
assert.deepEqual(s.calls, []);
|
||||
// A marker only in this process's own environment counts too.
|
||||
const savedMarker = process.env.CLAUDECODE;
|
||||
process.env.CLAUDECODE = '1';
|
||||
try {
|
||||
const y = io(humanEnv());
|
||||
delete y.env.CLAUDECODE;
|
||||
await assert.rejects(main(['tasks', 'create', '--title', 'x', '--requirement', 'REQ-TASK-1', '--business', 'demo'], y, { system: { dataRoot: s.dataRoot }, transport: s.transport }), e => e instanceof CliError && e.exitCode === 3 && /outside any agent run/.test(e.message));
|
||||
} finally { if (savedMarker === undefined) delete process.env.CLAUDECODE; else process.env.CLAUDECODE = savedMarker; }
|
||||
assert.deepEqual(s.calls, []);
|
||||
// Inside an agent run the write is refused before anything is sent.
|
||||
const x = io({ ...humanEnv(), CLAUDECODE: '1' });
|
||||
await assert.rejects(main(['tasks', 'create', '--title', 'x', '--requirement', 'REQ-TASK-1', '--business', 'demo'], x, { system: { dataRoot: s.dataRoot }, transport: s.transport }), e => e instanceof CliError && e.exitCode === 3);
|
||||
assert.deepEqual(s.calls, []);
|
||||
});
|
||||
@@ -115,21 +115,47 @@ test('mosaic console restart relaunches a supervised Console and the trail recor
|
||||
assert.deepEqual(warnings, ['mosaic: console asked for a restart; relaunching on the same arguments\n']);
|
||||
});
|
||||
|
||||
test('a detached Console refuses mosaic console restart, and org reload of an unknown org, with no trail', { timeout: 90000 }, async t => {
|
||||
// D1 (#1873): a detached dev-owner Console runs under a detached supervisor, so the terminal restart
|
||||
// works on it as on a foreground one. Before D1 this Console refused with not-supervised.
|
||||
test('a detached dev-owner Console restarts through mosaic console restart, and org reload of an unknown org is refused with no trail', { timeout: 120000 }, async t => {
|
||||
const { ctx, env, trailFile } = await instance(t);
|
||||
const result = await consoleStart(ctx, { devLocalOwner: true, detach: true, port: 'auto', warn: () => {} });
|
||||
const port = Number(new URL(result.url).port);
|
||||
assert.ok(!FORBIDDEN.includes(port));
|
||||
const r = await mosaic(env, ['console', 'restart']);
|
||||
assert.equal(r.status, 3, r.stderr);
|
||||
assert.match(r.stderr, /console restart refused \(not-supervised\)/);
|
||||
// The real organization service refuses an org that does not exist before any trail line.
|
||||
const unknown = await mosaic(env, ['org', 'reload', 'nosuch']);
|
||||
assert.equal(unknown.status, 3, unknown.stderr);
|
||||
assert.match(unknown.stderr, /org reload refused \(org-[a-z-]+\)/);
|
||||
assert.equal(existsSync(trailFile), false);
|
||||
const before = (await get(port, '/api/config')).body;
|
||||
assert.equal(before.consoleRestart, true);
|
||||
const r = await mosaic(env, ['console', 'restart']);
|
||||
assert.equal(r.status, 0, r.stderr);
|
||||
const answer = JSON.parse(r.stdout);
|
||||
assert.equal(answer.outcome, 'restarted'); assert.equal(answer.from, before.boot);
|
||||
assert.equal((await get(port, '/api/config')).body.boot, answer.boot);
|
||||
assert.deepEqual(trailLines(trailFile), [{ by: DEV, action: 'console.restart', outcome: 'restarting', boot: before.boot }]);
|
||||
assert.equal((await consoleStop(ctx)).stopped, true);
|
||||
// The supervisor is unref'd, so a timer keeps this process awake while it exits.
|
||||
let code; result.done.then(c => { code = c; });
|
||||
for (let i = 0; i < 200 && code === undefined; i++) await new Promise(r => setTimeout(r, 50));
|
||||
assert.equal(code, 0);
|
||||
});
|
||||
|
||||
// A dev-owner Console nothing supervises (here one in this process, started with no restart hook)
|
||||
// still refuses the terminal restart and stays up, with no trail line.
|
||||
test('an unsupervised Console refuses mosaic console restart with not-supervised and stays up', { timeout: 90000 }, async t => {
|
||||
const { ctx, env, trailFile } = await instance(t);
|
||||
const server = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso: createDevOwner(), send: async () => ({}), log: () => {}, trail: actionTrail(join(ctx.dataRoot, 'console')) });
|
||||
t.after(() => close(server));
|
||||
const port = server.address().port;
|
||||
assert.ok(!FORBIDDEN.includes(port));
|
||||
recordConsole(ctx, `http://127.0.0.1:${port}/`);
|
||||
const r = await mosaic(env, ['console', 'restart']);
|
||||
assert.equal(r.status, 3, r.stderr);
|
||||
assert.match(r.stderr, /console restart refused \(not-supervised\)/);
|
||||
assert.equal((await get(port, '/api/config')).status, 200);
|
||||
assert.equal(existsSync(trailFile), false);
|
||||
assert.equal((await consoleStop(ctx)).stopped, true);
|
||||
});
|
||||
|
||||
// The F3 bus control through the Console's own route: a stopped bus starts and a running one restarts.
|
||||
|
||||
@@ -22,7 +22,7 @@ function fixture(t) {
|
||||
return { root, file, env, run, boot };
|
||||
}
|
||||
// label: the CLI refusal names the file by this label and never by host path (PR #1916).
|
||||
// Without it, the refusal comes from scripts/mosaic-config.mjs, which still names the file.
|
||||
// scripts/mosaic-config.mjs names it by label too (#1873 redact6).
|
||||
function redacted(result, file, status, label) {
|
||||
assert.equal(result.status, status, result.stderr);
|
||||
assert.equal(result.stdout, '');
|
||||
@@ -37,10 +37,12 @@ function redacted(result, file, status, label) {
|
||||
test('system config parse refusal and CLI relay omit parser bytes', t => {
|
||||
const f = fixture(t);
|
||||
mkdirSync(dirname(f.file), { recursive: true }); writeFileSync(f.file, secret);
|
||||
redacted(f.run(configScript, 'validate', '--instance', 'redacttest'), f.file, 2);
|
||||
redacted(f.run(configScript, 'validate', '--instance', 'redacttest'), f.file, 2, 'configuration is not valid JSON (the config of instance redacttest)');
|
||||
assert.throws(() => loadSystem({ env: f.env, instance: 'redacttest' }), error => {
|
||||
assert.equal(error.exitCode, 3);
|
||||
assert.ok(error.message.includes(f.file));
|
||||
// #1873: the cli relay names the instance's config by label, never its path.
|
||||
assert.ok(!error.message.includes(f.file), error.message);
|
||||
assert.ok(error.message.includes('configuration is not valid JSON; expected readable JSON: the config of instance redacttest'), error.message);
|
||||
assert.match(error.message, /not valid JSON/);
|
||||
assert.doesNotMatch(error.message, /sk-test|Unexpected token/);
|
||||
return true;
|
||||
@@ -52,7 +54,7 @@ test('named instance marker parse refusal omits parser bytes', t => {
|
||||
const f = fixture(t); f.boot();
|
||||
const { dataRoot } = JSON.parse(readFileSync(f.file, 'utf8'));
|
||||
const marker = join(dataRoot, 'instance.json'); writeFileSync(marker, secret);
|
||||
redacted(f.run(configScript, 'validate', '--instance', 'redacttest'), marker, 3);
|
||||
redacted(f.run(configScript, 'validate', '--instance', 'redacttest'), marker, 3, "instance-root-unclaimed: the data root's instance marker is not a valid marker (not valid JSON)");
|
||||
});
|
||||
|
||||
for (const operation of ['validate', 'create', 'update']) test(`org ${operation} document refusal omits parser bytes`, t => {
|
||||
|
||||
@@ -89,7 +89,12 @@ test('CLI boot project id mismatch omits the configured value and keeps exit 3',
|
||||
const root = scratch(t), f = fixture(root), file = join(f.doc.projects.stack.root, '.mosaic', 'project.json');
|
||||
mkdirSync(join(f.doc.projects.stack.root, '.mosaic'));
|
||||
writeFileSync(file, JSON.stringify({ projectVersion: 1, id: secret.toLowerCase() }));
|
||||
assert.throws(() => bootConfig({ system: loadSystem({ env: f.env }), businessId: 'acme', env: f.env }), safe(file, CliError, { exitCode: 3 }, secret.toLowerCase()));
|
||||
assert.throws(() => bootConfig({ system: loadSystem({ env: f.env }), businessId: 'acme', env: f.env }), error => {
|
||||
// #1873: the refusal names the project by its key, never the project file's path.
|
||||
safe('project file of project stack has id that does not match the declared project key', CliError, { exitCode: 3 }, secret.toLowerCase())(error);
|
||||
assert.ok(!error.message.includes(file) && !error.message.includes(f.doc.projects.stack.root), error.message);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
for (const mission of [false, true]) test(`duplicate ${mission ? 'mission' : 'task'} tool rejects an unknown value before naming it`, t => {
|
||||
|
||||
@@ -120,7 +120,7 @@ test('row 65 the loader refusing a module refuses harness-refused and names it;
|
||||
}
|
||||
const s=await setup(t,route('throws','claude-sonnet-5-5','high'));
|
||||
assert.deepEqual(await s.launch(),{ok:false,error:'harness-failed'});
|
||||
assert.equal(s.last().why,'harness throws preflight: fixture preflight threw');
|
||||
assert.equal(s.last().why,'harness throws preflight: threw Error without a system code');
|
||||
});
|
||||
|
||||
test('row 65 a thinking level the route harness does not apply refuses thinking-unsupported, on the primary or a fallback taken',async t=>{
|
||||
|
||||
@@ -768,9 +768,10 @@ test("a missing or failing bwrap refuses sandbox-unavailable before anything sta
|
||||
const bad = join(given.root, "bwrap");
|
||||
writeFileSync(bad, "#!/bin/sh\necho 'bwrap: setting up uid map: Permission denied' >&2\nexit 1\n", { mode: 0o755 });
|
||||
const { f, launcher, cto } = await setup(t, undefined, given, { bwrap: bad });
|
||||
await assert.rejects(launcher.launchPm(), (e) => e.message === `PM launch refused: sandbox-unavailable (${bad} failed: bwrap: setting up uid map: Permission denied)`);
|
||||
// #1873: the sandbox by name, never the binary's host path.
|
||||
await assert.rejects(launcher.launchPm(), (e) => e.message === "PM launch refused: sandbox-unavailable (the sandbox (bwrap) failed: bwrap: setting up uid map: Permission denied)");
|
||||
rmSync(bad);
|
||||
await assert.rejects(launcher.launchPm(), (e) => e.message === `PM launch refused: sandbox-unavailable (${bad} is missing)`);
|
||||
await assert.rejects(launcher.launchPm(), (e) => e.message === "PM launch refused: sandbox-unavailable (the sandbox (bwrap) is missing)");
|
||||
// A session's launch request gets the same code, through refuse().
|
||||
assert.deepEqual(await launcher.fromSession(cto.cap, "coder"), { ok: false, error: "sandbox-unavailable" });
|
||||
assert.deepEqual(log(f).map((e) => e.code), ["sandbox-unavailable", "sandbox-unavailable", "sandbox-unavailable"]);
|
||||
@@ -791,7 +792,7 @@ test("row 64 a role skill that isn't enabled refuses skill-not-enabled, and one
|
||||
assert.deepEqual(await launcher.fromSession(cto.cap, "coder"), { ok: false, error: "skill-not-enabled" });
|
||||
// A symlink in skills-enabled is refused, not followed.
|
||||
symlinkSync(join(REPO, "skills", "ms-unslop"), join(enabled, "ms-unslop"));
|
||||
await assert.rejects(launcher.launchPm(), (e) => e.exitCode === 3 && e.message === `PM launch refused: skill-refused (skill ms-unslop isn't a plain directory under ${enabled})`);
|
||||
await assert.rejects(launcher.launchPm(), (e) => e.exitCode === 3 && e.message === "PM launch refused: skill-refused (skill ms-unslop isn't a plain directory under the enabled skills directory)");
|
||||
assert.deepEqual(await launcher.fromSession(cto.cap, "coder"), { ok: false, error: "skill-refused" });
|
||||
assert.deepEqual(log(f).map((e) => [e.event, e.code]), [["refused", "skill-not-enabled"], ["refused", "skill-not-enabled"], ["refused", "skill-refused"], ["refused", "skill-refused"]]);
|
||||
assert.deepEqual(readSessions(f.dataRoot), []);
|
||||
@@ -832,7 +833,9 @@ test("row 213 a creative instance's persona is read from <configDir>/personas: o
|
||||
const e = entries[1];
|
||||
assert.deepEqual([e.event, e.code], ["refused", "launch-failed"]);
|
||||
assert.equal(e.thread, thread);
|
||||
assert.equal(e.why, `instance bean: persona file ${join(dir, "bean.md")} isn't the file persona.sha256 names`);
|
||||
// #1873: the file by its place under the persona directory, never its host path.
|
||||
assert.equal(e.why, "instance bean: persona file acme/bean.md in the persona directory isn't the file persona.sha256 names");
|
||||
assert.ok(!e.why.includes(dir));
|
||||
assert.ok(!JSON.stringify(log(f)).includes("PERSONA-MARKER-d2"));
|
||||
assert.deepEqual(readSessions(f.dataRoot), []);
|
||||
// The run directory exists by then (BUILD.md residual); the persona is read before any bundle file.
|
||||
|
||||
@@ -404,6 +404,10 @@ test("row 194 V5: delete retires the name, renames the workspace to .retired/I-<
|
||||
const del = s.call("instances.set", { revision: revision(), instance: "sec-1", op: "delete" }).request;
|
||||
const failed = await s.carry(del);
|
||||
assert.equal(failed.done.refusal, "workspace-rename-failed");
|
||||
// #1873: the refused event's reason names the workspace by label and the error by code, never a path.
|
||||
const refused = launchLog(p.f).filter((e) => e.event === "refused" && e.code === "workspace-rename-failed");
|
||||
assert.deepEqual(refused.map((e) => e.why), ["cannot mkdir the instance workspace (EEXIST); left in place"]);
|
||||
assert.ok(!JSON.stringify(refused).includes(p.f.dataRoot));
|
||||
const transaction = failed.done.transaction;
|
||||
assert.ok(transaction);
|
||||
const file = JSON.parse(readFileSync(join(p.root, "config", "businesses", "acme.json"), "utf8"));
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, readdirSync, readFileSync, rmSync, existsSync, statSync } from 'node:fs';
|
||||
import { get, request } from 'node:http';
|
||||
import { join } from 'node:path';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { reserveOrg, activateOrg } from '../../business/src/org-registry.mjs';
|
||||
import { humanEnv } from './helpers.mjs';
|
||||
import { consoleStart, consoleStatus, consoleStop } from '../src/org-console.mjs';
|
||||
import { consoleStart, consoleStatus, consoleStop, consoleRestart } from '../src/org-console.mjs';
|
||||
|
||||
const REPO = new URL('../../../', import.meta.url).pathname;
|
||||
// A detached supervisor's handle is unref'd, so its exit is awaited with a timer that keeps the loop alive.
|
||||
async function exitOf(done, ms = 20000) {
|
||||
let code; done.then(c => { code = c; });
|
||||
for (const deadline = Date.now() + ms; code === undefined; await new Promise(r => setTimeout(r, 100))) if (Date.now() > deadline) assert.fail('the supervisor did not exit');
|
||||
return code;
|
||||
}
|
||||
test('detached console reports its live loopback URL and stops', { timeout: 60000 }, async t => {
|
||||
const home = mkdtempSync(join(tmpdir(), 's1-'));
|
||||
const env = humanEnv({ HOME: home, MOSAIC_CONFIG: join(home, '.config/mosaic-s1test/config.json') });
|
||||
@@ -31,11 +38,13 @@ test('detached console reports its live loopback URL and stops', { timeout: 6000
|
||||
assert.ok(Number.isSafeInteger(result.pid));
|
||||
// A selector producer (rename r2): the Console child gets the active organization as --business.
|
||||
const argv = readFileSync(`/proc/${result.pid}/cmdline`, 'utf8').split('\0').slice(0, -1);
|
||||
assert.deepEqual(argv, [process.execPath, join(REPO, 'packages/webui/src/cli.mjs'), 'serve', '--port', '0', '--business', 'alpha', '--dev-local-owner']);
|
||||
// D1 (#1873): a detached dev-owner Console runs under a detached supervisor, so it is --supervised.
|
||||
assert.deepEqual(argv, [process.execPath, join(REPO, 'packages/webui/src/cli.mjs'), 'serve', '--port', '0', '--business', 'alpha', '--dev-local-owner', '--supervised']);
|
||||
assert.ok(![7332, 7340, 7341, 33223, 17450, 17443, 44307, 7350].includes(Number(url.port)));
|
||||
const status = await new Promise((resolve, reject) => get(url, res => { res.resume(); resolve(res.statusCode); }).on('error', reject));
|
||||
assert.equal(status, 200);
|
||||
assert.equal((await consoleStop(ctx)).stopped, true);
|
||||
assert.equal(await exitOf(result.done), 0);
|
||||
});
|
||||
|
||||
test('mosaic console start and stop dispatch through the CLI', { timeout: 60000 }, async t => {
|
||||
@@ -198,15 +207,213 @@ test('a foreground console relaunches on the same address when the owner restart
|
||||
assert.equal(consoleStatus(ctx).running, false);
|
||||
});
|
||||
|
||||
test('a detached console has no supervisor, so it refuses a restart and stays up', { timeout: 60000 }, async t => {
|
||||
// D1 (#1873, readiness audit P1-1): Restart Console on a detached dev-owner Console. The start runs
|
||||
// the foreground supervision in a process of its own session; the Console is that process's child.
|
||||
const SUPERVISOR = join(REPO, 'packages/cli/src/console-supervisor.mjs');
|
||||
const statOf = pid => readFileSync(`/proc/${pid}/stat`, 'utf8').replace(/^.*\) /, '').split(' ');
|
||||
test('a detached dev-owner console runs under a detached supervisor, so the owner restarts it in place', { timeout: 120000 }, async t => {
|
||||
const { ctx, mosaic } = await instance(t);
|
||||
const warnings = [];
|
||||
const result = await consoleStart(ctx, { devLocalOwner: true, detach: true, port: 'auto', warn: s => warnings.push(s) });
|
||||
t.after(() => { try { process.kill(result.supervisor, 'SIGTERM'); } catch {} });
|
||||
assert.deepEqual(warnings, []);
|
||||
assert.deepEqual(Object.keys(result), ['url', 'pid', 'supervisor']);
|
||||
const port = Number(new URL(result.url).port);
|
||||
assert.ok(![7332, 7340, 7341, 33223, 17450, 17443, 44307, 7350].includes(port));
|
||||
const sup = argvOf(result.supervisor);
|
||||
assert.deepEqual(sup.slice(0, 2), [process.execPath, SUPERVISOR]);
|
||||
assert.deepEqual(JSON.parse(sup[2]), { configDir: ctx.configDir, dataRoot: ctx.dataRoot, instance: 's1test', port: 'auto', bind: null, allowHosts: [] });
|
||||
// The supervisor leads its own session; the Console is its child, started as in the foreground.
|
||||
assert.equal(Number(statOf(result.supervisor)[3]), result.supervisor);
|
||||
assert.equal(Number(statOf(result.pid)[1]), result.supervisor);
|
||||
assert.deepEqual(argvOf(result.pid), [process.execPath, WEBUI, 'serve', '--port', '0', '--business', 'alpha', '--dev-local-owner', '--supervised']);
|
||||
const before = (await call(port, '/api/config')).body;
|
||||
assert.equal(before.consoleRestart, true);
|
||||
const stateFile = join(ctx.dataRoot, 'console/console.json'), trailFile = join(ctx.dataRoot, 'console/actions.jsonl');
|
||||
// Another origin or site restarts nothing.
|
||||
for (const headers of [{ origin: 'http://evil.example' }, { origin: null }, { 'sec-fetch-site': 'cross-site' }, { host: `evil.example:${port}` }]) {
|
||||
assert.equal((await call(port, '/api/console/restart', { method: 'POST', body: '{}', headers })).status, 403, JSON.stringify(headers));
|
||||
}
|
||||
assert.equal(existsSync(trailFile), false);
|
||||
// The page's request, as `mosaic console restart` sends it: 202, then a new boot on the same address.
|
||||
const restarted = await consoleRestart(ctx);
|
||||
assert.equal(restarted.outcome, 'restarted'); assert.equal(restarted.from, before.boot); assert.notEqual(restarted.boot, before.boot);
|
||||
// The relaunched Console answers before the supervisor writes its state (relaunch), so wait for it.
|
||||
const saved = await relaunchedState(ctx, result.pid);
|
||||
assert.notEqual(saved.pid, result.pid);
|
||||
assert.equal(saved.url, result.url);
|
||||
assert.equal(Number(statOf(saved.pid)[1]), result.supervisor);
|
||||
assert.deepEqual(argvOf(saved.pid), [process.execPath, WEBUI, 'serve', '--port', String(port), '--business', 'alpha', '--dev-local-owner', '--supervised']);
|
||||
const after = (await call(port, '/api/config')).body;
|
||||
assert.equal(after.boot, restarted.boot); assert.equal(after.consoleRestart, true);
|
||||
const [line, ...more] = readFileSync(trailFile, 'utf8').split('\n').filter(Boolean).map(l => JSON.parse(l));
|
||||
assert.deepEqual(more, []);
|
||||
assert.deepEqual({ ...line, at: undefined }, { at: undefined, by: DEV, action: 'console.restart', outcome: 'restarting', boot: before.boot });
|
||||
// The supervisor's later warnings go to its log under the data root.
|
||||
const logs = readdirSync(join(ctx.dataRoot, 'console')).filter(x => /^supervisor-\d+-\d+\.log$/.test(x));
|
||||
assert.equal(logs.length, 1);
|
||||
assert.equal(statSync(join(ctx.dataRoot, 'console', logs[0])).mode & 0o777, 0o600);
|
||||
assert.equal(readFileSync(join(ctx.dataRoot, 'console', logs[0]), 'utf8'), 'mosaic: console asked for a restart; relaunching on the same arguments\n');
|
||||
// A second start is refused by the supervisor's start and says so here; nothing else starts.
|
||||
await assert.rejects(consoleStart(ctx, { devLocalOwner: true, detach: true }), e => e.exitCode === 3 && /console state exists/.test(e.message));
|
||||
const cli = mosaic(['console', 'start', '--dev-local-owner', '--detach', '--port', 'auto']);
|
||||
assert.equal(cli.status, 3); assert.match(cli.stderr, /console state exists/); assert.equal(cli.stdout, '');
|
||||
assert.deepEqual(consoleStatus(ctx), { running: true, pid: saved.pid, url: result.url, bind: '127.0.0.1' });
|
||||
// A stop ends the Console and then the supervisor, cleanly.
|
||||
assert.deepEqual(await consoleStop(ctx), { stopped: true, pid: saved.pid });
|
||||
assert.equal(await exitOf(result.done), 0);
|
||||
assert.deepEqual(consoleStatus(ctx), { running: false });
|
||||
});
|
||||
|
||||
test('a supervisor stopped by a signal stops its detached dev-owner console', { timeout: 60000 }, async t => {
|
||||
const { ctx } = await instance(t);
|
||||
const result = await consoleStart(ctx, { devLocalOwner: true, detach: true, port: 'auto', warn: () => {} });
|
||||
t.after(() => { try { process.kill(result.supervisor, 'SIGTERM'); } catch {} });
|
||||
result.stop();
|
||||
assert.equal(await exitOf(result.done), 0);
|
||||
assert.equal(consoleStatus(ctx).running, false);
|
||||
});
|
||||
|
||||
// relaunch() writes console.json after the new Console answers; read it once it names a new pid.
|
||||
async function relaunchedState(ctx, oldPid) {
|
||||
const file = join(ctx.dataRoot, 'console/console.json');
|
||||
for (const deadline = Date.now() + 15000; ; await new Promise(r => setTimeout(r, 50))) {
|
||||
const saved = JSON.parse(readFileSync(file, 'utf8'));
|
||||
if (saved.pid !== oldPid || Date.now() > deadline) return saved;
|
||||
}
|
||||
}
|
||||
|
||||
// D1 fix round (build review P2): the starting CLI can die before it reads the supervisor's reply
|
||||
// (Ctrl-C, a wrapper's timeout). The reply is dropped, and the supervisor keeps supervising.
|
||||
test('a supervisor whose starting CLI is gone keeps supervising, so Restart Console still relaunches', { timeout: 60000 }, async t => {
|
||||
const { ctx } = await instance(t);
|
||||
const child = spawn(process.execPath, [SUPERVISOR, JSON.stringify({ configDir: ctx.configDir, dataRoot: ctx.dataRoot, instance: 's1test', port: 'auto', bind: null, allowHosts: [] })],
|
||||
{ env: ctx.env, detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
t.after(() => { try { process.kill(child.pid, 'SIGTERM'); } catch {} });
|
||||
const exit = new Promise(resolve => child.once('exit', code => resolve(code)));
|
||||
let stderr = ''; child.stderr.setEncoding('utf8').on('data', c => { stderr += c; });
|
||||
child.stdout.destroy();
|
||||
let status = consoleStatus(ctx);
|
||||
for (const deadline = Date.now() + 30000; !status.running; status = consoleStatus(ctx)) {
|
||||
if (Date.now() > deadline) assert.fail(`the console did not start: ${stderr}`);
|
||||
await new Promise(r => setTimeout(r, 100));
|
||||
}
|
||||
// The reply follows the state write; give it time to meet the closed pipe.
|
||||
await new Promise(r => setTimeout(r, 1000));
|
||||
assert.equal(child.exitCode, null, stderr);
|
||||
assert.equal(Number(statOf(status.pid)[1]), child.pid);
|
||||
const restarted = await consoleRestart(ctx);
|
||||
assert.equal(restarted.outcome, 'restarted');
|
||||
const after = await relaunchedState(ctx, status.pid);
|
||||
assert.notEqual(after.pid, status.pid); assert.equal(after.url, status.url);
|
||||
assert.equal(Number(statOf(after.pid)[1]), child.pid);
|
||||
assert.deepEqual(await consoleStop(ctx), { stopped: true, pid: after.pid });
|
||||
assert.equal(await exit, 0);
|
||||
assert.doesNotMatch(stderr, /EPIPE|Unhandled/);
|
||||
});
|
||||
|
||||
// D1 fix round (security review P2): a stop signal while the supervisor holds the start lock is kept
|
||||
// until the start settles; then the Console stops, the lock is released and the supervisor exits.
|
||||
test('a supervisor signalled during its start stops the console it started and releases the start lock', { timeout: 60000 }, async t => {
|
||||
const { ctx } = await instance(t);
|
||||
const child = spawn(process.execPath, [SUPERVISOR, JSON.stringify({ configDir: ctx.configDir, dataRoot: ctx.dataRoot, instance: 's1test', port: 'auto', bind: null, allowHosts: [] })],
|
||||
{ env: ctx.env, detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
|
||||
t.after(() => { try { process.kill(-child.pid, 'SIGKILL'); } catch {} });
|
||||
const exit = new Promise(resolve => child.once('exit', (code, signal) => resolve({ code, signal })));
|
||||
let stdout = '', stderr = '';
|
||||
child.stdout.setEncoding('utf8').on('data', c => { stdout += c; });
|
||||
child.stderr.setEncoding('utf8').on('data', c => { stderr += c; });
|
||||
const lock = join(ctx.dataRoot, 'console/start.lock');
|
||||
for (const deadline = Date.now() + 20000; !existsSync(lock); await new Promise(r => setTimeout(r, 2))) {
|
||||
if (Date.now() > deadline) assert.fail(`the supervisor never took the start lock: ${stderr}`);
|
||||
}
|
||||
process.kill(child.pid, 'SIGTERM');
|
||||
assert.deepEqual(await exit, { code: 0, signal: null }, stderr);
|
||||
assert.equal(existsSync(lock), false);
|
||||
// As after a foreground stop by signal, the state file stays and reads stale, not running.
|
||||
assert.equal(consoleStatus(ctx).running, false);
|
||||
// The started Console was this supervisor's child, and it is gone.
|
||||
const reply = JSON.parse(stdout.trim().split('\n')[0]);
|
||||
assert.equal(existsSync(`/proc/${reply.receipt.pid}`), false);
|
||||
});
|
||||
|
||||
// D1 fix round (security review P1): an agent run never starts a dev-local-owner Console.
|
||||
// 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]) {
|
||||
await assert.rejects(consoleStart(agent, { devLocalOwner: true, detach, port: 'auto', warn: () => {} }), e => e.exitCode === 3 && /only from a human shell/.test(e.message));
|
||||
}
|
||||
// A clean environment given for the Console does not hide this process's own marker (re-check P1).
|
||||
process.env.MOSAIC_RUN_ID = 'run-2';
|
||||
try {
|
||||
for (const detach of [true, false]) {
|
||||
await assert.rejects(consoleStart(ctx, { devLocalOwner: true, detach, port: 'auto', warn: () => {} }), e => e.exitCode === 3 && /found MOSAIC_RUN_ID/.test(e.message));
|
||||
}
|
||||
} finally { delete process.env.MOSAIC_RUN_ID; }
|
||||
const agentCli = args => spawnSync(join(REPO, 'scripts/mosaic'), args, { env: { ...ctx.env, MOSAIC_AGENT_RUN: '1' }, encoding: 'utf8', timeout: 30000 });
|
||||
const refused = agentCli(['console', 'start', '--dev-local-owner', '--detach', '--port', 'auto']);
|
||||
assert.equal(refused.status, 3); assert.equal(refused.stdout, ''); assert.match(refused.stderr, /only from a human shell/);
|
||||
const consoleDir = join(ctx.dataRoot, 'console');
|
||||
assert.deepEqual(existsSync(consoleDir) ? readdirSync(consoleDir) : [], []);
|
||||
assert.deepEqual(consoleStatus(ctx), { running: false });
|
||||
const plain = agentCli(['console', 'start', '--detach', '--port', 'auto']);
|
||||
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);
|
||||
});
|
||||
|
||||
// D1 security re-check P2: a supervisor that hangs holding the start lock is killed with its group
|
||||
// after the 60 s start timeout and 10 s grace. Its lock names it, so the start clears it and the
|
||||
// next start proceeds. The hook blocks the supervisor's event loop once it holds the lock (at its
|
||||
// console state read), so its stop handler never runs and only the group SIGKILL ends it.
|
||||
test('a detached start that times out clears the start lock of the supervisor it killed', { timeout: 150000 }, async t => {
|
||||
const { ctx } = await instance(t);
|
||||
const hook = join(dirname(ctx.dataRoot), 'hang-after-lock.mjs');
|
||||
writeFileSync(hook, `import fs from 'node:fs'; import { syncBuiltinESMExports } from 'node:module';
|
||||
if (process.argv[1]?.endsWith('console-supervisor.mjs')) { const real = fs.openSync;
|
||||
fs.openSync = (...args) => { if (String(args[0]).endsWith('/console/console.json')) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0); return real(...args); };
|
||||
syncBuiltinESMExports(); }\n`);
|
||||
const hung = { ...ctx, env: { ...ctx.env, NODE_OPTIONS: `--import=${pathToFileURL(hook).href}` } };
|
||||
const lock = join(ctx.dataRoot, 'console/start.lock');
|
||||
const began = Date.now();
|
||||
await assert.rejects(consoleStart(hung, { devLocalOwner: true, detach: true, port: 'auto', warn: () => {} }),
|
||||
e => e.exitCode === 3 && /^console failed to start; see console\/supervisor-\d+-\d+\.log under the data root$/.test(e.message));
|
||||
assert.ok(Date.now() - began >= 69000, 'the start waited out its timeout and grace');
|
||||
assert.equal(existsSync(lock), false);
|
||||
assert.deepEqual(consoleStatus(ctx), { running: false });
|
||||
// The next start is not blocked by the killed supervisor's lock.
|
||||
const result = await consoleStart(ctx, { devLocalOwner: true, detach: true, port: 'auto', warn: () => {} });
|
||||
assert.equal((await consoleStop(ctx)).stopped, true);
|
||||
assert.equal(await exitOf(result.done), 0);
|
||||
});
|
||||
|
||||
// Without --dev-local-owner a detached start is what it was before D1: no supervisor, restart refused.
|
||||
test('a detached console without the dev owner has no supervisor, so it refuses a restart and stays up', { timeout: 60000 }, async t => {
|
||||
const { ctx, mosaic } = await instance(t);
|
||||
const warnings = [];
|
||||
const result = await consoleStart(ctx, { detach: true, port: 'auto', warn: s => warnings.push(s) });
|
||||
assert.deepEqual(warnings, []);
|
||||
assert.deepEqual(Object.keys(result), ['url', 'pid']);
|
||||
const port = Number(new URL(result.url).port);
|
||||
assert.ok(!argvOf(result.pid).includes('--supervised'));
|
||||
assert.deepEqual(argvOf(result.pid), [process.execPath, WEBUI, 'serve', '--port', '0', '--business', 'alpha']);
|
||||
assert.equal(readdirSync(join(ctx.dataRoot, 'console')).filter(x => x.startsWith('supervisor-')).length, 0);
|
||||
assert.equal((await call(port, '/api/config')).body.consoleRestart, false);
|
||||
const r = await call(port, '/api/console/restart', { method: 'POST', body: '{}' });
|
||||
assert.equal(r.status, 409); assert.equal(r.body.error, 'not-supervised');
|
||||
// Without a dev owner there is no owner session to restart as: refused before the supervisor check.
|
||||
assert.equal(r.status, 403, JSON.stringify(r.body)); assert.deepEqual(r.body, { error: 'owner-only' });
|
||||
assert.equal((await call(port, '/api/config')).status, 200);
|
||||
assert.equal(existsSync(join(ctx.dataRoot, 'console/actions.jsonl')), false);
|
||||
assert.equal((await consoleStop(ctx)).stopped, true);
|
||||
// The CLI's receipt keeps its two fields.
|
||||
const start = mosaic(['console', 'start', '--detach', '--port', 'auto']);
|
||||
assert.equal(start.status, 0, start.stderr);
|
||||
assert.deepEqual(Object.keys(JSON.parse(start.stdout)), ['url', 'pid']);
|
||||
assert.equal(start.stderr, '');
|
||||
assert.equal((await consoleStop(ctx)).stopped, true);
|
||||
});
|
||||
@@ -21,8 +21,9 @@ async function fixture() {
|
||||
const env = humanEnv({ HOME: root, MOSAIC_CONFIG: join(configDir, 'config.json') });
|
||||
const moduleUrl = new URL('../src/cli.mjs', import.meta.url).href;
|
||||
const code = `import { run } from ${JSON.stringify(moduleUrl)}; process.exitCode = await run(JSON.parse(process.argv[1]), undefined, {system: {dataRoot: process.argv[2]}});`;
|
||||
const call = async (...args) => { const x = await exec(process.execPath, ['--input-type=module', '-e', code, JSON.stringify(args), dataRoot], { env }); return { out: { text: x.stdout }, err: { text: x.stderr } }; };
|
||||
return { root, ctx, org, call };
|
||||
const callWith = async (extra, ...args) => { const x = await exec(process.execPath, ['--input-type=module', '-e', code, JSON.stringify(args), dataRoot], { env: { ...env, ...extra } }); return { out: { text: x.stdout }, err: { text: x.stderr } }; };
|
||||
const call = (...args) => callWith({}, ...args);
|
||||
return { root, ctx, org, call, callWith };
|
||||
}
|
||||
const dead = 2147483647;
|
||||
const json = (path, value) => writeFileSync(path, JSON.stringify(value), { mode: 0o600 });
|
||||
@@ -84,7 +85,8 @@ test('lock recovery reports fences and recovers a dead clearer while refusing li
|
||||
assert.equal(shown.fence.pid, process.pid);
|
||||
await assert.rejects(f.call('org', 'lock', 'clear', 'alpha'), new RegExp(`recovery is already in progress.*${process.pid}`));
|
||||
writeFileSync(fence, '');
|
||||
await assert.rejects(f.call('org', 'lock', 'clear', 'alpha'), e => e.message.includes(fence) && /remove.*hand/i.test(e.message));
|
||||
// #1873: the fence is named by label in the config directory, never by its path.
|
||||
await assert.rejects(f.call('org', 'lock', 'clear', 'alpha'), e => !e.message.includes(f.ctx.configDir) && e.message.includes('recovery fence org-lifecycle-alpha.lock.clear in the config directory') && /remove.*hand/i.test(e.message));
|
||||
unlinkSync(fence);
|
||||
const registry = new URL('../../organization/src/org-registry.mjs', import.meta.url).href;
|
||||
const code = `import fs from 'node:fs'; import { syncBuiltinESMExports } from 'node:module';
|
||||
@@ -120,8 +122,9 @@ test('org lock show and clear require a provably dead holder', async () => {
|
||||
|
||||
test('bus recover preserves evidence for dead and wrong-process hosts without signals', async () => {
|
||||
const f = await fixture(), dir = join(f.org.root, 'bus-host'); mkdirSync(dir, { mode: 0o700 });
|
||||
// A wrong process is a live pid whose start time is not the recorded one (the pid was reused).
|
||||
for (const pid of [dead, process.pid]) {
|
||||
const state = { pid, startTime: startTimeOf(pid) ?? '1', business: 'alpha', startedAt: '2026-01-01T00:00:00Z' };
|
||||
const state = { pid, startTime: '1', business: 'alpha', startedAt: '2026-01-01T00:00:00Z' };
|
||||
json(join(dir, 'host.json'), state); json(join(dir, 'host.lock'), state);
|
||||
const x = await f.call('bus', 'recover', 'alpha');
|
||||
const result = JSON.parse(x.out.text);
|
||||
@@ -137,6 +140,26 @@ test('bus recover preserves evidence for dead and wrong-process hosts without si
|
||||
assert.equal(process.pid > 0, true);
|
||||
});
|
||||
|
||||
test('D2: bus recover refuses a live pid with the recorded start time, whatever its argv', async () => {
|
||||
const f = await fixture(), dir = join(f.org.root, 'bus-host'); mkdirSync(dir, { mode: 0o700 });
|
||||
const state = { pid: process.pid, startTime: startTimeOf(process.pid), business: 'alpha', startedAt: '2026-01-01T00:00:00Z' };
|
||||
assert.match(state.startTime, /^\d+$/);
|
||||
json(join(dir, 'host.json'), state); json(join(dir, 'host.lock'), state);
|
||||
await assert.rejects(f.call('bus', 'recover', 'alpha'), /bus host identity is uncertain/);
|
||||
assert.deepEqual(JSON.parse(readFileSync(join(dir, 'host.json'), 'utf8')), state);
|
||||
assert.equal(existsSync(join(dir, 'host.lock')), true);
|
||||
assert.ok(!readdirSync(dir).some(x => x.startsWith('recovery-')));
|
||||
});
|
||||
|
||||
test('D2: bus recover refuses inside an agent run before it reads anything', async () => {
|
||||
const f = await fixture(), dir = join(f.org.root, 'bus-host'); mkdirSync(dir, { mode: 0o700 });
|
||||
const state = { pid: dead, startTime: '1', business: 'alpha', startedAt: '2026-01-01T00:00:00Z' };
|
||||
json(join(dir, 'host.json'), state); json(join(dir, 'host.lock'), state);
|
||||
await assert.rejects(f.callWith({ MOSAIC_AGENT_RUN: '1' }, 'bus', 'recover', 'alpha'), e => e.code === 3 && /outside any agent run/.test(e.stderr));
|
||||
assert.equal(existsSync(join(dir, 'host.json')), true);
|
||||
assert.ok(!readdirSync(dir).some(x => x.startsWith('recovery-')));
|
||||
});
|
||||
|
||||
test('lock recovery refuses malformed or unreadable holders', async () => {
|
||||
const f = await fixture(), path = join(f.ctx.configDir, 'org-lifecycle-alpha.lock');
|
||||
for (const value of [{ pid: 0, at: '2026-01-01T00:00:00Z' }, { pid: dead }, null]) {
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
// #1873 P1-6 and P1-7: `org project repo init --remote` sets and checks origin; `org project repo
|
||||
// set` changes the repository both ways. The remotes point at 127.0.0.1 port 1, which refuses at
|
||||
// once, so the check runs for real and answers "unreachable" without leaving the host.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
||||
import { createHash } from 'node:crypto';
|
||||
import { join, resolve } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { execFileSync, spawnSync } from 'node:child_process';
|
||||
const repo = resolve(import.meta.dirname, '../../..');
|
||||
const run = (env, ...args) => spawnSync(join(repo, 'scripts/mosaic'), args, { cwd: repo, env, encoding: 'utf8' });
|
||||
const git = (cwd, ...args) => execFileSync('git', ['-C', cwd, ...args], { env: { PATH: process.env.PATH, HOME: '/nonexistent', GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null' }, encoding: 'utf8' }).trim();
|
||||
|
||||
test('org project repo init --remote and repo set: origin follows, the check is reported, refusals are plain', () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'org-repo-')); const env = { ...process.env, HOME: home, MOSAIC_CONFIG: join(home, '.config/mosaic-orgtest/config.json') };
|
||||
const boot = spawnSync(join(repo, 'scripts/bootstrap.sh'), ['--instance', 'orgtest'], { cwd: repo, env, encoding: 'utf8' }); assert.equal(boot.status, 0, boot.stderr);
|
||||
const ok = (...args) => { const r = run(env, ...args); assert.equal(r.status, 0, r.stderr); return JSON.parse(r.stdout); };
|
||||
const file = join(home, 'alpha.json');
|
||||
run(env, 'org', 'template', 'render', 'alpha', '--fixture', 'local', '--output', file); ok('org', 'create', 'alpha', '--from', file);
|
||||
|
||||
const ssh = 'ssh://[email protected]:1/team/work.git';
|
||||
const init = ok('org', 'project', 'repo', 'init', 'alpha', 'work', '--remote', ssh);
|
||||
assert.equal(init.origin, ssh); assert.equal(init.check.result, 'unreachable'); assert.match(init.check.message, /port 1|refused/i);
|
||||
assert.equal(git(init.root, 'remote', '-v'), `origin\t${ssh} (fetch)\norigin\t${ssh} (push)`);
|
||||
|
||||
// Usage errors come first and change nothing.
|
||||
for (const args of [['--local-only', '--accept-local-loss-risk'], ['--remote', ssh, '--local-only', '--if-revision', '9'], ['--if-revision', '9'], ['--remote', ssh, '--if-revision', '0']]) {
|
||||
const r = run(env, 'org', 'project', 'repo', 'set', 'alpha', 'work', ...args); assert.equal(r.status, 4, `${args}: ${r.stderr}`);
|
||||
}
|
||||
assert.equal(run(env, 'org', 'queue', 'set', 'alpha', '--local-only', '--accept-local-loss-risk', '--if-revision', '1').status, 4);
|
||||
let rev = ok('org', 'show', 'alpha', '--json').revision;
|
||||
for (const bad of ['ext::sh -c id', 'file:///etc', '/srv/repo', 'https://user:[email protected]:1/o/r', 'http://127.0.0.1:1/o/r']) {
|
||||
const r = run(env, 'org', 'project', 'repo', 'set', 'alpha', 'work', '--remote', bad, '--if-revision', String(rev));
|
||||
assert.equal(r.status, 2, bad); assert.match(r.stderr, /org-invalid/, bad);
|
||||
}
|
||||
const stale = run(env, 'org', 'project', 'repo', 'set', 'alpha', 'work', '--local-only', '--accept-local-loss-risk', '--if-revision', String(rev + 5));
|
||||
assert.equal(stale.status, 2); assert.match(stale.stderr, /revision-conflict/);
|
||||
assert.equal(git(init.root, 'remote', 'get-url', 'origin'), ssh);
|
||||
|
||||
const local = ok('org', 'project', 'repo', 'set', 'alpha', 'work', '--local-only', '--accept-local-loss-risk', '--if-revision', String(rev));
|
||||
assert.equal(local.op, 'org.project.repo.set'); assert.equal(local.revision, rev + 1); assert.equal(local.origin, null); assert.equal(git(init.root, 'remote', '-v'), '');
|
||||
rev = local.revision;
|
||||
const https = 'https://127.0.0.1:1/team/work.git';
|
||||
const back = ok('org', 'project', 'repo', 'set', 'alpha', 'work', '--remote', https, '--if-revision', String(rev), '--json');
|
||||
assert.equal(back.revision, rev + 1); assert.equal(back.check.result, 'unreachable'); assert.match(back.check.message, /Failed to connect|Could not connect/);
|
||||
assert.equal(git(init.root, 'remote', 'get-url', 'origin'), https);
|
||||
assert.deepEqual(ok('org', 'project', 'repo', 'show', 'alpha', 'work', '--json').current, { mode: 'remote', remote: https, origin: https, revision: back.revision, check: back.check, changedAt: back.at });
|
||||
assert.equal(back.check.url, https);
|
||||
|
||||
// The acceptance URL (#1873 P1-6): an .invalid host never resolves, so the check is unreachable
|
||||
// and origin is still set to it.
|
||||
const invalid = 'https://git.example.invalid/acme/work';
|
||||
const named = ok('org', 'project', 'repo', 'set', 'alpha', 'work', '--remote', invalid, '--if-revision', String(back.revision), '--json');
|
||||
assert.equal(named.origin, invalid); assert.equal(named.check.result, 'unreachable'); assert.equal(named.check.url, invalid);
|
||||
assert.match(named.check.message, /resolve|timed out/i);
|
||||
assert.equal(git(init.root, 'remote', '-v'), `origin\t${invalid} (fetch)\norigin\t${invalid} (push)`);
|
||||
});
|
||||
|
||||
// #1873 F7: while a copy binding that names the project is approved or active, repo set is refused
|
||||
// with exit 2 and the next step; once the binding is unbound (revoked) the same set goes through.
|
||||
// The bindings are written into the document as the CB1 writer would leave them.
|
||||
test('org project repo set is refused under a copy binding that names the project, and allowed after revoke', () => {
|
||||
const home = mkdtempSync(join(tmpdir(), 'org-repo-')); const env = { ...process.env, HOME: home, MOSAIC_CONFIG: join(home, '.config/mosaic-orgtest/config.json') };
|
||||
const boot = spawnSync(join(repo, 'scripts/bootstrap.sh'), ['--instance', 'orgtest'], { cwd: repo, env, encoding: 'utf8' }); assert.equal(boot.status, 0, boot.stderr);
|
||||
const ok = (...args) => { const r = run(env, ...args); assert.equal(r.status, 0, r.stderr); return JSON.parse(r.stdout); };
|
||||
const file = join(home, 'alpha.json');
|
||||
run(env, 'org', 'template', 'render', 'alpha', '--fixture', 'local', '--output', file); ok('org', 'create', 'alpha', '--from', file);
|
||||
const init = ok('org', 'project', 'repo', 'init', 'alpha', 'work', '--local-only', '--accept-local-loss-risk');
|
||||
const configDir = join(home, '.config/mosaic-orgtest');
|
||||
const seed = (state) => {
|
||||
const docFile = join(configDir, 'businesses/alpha.json'); const doc = JSON.parse(readFileSync(docFile, 'utf8'));
|
||||
doc.copyBindings = { 'rev.work': { copyId: '1'.repeat(32), orgIncarnation: 'a'.repeat(32), source: { level: 'project', name: 'rev', project: 'work' },
|
||||
definition: { ref: 'reviewer', rev: null, sha256: 'b'.repeat(64) }, generation: 1, state,
|
||||
services: state === 'unbound' ? {} : { gitea: { slot: 'self', scopes: ['read:repository'], resources: { projects: ['work'] } } },
|
||||
decision: '11111111-1111-4111-8111-111111111111' } };
|
||||
const raw = JSON.stringify(doc, null, 2) + '\n'; writeFileSync(docFile, raw);
|
||||
const regFile = join(configDir, 'org-runtime.json'); const reg = JSON.parse(readFileSync(regFile, 'utf8'));
|
||||
reg.orgs.alpha.digest = createHash('sha256').update(raw).digest('hex'); writeFileSync(regFile, JSON.stringify(reg, null, 2) + '\n');
|
||||
};
|
||||
const invalid = 'https://git.example.invalid/acme/other';
|
||||
seed('pending');
|
||||
const rev = ok('org', 'show', 'alpha', '--json').revision;
|
||||
const refused = run(env, 'org', 'project', 'repo', 'set', 'alpha', 'work', '--remote', invalid, '--if-revision', String(rev));
|
||||
assert.equal(refused.status, 2, refused.stderr);
|
||||
assert.match(refused.stderr, /project-repo-bound: project work is in a copy binding that still grants it: rev\.work \(approved, not yet verified\)\. Revoke that copy binding first/);
|
||||
assert.equal(git(init.root, 'remote', '-v'), '');
|
||||
seed('unbound');
|
||||
const set = ok('org', 'project', 'repo', 'set', 'alpha', 'work', '--remote', invalid, '--if-revision', String(rev), '--json');
|
||||
assert.equal(set.revision, rev + 1); assert.equal(git(init.root, 'remote', 'get-url', 'origin'), invalid);
|
||||
});
|
||||
@@ -0,0 +1,94 @@
|
||||
// E1 (P1-4): `mosaic org restore` brings the organization back running. It starts the bus as
|
||||
// `mosaic bus start ID --dev-local-owner --detach` would; a bus that does not start is said so with
|
||||
// that verb, and the restore stands. On an instance where that start is refused, the restore is
|
||||
// refused first and changes nothing. `--no-bus` keeps the old restore. The bus start itself is a
|
||||
// stand-in here; agent-guard.test.mjs and org.test.mjs start a real one.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import * as service from '../../organization/src/org-service.mjs';
|
||||
import { listOrgs } from '../../organization/src/org-registry.mjs';
|
||||
import { DEV_OWNER } from '../src/dev-local-owner.mjs';
|
||||
import { orgCommand } from '../src/org.mjs';
|
||||
import { io, humanEnv } from './helpers.mjs';
|
||||
|
||||
// A named instance under a temporary HOME (the dev-owner checks read the instance from HOME), with
|
||||
// one archived organization.
|
||||
async function archived(t) {
|
||||
const home = mkdtempSync(join(tmpdir(), 'e1-restore-')), saved = process.env.HOME;
|
||||
process.env.HOME = home;
|
||||
t.after(() => { process.env.HOME = saved; rmSync(home, { recursive: true, force: true }); });
|
||||
const configDir = join(home, '.config', 'mosaic-e1test'), dataRoot = join(home, 'data');
|
||||
mkdirSync(configDir, { recursive: true, mode: 0o700 }); mkdirSync(dataRoot, { mode: 0o700 });
|
||||
const ctx = { configDir, dataRoot, instance: 'e1test', principal: DEV_OWNER };
|
||||
const created = await service.createOrg(ctx, 'alpha', await service.templateRender('alpha'));
|
||||
const gone = await service.archiveOrg(ctx, 'alpha', { ifRevision: created.revision, busStop: async () => ({ stopped: false }) });
|
||||
const env = humanEnv({ HOME: home, MOSAIC_CONFIG: join(configDir, 'config.json') });
|
||||
const state = async () => (await listOrgs(ctx)).find(e => e.id === 'alpha').state;
|
||||
return { ctx, env, revision: gone.revision, state, system: { dataRoot } };
|
||||
}
|
||||
const restore = (f, deps, ...extra) => { const x = io(f.env); return orgCommand(['restore', 'alpha', '--if-revision', String(f.revision), '--json', ...extra], x, { system: f.system, ...deps }).then(() => x, e => { e.io = x; throw e; }); };
|
||||
|
||||
test('E1: restore starts the bus as the dev owner and its receipt names the bus', async t => {
|
||||
const f = await archived(t), calls = [];
|
||||
const x = await restore(f, { busStart: async (ctx, id, opts) => { calls.push([ctx.dataRoot, ctx.env === f.env, id, opts]); return { org: id, pid: 4242, socket: '/s', root: '/r' }; } });
|
||||
assert.deepEqual(calls, [[f.ctx.dataRoot, true, 'alpha', { detach: true, devLocalOwner: true, pm: false }]]);
|
||||
const receipt = JSON.parse(x.out.text);
|
||||
assert.equal(receipt.revision, f.revision + 1);
|
||||
assert.deepEqual(receipt.bus, { outcome: 'started', pid: 4242 });
|
||||
assert.equal(await f.state(), 'active');
|
||||
});
|
||||
|
||||
test('E1: a bus that does not start after a good restore is said so, with the verb, and the restore stands', async t => {
|
||||
const f = await archived(t);
|
||||
const { CliError } = await import('../src/errors.mjs');
|
||||
const busStart = async () => { throw new CliError('bus failed to start; see the bus log', 3); };
|
||||
await assert.rejects(restore(f, { busStart }), e => {
|
||||
assert.equal(e.exitCode, 3);
|
||||
assert.equal(e.message, 'alpha was restored, but its bus did not start: bus failed to start; see the bus log; start it with mosaic bus start alpha --dev-local-owner --detach');
|
||||
// The restore receipt is printed before the refusal.
|
||||
const receipt = JSON.parse(e.io.out.text);
|
||||
assert.equal(receipt.revision, f.revision + 1);
|
||||
assert.deepEqual(receipt.bus, { outcome: 'failed', message: 'bus failed to start; see the bus log', next: 'mosaic bus start alpha --dev-local-owner --detach' });
|
||||
return true;
|
||||
});
|
||||
assert.equal(await f.state(), 'active');
|
||||
});
|
||||
|
||||
test('E1: a bus host that died leaves a stale lock; the receipt names bus recover before the start', async t => {
|
||||
const f = await archived(t);
|
||||
const { CliError } = await import('../src/errors.mjs');
|
||||
const busStart = async () => { throw new CliError('stale bus host lock', 3); };
|
||||
await assert.rejects(restore(f, { busStart }), e => {
|
||||
assert.equal(e.exitCode, 3);
|
||||
assert.equal(e.message, 'alpha was restored, but its bus did not start: stale bus host lock; clear the dead host with mosaic bus recover alpha, then start it with mosaic bus start alpha --dev-local-owner --detach');
|
||||
assert.deepEqual(JSON.parse(e.io.out.text).bus, { outcome: 'failed', message: 'stale bus host lock', next: 'mosaic bus recover alpha; mosaic bus start alpha --dev-local-owner --detach' });
|
||||
return true;
|
||||
});
|
||||
assert.equal(await f.state(), 'active');
|
||||
});
|
||||
|
||||
test('E1: where a dev-owner bus start is refused, restore is refused before it changes anything, and --no-bus restores', async t => {
|
||||
const f = await archived(t); let starts = 0;
|
||||
const busStart = async () => { starts++; return { pid: 1 }; };
|
||||
writeFileSync(join(f.ctx.dataRoot, '.deployed'), '{}\n');
|
||||
await assert.rejects(restore(f, { busStart }), e => e.exitCode === 3 && /^restore starts the bus as the local dev owner, and --dev-local-owner refuses \.deployed; restore with --no-bus, then start the bus with mosaic bus start$/.test(e.message) && e.io.out.text === '');
|
||||
assert.equal(await f.state(), 'archived');
|
||||
const x = await restore(f, { busStart }, '--no-bus');
|
||||
assert.equal(JSON.parse(x.out.text).bus, null);
|
||||
assert.equal(starts, 0);
|
||||
assert.equal(await f.state(), 'active');
|
||||
});
|
||||
|
||||
test('E1: an agent run may restore only with --no-bus', async t => {
|
||||
const f = await archived(t); let starts = 0;
|
||||
const busStart = async () => { starts++; return { pid: 1 }; };
|
||||
f.env = { ...f.env, MOSAIC_AGENT_RUN: '1' };
|
||||
await assert.rejects(restore(f, { busStart }), e => e.exitCode === 3 && /MOSAIC_AGENT_RUN/.test(e.message));
|
||||
assert.equal(await f.state(), 'archived');
|
||||
await restore(f, { busStart }, '--no-bus');
|
||||
assert.equal(starts, 0);
|
||||
assert.equal(await f.state(), 'active');
|
||||
});
|
||||
@@ -0,0 +1,162 @@
|
||||
// D2 (P0-3): a Console unit stop (KillMode=mixed) SIGKILLs the bus hosts the Console started. These
|
||||
// tests start real bus hosts, kill them the same way, and check that busRevive and the Console-start
|
||||
// revive bring them back, and that a live host or a live writer lock is never taken over.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawn, spawnSync } from 'node:child_process';
|
||||
import { existsSync, lstatSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { reserveOrg, activateOrg, listOrgs } from '../../organization/src/org-registry.mjs';
|
||||
import { businessDoc, rolesCopy, writeJson } from '../../organization/tests/helpers.mjs';
|
||||
import { humanEnv, notifyConfig } from './helpers.mjs';
|
||||
import { busRevive, busStart, busStatus, busStop, setAside } from '../src/org-bus.mjs';
|
||||
import { reviveBuses } from '../../webui/src/bus-revive.mjs';
|
||||
|
||||
const REPO = new URL('../../../', import.meta.url).pathname;
|
||||
const IDS = ['alpha', 'beta'];
|
||||
|
||||
async function instance(t) {
|
||||
const home = mkdtempSync(join(tmpdir(), 'rv-'));
|
||||
const env = humanEnv({ HOME: home, MOSAIC_CONFIG: join(home, '.config/mosaic-rvtest/config.json') });
|
||||
const setup = spawnSync(join(REPO, 'scripts/bootstrap.sh'), ['--instance', 'rvtest'], { env, encoding: 'utf8' });
|
||||
assert.equal(setup.status, 0, setup.stderr);
|
||||
const ctx = { configDir: join(home, '.config/mosaic-rvtest'), dataRoot: join(home, '.mosaic-rvtest'), instance: 'rvtest', env };
|
||||
env.MOSAIC_ROLES_DIR = rolesCopy(home);
|
||||
const roots = {};
|
||||
for (const id of IDS) {
|
||||
const org = await reserveOrg(ctx, id);
|
||||
await activateOrg(ctx, id);
|
||||
writeJson(join(ctx.configDir, 'businesses', `${id}.json`), businessDoc(home, id));
|
||||
notifyConfig(org.root, id, null);
|
||||
roots[id] = org.root;
|
||||
}
|
||||
const extra = [];
|
||||
t.after(async () => {
|
||||
for (const child of extra) { try { child.kill('SIGKILL'); } catch {} }
|
||||
for (const id of IDS) { try { await busStop(ctx, id); } catch {} }
|
||||
rmSync(home, { recursive: true, force: true });
|
||||
});
|
||||
return { ctx, roots, extra };
|
||||
}
|
||||
// What a Console unit stop does to a host it started: SIGKILL to the host and every process under it.
|
||||
// The host is a session leader (detached), so its process group is the test's own; `done` resolves
|
||||
// once the host is reaped, and the loop waits for the rest of the group to go.
|
||||
async function kill(receipt) {
|
||||
process.kill(-receipt.pid, 'SIGKILL');
|
||||
await receipt.done;
|
||||
for (let i = 0; i < 100; i++) {
|
||||
try { process.kill(-receipt.pid, 0); } catch (e) { if (e.code === 'ESRCH') return; throw e; }
|
||||
await new Promise(r => setTimeout(r, 50));
|
||||
}
|
||||
throw new Error(`process group ${receipt.pid} did not go`);
|
||||
}
|
||||
const host = root => JSON.parse(readFileSync(join(root, 'bus-host/host.json'), 'utf8'));
|
||||
const recoveries = root => readdirSync(join(root, 'bus-host')).filter(name => /^recovery-.*\.json$/.test(name));
|
||||
|
||||
test('D2: a SIGKILLed bus host comes back from one revive, on the options it was started with', { timeout: 120000 }, async t => {
|
||||
const { ctx, roots } = await instance(t);
|
||||
const first = await busStart(ctx, 'alpha', { detach: true, devLocalOwner: true, noLaunch: true });
|
||||
await kill(first);
|
||||
// What a Console unit stop leaves behind: the dead host's state, lock and socket.
|
||||
assert.equal(existsSync(join(roots.alpha, 'bus-host/host.json')), true);
|
||||
await assert.rejects(busStatus(ctx, 'alpha'), e => e.name === 'CliError' && /stale/.test(e.message));
|
||||
const revived = await busRevive(ctx, 'alpha');
|
||||
assert.notEqual(revived.pid, first.pid);
|
||||
assert.equal(revived.recovered.pid, first.pid);
|
||||
assert.equal((await busStatus(ctx, 'alpha')).healthy, true);
|
||||
assert.deepEqual(host(roots.alpha).options, { devLocalOwner: true, pm: false, noLaunch: true });
|
||||
assert.equal(recoveries(roots.alpha).length, 1);
|
||||
const receipt = JSON.parse(readFileSync(revived.recovered.receipt, 'utf8'));
|
||||
assert.equal(receipt.host.pid, first.pid);
|
||||
});
|
||||
|
||||
test('D2: a live bus host is never taken over, and its state is left as it was', { timeout: 120000 }, async t => {
|
||||
const { ctx, roots } = await instance(t);
|
||||
const live = await busStart(ctx, 'alpha', { detach: true, noLaunch: true });
|
||||
const before = readFileSync(join(roots.alpha, 'bus-host/host.json'), 'utf8');
|
||||
await assert.rejects(busRevive(ctx, 'alpha'), e => e.name === 'CliError' && e.message === `bus host pid ${live.pid} is alive`);
|
||||
assert.equal(readFileSync(join(roots.alpha, 'bus-host/host.json'), 'utf8'), before);
|
||||
assert.equal(recoveries(roots.alpha).length, 0);
|
||||
const status = await busStatus(ctx, 'alpha');
|
||||
assert.equal(status.healthy, true);
|
||||
assert.equal(status.pid, live.pid);
|
||||
});
|
||||
|
||||
test('D2: a writer lock held by a live process refuses the revive, even with the host dead', { timeout: 120000 }, async t => {
|
||||
const { ctx, roots, extra } = await instance(t);
|
||||
const first = await busStart(ctx, 'alpha', { detach: true, noLaunch: true });
|
||||
await kill(first);
|
||||
// A live process the test started holds the writer lock.
|
||||
const holder = spawn('sleep', ['60'], { stdio: 'ignore' }); extra.push(holder);
|
||||
writeFileSync(join(roots.alpha, 'bus/writer.lock'), JSON.stringify({ pid: holder.pid, at: new Date().toISOString() }), { mode: 0o600 });
|
||||
const lockBefore = readFileSync(join(roots.alpha, 'bus/writer.lock'), 'utf8');
|
||||
await assert.rejects(busRevive(ctx, 'alpha'), e => e.name === 'CliError' && e.message === `bus writer pid ${holder.pid} is alive`);
|
||||
assert.equal(readFileSync(join(roots.alpha, 'bus/writer.lock'), 'utf8'), lockBefore);
|
||||
assert.equal(existsSync(join(roots.alpha, 'bus-host/host.json')), true);
|
||||
assert.equal(recoveries(roots.alpha).length, 0);
|
||||
// The Console-start revive leaves it too, and logs the refusal.
|
||||
const lines = [], trail = [];
|
||||
const results = await reviveBuses({ ctx, listOrgs, busStatus, busRevive, env: {}, trail: e => trail.push(e), log: line => lines.push(line) });
|
||||
assert.deepEqual(results.find(r => r.id === 'alpha'), { id: 'alpha', outcome: 'refused' });
|
||||
assert.ok(lines.some(line => line.includes(`alpha: refused (bus writer pid ${holder.pid} is alive)`)), lines.join('\n'));
|
||||
assert.deepEqual(trail.filter(e => e.org === 'alpha').map(e => e.outcome), ['requested', 'refused']);
|
||||
assert.equal(existsSync(join(roots.alpha, 'bus-host/host.json')), true);
|
||||
});
|
||||
|
||||
test('D2: Console start brings both killed buses back and reattaches to one that still runs', { timeout: 180000 }, async t => {
|
||||
const { ctx, roots } = await instance(t);
|
||||
const a = await busStart(ctx, 'alpha', { detach: true, devLocalOwner: true, noLaunch: true });
|
||||
const b = await busStart(ctx, 'beta', { detach: true, devLocalOwner: true, noLaunch: true });
|
||||
await kill(a); await kill(b);
|
||||
const trail = [], lines = [];
|
||||
const results = await reviveBuses({ ctx, listOrgs, busStatus, busRevive, env: {}, trail: e => trail.push(e), log: line => lines.push(line) });
|
||||
assert.deepEqual(results.map(r => [r.id, r.outcome]), [['alpha', 'revived'], ['beta', 'revived']]);
|
||||
for (const id of IDS) {
|
||||
const status = await busStatus(ctx, id);
|
||||
assert.equal(status.healthy, true, id);
|
||||
assert.equal(host(roots[id]).options.devLocalOwner, true);
|
||||
assert.deepEqual(trail.filter(e => e.org === id).map(e => [e.action, e.outcome, e.by.sub]), [['bus.revive', 'requested', 'local-dev-owner'], ['bus.revive', 'done', 'local-dev-owner']]);
|
||||
}
|
||||
// A second Console start finds them running and leaves them.
|
||||
const pids = Object.fromEntries(await Promise.all(IDS.map(async id => [id, (await busStatus(ctx, id)).pid])));
|
||||
const again = await reviveBuses({ ctx, listOrgs, busStatus, busRevive, env: {}, trail: e => trail.push(e), log: () => {} });
|
||||
assert.deepEqual(again.map(r => [r.id, r.outcome]), [['alpha', 'running'], ['beta', 'running']]);
|
||||
for (const id of IDS) assert.equal((await busStatus(ctx, id)).pid, pids[id]);
|
||||
});
|
||||
|
||||
test('D2: a start that fails after the dead state was cleared says so (error.recovered)', { timeout: 120000 }, async t => {
|
||||
const { ctx, roots } = await instance(t);
|
||||
const first = await busStart(ctx, 'alpha', { detach: true, noLaunch: true });
|
||||
await kill(first);
|
||||
// A record busStart refuses (--pm with --no-launch), rewritten in place: the clear goes through and
|
||||
// the start that follows fails, the way a host that cannot come up would.
|
||||
const file = join(roots.alpha, 'bus-host/host.json');
|
||||
writeFileSync(file, JSON.stringify({ ...host(roots.alpha), options: { devLocalOwner: false, pm: true, noLaunch: true } }));
|
||||
await assert.rejects(busRevive(ctx, 'alpha'), e => e.name === 'CliError' && /--pm or --no-launch/.test(e.message) && e.recovered?.pid === first.pid);
|
||||
assert.equal(existsSync(file), false);
|
||||
assert.equal(recoveries(roots.alpha).length, 1);
|
||||
});
|
||||
|
||||
test('D2: an entry swapped after inspection is put back and nothing is removed', t => {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'aside-'));
|
||||
t.after(() => rmSync(dir, { recursive: true, force: true }));
|
||||
const a = join(dir, 'host.json'), b = join(dir, 'writer.lock');
|
||||
writeFileSync(a, 'dead host', { mode: 0o600 }); writeFileSync(b, 'dead writer', { mode: 0o600 });
|
||||
const entries = [{ path: a, st: lstatSync(a) }, { path: b, st: lstatSync(b) }];
|
||||
// The swap: the inspected writer.lock is moved off (its inode stays in use) and a new one takes the name.
|
||||
renameSync(b, join(dir, 'kept')); writeFileSync(b, 'live writer', { mode: 0o600 });
|
||||
assert.throws(() => setAside(entries), e => e.name === 'CliError' && e.message === 'bus recovery state changed');
|
||||
assert.equal(readFileSync(a, 'utf8'), 'dead host');
|
||||
assert.equal(readFileSync(b, 'utf8'), 'live writer');
|
||||
assert.deepEqual(readdirSync(dir).sort(), ['host.json', 'kept', 'writer.lock']);
|
||||
// Unswapped, the entries go aside and remove() deletes only those.
|
||||
renameSync(join(dir, 'kept'), b);
|
||||
const fresh = [{ path: a, st: lstatSync(a) }, { path: b, st: lstatSync(b) }];
|
||||
const aside = setAside(fresh);
|
||||
assert.equal(existsSync(a) || existsSync(b), false);
|
||||
writeFileSync(a, 'a new host', { mode: 0o600 });
|
||||
aside.remove();
|
||||
assert.deepEqual(readdirSync(dir), ['host.json']);
|
||||
assert.equal(readFileSync(a, 'utf8'), 'a new host');
|
||||
});
|
||||
@@ -21,6 +21,8 @@ test('organization CLI renders, validates, creates, updates and initializes git'
|
||||
info = JSON.parse(invoke('org', 'show', 'alpha', '--json')); const archived = JSON.parse(invoke('org', 'archive', 'alpha', '--if-revision', String(info.revision), '--json')); assert.equal(archived.revision, info.revision + 1);
|
||||
assert.equal(JSON.parse(invoke('org', 'list', '--json'))[0].state, 'archived');
|
||||
const restored = JSON.parse(invoke('org', 'restore', 'alpha', '--if-revision', String(archived.revision), '--json')); assert.equal(restored.revision, archived.revision + 1);
|
||||
// E1 (P1-4): restore brings the organization back running; the delete below stops the bus again.
|
||||
assert.equal(restored.bus.outcome, 'started'); assert.ok(Number.isInteger(restored.bus.pid)); assert.equal(JSON.parse(invoke('bus', 'status', 'alpha', '--json')).healthy, true);
|
||||
info = JSON.parse(invoke('org', 'show', 'alpha', '--json')); const receipt = JSON.parse(invoke('org', 'delete', 'alpha', '--confirm', 'alpha', '--if-revision', String(info.revision), '--json')); assert.equal(receipt.org, 'alpha'); assert.deepEqual(JSON.parse(invoke('org', 'list', '--json')), []);
|
||||
});
|
||||
test('business and org aliases conflict in either order', () => { for (const args of [['--business','alpha','--org','beta'], ['--org','beta','--business','alpha']]) { const r = run(process.env, 'tasks', ...args); assert.notEqual(r.status, 0); assert.match(r.stderr, /conflict/i); } });
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
// #1873 (redact6): a cli refusal names the file, the instance or the field, never a host path,
|
||||
// and never relays mosaic-config's stderr or a Node system error's message. The sentinel is a
|
||||
// component of every scratch path, so a refusal that quotes a path quotes the sentinel too; each
|
||||
// test also checks the scratch root and the label. The launcher cases need no bus: a bus socket
|
||||
// under a sentinel path would pass the Unix socket length limit, so they use a stub host.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdirSync, readFileSync, symlinkSync, writeFileSync } from 'node:fs';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { join } from 'node:path';
|
||||
import { createLauncher, relay } from '../src/launcher.mjs';
|
||||
import { CliError } from '../src/errors.mjs';
|
||||
import { loadSystem } from '../src/config.mjs';
|
||||
import { main } from '../src/cli.mjs';
|
||||
import { initializeControl } from '../../processing-control/src/state.mjs';
|
||||
import { launchLogFile } from '../../seat/src/session.mjs';
|
||||
import { fakeHarnesses, fixture, io, tmp, withoutHostCredentials } from './helpers.mjs';
|
||||
|
||||
const secret = 'sk-test-NOT-A-SECRET-1234';
|
||||
function scratch(t) {
|
||||
const root = tmp(t, 'r6-');
|
||||
const dir = join(root, secret);
|
||||
mkdirSync(dir, { mode: 0o700 });
|
||||
return { root, dir };
|
||||
}
|
||||
function clean(text, root) {
|
||||
assert.ok(!text.includes(root), text);
|
||||
assert.doesNotMatch(text, /sk-test|NOT-A-SECRET/);
|
||||
}
|
||||
const PI = JSON.stringify(new URL('../../plugins/harness/pi/index.mjs', import.meta.url).href);
|
||||
|
||||
function stubLauncher(t, { op = async () => ({}) } = {}) {
|
||||
const { root, dir } = scratch(t);
|
||||
const f = fixture(dir, 'acme');
|
||||
initializeControl(f.dataRoot);
|
||||
withoutHostCredentials(f.env, { piKey: true });
|
||||
const system = JSON.parse(readFileSync(f.config, 'utf8'));
|
||||
const host = { business: 'acme', beforeClose: () => {}, op };
|
||||
return { root, f, make: () => createLauncher({ host, system, env: f.env, bwrap: '/usr/bin/true', log: () => {} }) };
|
||||
}
|
||||
|
||||
test('an unreadable sessions file refuses exit 3 naming bus-host/sessions.json under the data root, never its path', t => {
|
||||
const s = stubLauncher(t);
|
||||
mkdirSync(join(s.f.dataRoot, 'bus-host'), { recursive: true, mode: 0o700 });
|
||||
writeFileSync(join(s.f.dataRoot, 'bus-host', 'sessions.json'), '{not json');
|
||||
assert.throws(() => s.make(), e => {
|
||||
assert.ok(e instanceof CliError, e.stack);
|
||||
assert.equal(e.exitCode, 3);
|
||||
assert.equal(e.message, 'sessions file is unreadable: bus-host/sessions.json under the data root; check which sessions still run, then move the file aside');
|
||||
clean(e.message, s.root);
|
||||
return true;
|
||||
});
|
||||
writeFileSync(join(s.f.dataRoot, 'bus-host', 'sessions.json'), JSON.stringify({ sessionsVersion: 999, sessions: [] }));
|
||||
assert.throws(() => s.make(), e => e.exitCode === 3 && e.message.startsWith('sessions file is malformed: bus-host/sessions.json under the data root;') && !e.message.includes(s.root));
|
||||
});
|
||||
|
||||
test('a launch socket path that is not a socket refuses exit 3 by label, never its path', async t => {
|
||||
const s = stubLauncher(t);
|
||||
mkdirSync(join(s.f.dataRoot, 'bus-host'), { recursive: true, mode: 0o700 });
|
||||
writeFileSync(join(s.f.dataRoot, 'bus-host', 'launch.sock'), 'in the way\n');
|
||||
const launcher = s.make();
|
||||
t.after(() => launcher.close?.());
|
||||
await assert.rejects(launcher.listen(), e => {
|
||||
assert.ok(e instanceof CliError, e.stack);
|
||||
assert.equal(e.exitCode, 3);
|
||||
assert.equal(e.message, "launch socket path exists and isn't a socket");
|
||||
clean(e.message, s.root);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
// The launch-routes pattern: a stub host stops each launch at authorizeLaunch, and the launch
|
||||
// log's refused event carries the reason.
|
||||
async function routed(t, harness) {
|
||||
const { root, dir } = scratch(t);
|
||||
const f = fixture(dir, 'acme', doc => {
|
||||
doc.roles.coder.vars = { harness, model: 'claude-sonnet-5-5', thinking: 'high' };
|
||||
doc.plugins = { ...doc.plugins, harness: ['pi', 'claude-code', harness] };
|
||||
return doc;
|
||||
});
|
||||
initializeControl(f.dataRoot);
|
||||
withoutHostCredentials(f.env, { piKey: true });
|
||||
const host = { business: 'acme', beforeClose: () => {}, op: async x => {
|
||||
if (x.op === 'identity') return { business: 'acme', role: 'pm' };
|
||||
if (x.op === 'mayLaunch') return { allow: true };
|
||||
if (x.op === 'authorizeLaunch') throw Object.assign(Error('fixture authorization stop'), { code: 'fixture-policy' });
|
||||
return {};
|
||||
} };
|
||||
const absent = join(dir, 'absent.json');
|
||||
const makeLoader = await fakeHarnesses(t, { pi: null, 'claude-code': null }, write => {
|
||||
// fsthrows: its preflight reads a missing file, so the error's message quotes the path.
|
||||
write('harness', 'fsthrows', `export * from ${PI};\nimport { readFileSync } from "node:fs";\nexport function preflight() { readFileSync(${JSON.stringify(absent)}); }\n`);
|
||||
// plainthrows: a plain Error whose message quotes a host path, as a module's own text.
|
||||
write('harness', 'plainthrows', `export * from ${PI};\nexport function preflight() { throw new Error(${JSON.stringify(`cannot read ${absent}`)}); }\n`);
|
||||
// pathfails: a preflight that returns a failure whose message quotes a host path.
|
||||
write('harness', 'pathfails', `export * from ${PI};\nexport function preflight() { return { code: "harness-binary-missing", message: ${JSON.stringify(`no binary at ${absent}; see file://${absent}`)} }; }\n`);
|
||||
});
|
||||
const system = JSON.parse(readFileSync(f.config, 'utf8'));
|
||||
const launcher = createLauncher({ host, system, env: f.env, makeLoader, bwrap: '/usr/bin/true', log: () => {} });
|
||||
const last = () => readFileSync(launchLogFile(f.dataRoot, 'acme'), 'utf8').trim().split('\n').map(l => JSON.parse(l)).at(-1);
|
||||
return { root, f, launch: () => launcher.fromSession('fixture-cap', 'coder'), last };
|
||||
}
|
||||
|
||||
test('a harness whose preflight throws a plain Error naming a path refuses harness-failed by its class, never its message', async t => {
|
||||
const s = await routed(t, 'plainthrows');
|
||||
assert.deepEqual(await s.launch(), { ok: false, error: 'harness-failed' });
|
||||
assert.equal(s.last().why, 'harness plainthrows preflight: threw Error without a system code');
|
||||
clean(JSON.stringify(s.last()), s.root);
|
||||
});
|
||||
|
||||
test('a preflight failure whose message quotes a path keeps its code and relays the text with the path replaced', async t => {
|
||||
const s = await routed(t, 'pathfails');
|
||||
assert.deepEqual(await s.launch(), { ok: false, error: 'harness-binary-missing' });
|
||||
assert.equal(s.last().why, 'no binary at a host path; see a host path');
|
||||
clean(JSON.stringify(s.last()), s.root);
|
||||
});
|
||||
|
||||
test('the claude-code preflight names MOSAIC_CLAUDE_BIN, never its value', async t => {
|
||||
const { root, dir } = scratch(t);
|
||||
const f = fixture(dir, 'acme', doc => {
|
||||
doc.roles.coder.vars = { harness: 'claude-code', model: 'claude-sonnet-5-5', thinking: 'high' };
|
||||
return doc;
|
||||
});
|
||||
initializeControl(f.dataRoot);
|
||||
withoutHostCredentials(f.env);
|
||||
f.env.CLAUDE_CODE_OAUTH_TOKEN = 'fixture-only';
|
||||
f.env.MOSAIC_CLAUDE_BIN = join(dir, 'absent-claude');
|
||||
const host = { business: 'acme', beforeClose: () => {}, op: async x => x.op === 'identity' ? { business: 'acme', role: 'pm' } : x.op === 'mayLaunch' ? { allow: true } : {} };
|
||||
const makeLoader = await fakeHarnesses(t, { pi: null, 'claude-code': null });
|
||||
const launcher = createLauncher({ host, system: JSON.parse(readFileSync(f.config, 'utf8')), env: f.env, makeLoader, bwrap: '/usr/bin/true', log: () => {} });
|
||||
assert.deepEqual(await launcher.fromSession('fixture-cap', 'coder'), { ok: false, error: 'harness-binary-missing' });
|
||||
const last = JSON.parse(readFileSync(launchLogFile(f.dataRoot, 'acme'), 'utf8').trim().split('\n').at(-1));
|
||||
assert.equal(last.why, "MOSAIC_CLAUDE_BIN isn't an executable file at an absolute path");
|
||||
clean(JSON.stringify(last), root);
|
||||
});
|
||||
|
||||
test('relay replaces absolute, home and file: paths and keeps the rest of the text', () => {
|
||||
assert.equal(relay(`bad ${'/srv/x/' + secret} and ~/y, (file:///z) here`), 'bad a host path and a host path, (a host path) here');
|
||||
assert.equal(relay('fatal: not a git repository: .git; see https://example.test/a'), 'fatal: not a git repository: .git; see https://example.test/a');
|
||||
});
|
||||
|
||||
test('a harness whose preflight throws a system error refuses harness-failed naming the call and code, never the path', async t => {
|
||||
const s = await routed(t, 'fsthrows');
|
||||
assert.deepEqual(await s.launch(), { ok: false, error: 'harness-failed' });
|
||||
assert.equal(s.last().why, 'harness fsthrows preflight: cannot open a host file (ENOENT)');
|
||||
clean(JSON.stringify(s.last()), s.root);
|
||||
});
|
||||
|
||||
test('a harness the loader refuses names its code only, never the loader message or a path', async t => {
|
||||
const s = await routed(t, 'absent');
|
||||
assert.deepEqual(await s.launch(), { ok: false, error: 'harness-refused' });
|
||||
assert.match(s.last().why, /^harness absent: [a-z][a-z0-9-]*$/);
|
||||
clean(JSON.stringify(s.last()), s.root);
|
||||
});
|
||||
|
||||
test('the halt gate refusal names the instance, never its data root, and keeps exit 1 and the count', async t => {
|
||||
const { root, dir } = scratch(t);
|
||||
const result = { version: 1, generation: '11111111-1111-4111-8111-111111111111', operation: '11111111-1111-4111-8111-111111111111', business: 'demo', human: 'jason', at: '2026-10-20T00:00:00.000Z', recovered: false, halted: true, quiescent: false, remaining: 2, stopped: 0 };
|
||||
const x = io();
|
||||
await assert.rejects(main(['halt', '--business', 'demo'], x, { system: { dataRoot: join(dir, 'data') }, transport: () => async () => result }), e => {
|
||||
assert.equal(e.exitCode, 1);
|
||||
assert.equal(e.message, 'halt gate is on for this instance; 2 stops unconfirmed. Use mosaic processing status');
|
||||
clean(e.message, root);
|
||||
return true;
|
||||
});
|
||||
const odd = io();
|
||||
await assert.rejects(main(['halt', '--business', 'demo'], odd, { system: { dataRoot: join(dir, 'data') }, transport: () => async () => ({ ...result, remaining: join(dir, 'x') }) }), e => {
|
||||
assert.equal(e.message, 'halt gate is on for this instance; unknown stops unconfirmed. Use mosaic processing status');
|
||||
clean(e.message, root);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
test('a system config problem names the config by label and never relays mosaic-config stderr', t => {
|
||||
const { root, dir } = scratch(t);
|
||||
// Missing: the label for where it came from, with the bootstrap hint.
|
||||
assert.throws(() => loadSystem({ env: { PATH: process.env.PATH, HOME: dir, MOSAIC_CONFIG: join(dir, 'absent.json') } }), e => {
|
||||
assert.ok(e instanceof CliError, e.stack);
|
||||
assert.equal(e.exitCode, 3);
|
||||
assert.equal(e.message, 'system config problem (mosaic-config exit 3): configuration not found: the system config MOSAIC_CONFIG names (run scripts/bootstrap.sh to create it)');
|
||||
clean(e.message, root);
|
||||
return true;
|
||||
});
|
||||
assert.throws(() => loadSystem({ env: { PATH: process.env.PATH, HOME: dir } }), e => {
|
||||
assert.match(e.message, /^system config problem \(mosaic-config exit 3\): configuration not found: the default system config \(run scripts\/bootstrap\.sh to create it\)$/);
|
||||
clean(e.message, root);
|
||||
return true;
|
||||
});
|
||||
// A field whose value quotes the sentinel: the field and its constraint, never the value.
|
||||
const f = fixture(dir, 'acme');
|
||||
const doc = JSON.parse(readFileSync(f.config, 'utf8'));
|
||||
writeFileSync(f.config, JSON.stringify({ ...doc, dataRoot: `relative/${secret}` }));
|
||||
assert.throws(() => loadSystem({ env: f.env }), e => {
|
||||
assert.equal(e.exitCode, 3);
|
||||
assert.match(e.message, /^system config problem \(mosaic-config exit \d+\): dataRoot: expected a canonical absolute instance data path: the system config MOSAIC_CONFIG names$/);
|
||||
clean(e.message, root);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
// scripts/mosaic-config.mjs run standalone: every refusal names the config by where it came from,
|
||||
// the data root and its marker by their roles, and a key only when it is a plain name. Exit codes
|
||||
// are unchanged: 2 invalid, 3 missing or unclaimed, 4 usage.
|
||||
const CONFIG_SCRIPT = new URL('../../../scripts/mosaic-config.mjs', import.meta.url).pathname;
|
||||
function mosaicConfig(home, env, ...args) {
|
||||
return spawnSync(process.execPath, [CONFIG_SCRIPT, ...args], { encoding: 'utf8', env: { PATH: process.env.PATH, HOME: home, ...env } });
|
||||
}
|
||||
function refusedBy(r, status, text, root) {
|
||||
assert.equal(r.status, status, r.stderr);
|
||||
assert.equal(r.stdout, '');
|
||||
assert.ok(r.stderr.includes(text), r.stderr);
|
||||
clean(r.stderr, root);
|
||||
}
|
||||
|
||||
test('mosaic-config refusals name the config, data root and keys by label, never a host path or value', t => {
|
||||
const { root, dir } = scratch(t);
|
||||
const file = join(dir, 'config.json');
|
||||
const env = { MOSAIC_CONFIG: file };
|
||||
writeFileSync(file, 'not json');
|
||||
refusedBy(mosaicConfig(dir, env, 'validate'), 2, 'mosaic-config: configuration is not valid JSON (the system config MOSAIC_CONFIG names)', root);
|
||||
const good = { configVersion: 1, environment: 'development', dataRoot: join(dir, 'data'), execution: { backend: 'docker', provider: 'zai', model: 'glm-5.3-flash' } };
|
||||
writeFileSync(file, JSON.stringify({ ...good, [join(dir, 'k')]: 1 }));
|
||||
refusedBy(mosaicConfig(dir, env, 'validate'), 2, 'unsupported configuration key: that is not a plain name', root);
|
||||
writeFileSync(file, JSON.stringify({ ...good, console: { [join(dir, 'k')]: 1 } }));
|
||||
refusedBy(mosaicConfig(dir, env, 'validate'), 2, 'console has a key that is not a plain name', root);
|
||||
writeFileSync(file, JSON.stringify({ ...good, dataRoot: dir }));
|
||||
refusedBy(mosaicConfig(dir, env, 'validate'), 2, 'execution "dataRoot" must not be or contain the home directory', root);
|
||||
writeFileSync(join(dir, 'real.json'), JSON.stringify(good));
|
||||
const link = join(dir, 'link.json');
|
||||
symlinkSync(join(dir, 'real.json'), link);
|
||||
refusedBy(mosaicConfig(dir, { MOSAIC_CONFIG: link }, 'validate'), 2, 'configuration path must be a regular, non-symbolic-link file: the system config MOSAIC_CONFIG names', root);
|
||||
// A named instance: MOSAIC_CONFIG naming another file is usage (4); an unmarked root is 3.
|
||||
refusedBy(mosaicConfig(dir, env, 'validate', '--instance', 'alpha'), 4, 'instance-config-mismatch: --instance alpha names its own config, but MOSAIC_CONFIG names another file', root);
|
||||
refusedBy(mosaicConfig(dir, {}, 'validate', '--instance', join(dir, 'x')), 4, 'instance-name-invalid: that name must match', root);
|
||||
const named = join(dir, '.config', 'mosaic-alpha');
|
||||
mkdirSync(named, { recursive: true, mode: 0o700 });
|
||||
mkdirSync(join(dir, '.mosaic-alpha'), { mode: 0o700 });
|
||||
writeFileSync(join(named, 'config.json'), JSON.stringify({ ...good, dataRoot: join(dir, '.mosaic-alpha') }));
|
||||
refusedBy(mosaicConfig(dir, {}, 'validate', '--instance', 'alpha'), 3, 'instance-root-unclaimed: the data root holds no instance.json naming alpha', root);
|
||||
mkdirSync(join(dir, '.mosaic-alpha', 'instance.json'));
|
||||
refusedBy(mosaicConfig(dir, {}, 'validate', '--instance', 'alpha'), 3, "instance-root-unclaimed: the data root's instance marker must be a regular, non-symbolic-link file", root);
|
||||
});
|
||||
@@ -130,7 +130,8 @@ test('#774 F1 proxy launch of an agent with no key yet passes the first check un
|
||||
// claude-code module on claude-opus-5-5 (provider anthropic); MOSAIC_CLAUDE_BIN decides whether its
|
||||
// preflight passes. The stated fallback is pi on glm-5.3-flash (provider zai).
|
||||
const NO_CLAUDE = '/nonexistent-774/claude';
|
||||
const NO_CLAUDE_WHY = `MOSAIC_CLAUDE_BIN ${NO_CLAUDE} isn't an executable file at an absolute path`;
|
||||
// #1873: names the variable, never its value (was `MOSAIC_CLAUDE_BIN ${NO_CLAUDE} isn't ...`).
|
||||
const NO_CLAUDE_WHY = "MOSAIC_CLAUDE_BIN isn't an executable file at an absolute path";
|
||||
const routed = (t, bin) => managedLaunchFixture(t, { harness: 'claude-code', extra: ['pi'], real: ['claude-code'], hostEnv: { MOSAIC_CLAUDE_BIN: bin } });
|
||||
const withFallback = (doc, harness = 'pi', model = 'glm-5.3-flash') => {
|
||||
claudeCoder(doc);
|
||||
|
||||
@@ -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' });
|
||||
});
|
||||
@@ -112,7 +112,7 @@ test("rename r2 B2: a credential problem, an expiry warning and a system config
|
||||
for (const args of [["validate", "acme"], ["resolve", "acme", "coder"]]) {
|
||||
const { old, now } = await sameAcrossSpellings(args, bad.env, bad.root, 2);
|
||||
assert.equal(now.stderr, old.stderr, "a refusal is byte for byte the same, unnormalized");
|
||||
assert.match(old.stderr, /^mosaic business: .*coder-gitea\.token.*mode 644/m);
|
||||
assert.match(old.stderr, /^mosaic business: (?:roles\.coder\.credentials\.gitea|coder gitea) file: mode 644/m);
|
||||
assert.match(old.stderr, /credential reference problem\(s\)\n$/);
|
||||
}
|
||||
const soon = new Date(Date.now() + 3 * 86400000).toISOString().slice(0, 10);
|
||||
|
||||
@@ -133,7 +133,9 @@ test("T-I1 bootstrap --instance creates each instance's config, root and marker
|
||||
for (const name of ["gamma", "worktrees", "delta"]) {
|
||||
const r = bootstrap(home, "--instance", name);
|
||||
assert.equal(r.code, 3, r.err);
|
||||
assert.match(r.err, new RegExp(`instance-root-unclaimed: ${join(home, `.mosaic-${name}`)} exists, is not empty and holds no instance.json naming ${name}; nothing was written`));
|
||||
// #1873: the data root by its role, never its path.
|
||||
assert.match(r.err, new RegExp(`instance-root-unclaimed: the instance's data root exists, is not empty and holds no instance.json naming ${name}; nothing was written`));
|
||||
assert.ok(!r.err.includes(home), r.err);
|
||||
assert.equal(existsSync(join(home, ".config", `mosaic-${name}`)), false);
|
||||
}
|
||||
assert.deepEqual(snapshot(home), before);
|
||||
@@ -154,7 +156,8 @@ test("T-I1 bootstrap --instance creates each instance's config, root and marker
|
||||
const before = snapshot(home);
|
||||
const r = bootstrap(home, "--instance", "alpha");
|
||||
assert.equal(r.code, 3);
|
||||
assert.match(r.err, /instance-root-unclaimed: .*\.mosaic-alpha holds no instance\.json naming alpha/);
|
||||
assert.match(r.err, /instance-root-unclaimed: the data root holds no instance\.json naming alpha/);
|
||||
assert.ok(!r.err.includes(home), r.err);
|
||||
assert.deepEqual(snapshot(home), before);
|
||||
});
|
||||
|
||||
@@ -262,7 +265,8 @@ test("T-I2 selection: the four rows of D1 through the instance op and through an
|
||||
const r = config(home, { MOSAIC_CONFIG: given }, op, "--instance", "alpha");
|
||||
assert.equal(r.code, 4, `${op} ${given}: ${r.err}`);
|
||||
assert.equal(r.out, "");
|
||||
assert.match(r.err, new RegExp(`instance-config-mismatch: --instance alpha names ${cfgOf(home, "alpha")}, but MOSAIC_CONFIG names ${given}`));
|
||||
assert.match(r.err, /instance-config-mismatch: --instance alpha names its own config, but MOSAIC_CONFIG names another file/);
|
||||
assert.ok(!r.err.includes(home) && !r.err.includes(given), r.err);
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -270,7 +274,9 @@ test("T-I2 selection: the four rows of D1 through the instance op and through an
|
||||
await t.test("a named instance with no config exits 3 with its own bootstrap hint", () => {
|
||||
const r = config(home, {}, "instance", "--instance", "gamma");
|
||||
assert.equal(r.code, 3);
|
||||
assert.match(r.err, /configuration not found: .*mosaic-gamma\/config\.json \(run scripts\/bootstrap\.sh --instance gamma to create it\)/);
|
||||
// #1873: the refusal names the config by its instance, never by host path.
|
||||
assert.match(r.err, /configuration not found: the config of instance gamma \(run scripts\/bootstrap\.sh --instance gamma to create it\)/);
|
||||
assert.ok(!r.err.includes(cfgOf(home, "gamma")) && !r.err.includes(home), r.err);
|
||||
});
|
||||
|
||||
await t.test("an entry point takes --instance in any position, once, and refuses a mismatch before it prints", () => {
|
||||
@@ -326,7 +332,8 @@ test("T-I7 claims: the instance op refuses a root another instance claims, and a
|
||||
const r = config(home, {}, op, "--instance", "alpha");
|
||||
assert.equal(r.code, 3, `${op}: ${r.err}`);
|
||||
assert.equal(r.out, "");
|
||||
assert.match(r.err, new RegExp(`instance-root-mismatch: ${join(home, ".mosaic-beta")} is claimed by instance beta, not alpha`));
|
||||
assert.match(r.err, /instance-root-mismatch: the data root is claimed by instance beta, not alpha/);
|
||||
assert.ok(!r.err.includes(home), r.err);
|
||||
}
|
||||
edit("alpha", join(home, ".mosaic-alpha"));
|
||||
assert.equal(config(home, {}, "instance", "--instance", "alpha").code, 0);
|
||||
@@ -336,7 +343,8 @@ test("T-I7 claims: the instance op refuses a root another instance claims, and a
|
||||
for (const op of ["instance"]) {
|
||||
const r = config(home, { MOSAIC_CONFIG: cfgOf(home, "alpha") }, op);
|
||||
assert.equal(r.code, 3, r.err);
|
||||
assert.match(r.err, new RegExp(`instance-root-mismatch: ${join(home, ".mosaic-alpha")} is claimed by instance alpha; select it with --instance alpha`));
|
||||
assert.match(r.err, /instance-root-mismatch: the data root is claimed by instance alpha; select it with --instance alpha/);
|
||||
assert.ok(!r.err.includes(home), r.err);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import { existsSync, readFileSync, readdirSync, statSync, mkdirSync, writeFileSy
|
||||
import { join, basename, isAbsolute, resolve } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { readRegistration, samePath, SeatError, LAYOUTS } from "../../seat/src/seat.mjs";
|
||||
import { readRegistration, samePath, SeatError, LAYOUTS, plainKey } from "../../seat/src/seat.mjs";
|
||||
|
||||
import { discoverDiscordAgents, inspectDiscord, newestDiscordSession } from './discord.mjs';
|
||||
|
||||
@@ -32,17 +32,18 @@ export function defaultConfigPath() {
|
||||
}
|
||||
|
||||
// Fail closed: the config must exist, parse, and name an absolute dataRoot.
|
||||
// Refusals name the config, never its path (#1873).
|
||||
export function loadConfig(path = defaultConfigPath()) {
|
||||
if (!existsSync(path)) throw new ConfigError(`config not found: ${path}`);
|
||||
if (!existsSync(path)) throw new ConfigError("config not found");
|
||||
let raw;
|
||||
try {
|
||||
raw = parseJsonFileText(readFileSync(path, "utf8"), path);
|
||||
} catch (error) {
|
||||
throw new ConfigError(`config is not valid JSON: ${path}${error.code ? ` (${error.code})` : ""}`);
|
||||
throw new ConfigError(`config is not valid JSON${error.code ? ` (${error.code})` : ""}`);
|
||||
}
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new ConfigError(`config is not an object: ${path}`);
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new ConfigError("config is not an object");
|
||||
if (typeof raw.dataRoot !== "string" || !isAbsolute(raw.dataRoot)) {
|
||||
throw new ConfigError(`config.dataRoot must be an absolute path: ${path}`);
|
||||
throw new ConfigError("config.dataRoot must be an absolute path");
|
||||
}
|
||||
return { dataRoot: raw.dataRoot };
|
||||
}
|
||||
@@ -230,11 +231,11 @@ export function loadSeen(boardDir) {
|
||||
try {
|
||||
parsed = parseJsonFileText(readFileSync(path, "utf8"), path);
|
||||
} catch (error) {
|
||||
throw new ConfigError(`seen marks file is not valid JSON: ${path}${error.code ? ` (${error.code})` : ""}`);
|
||||
throw new ConfigError(`seen marks file seen.json is not valid JSON${error.code ? ` (${error.code})` : ""}`);
|
||||
}
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new ConfigError(`seen marks file must be a JSON object: ${path}`);
|
||||
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new ConfigError("seen marks file seen.json must be a JSON object");
|
||||
for (const [k, v] of Object.entries(parsed)) {
|
||||
if (typeof v !== "string") throw new ConfigError(`seen marks file has a non-string value for ${JSON.stringify(k)}: ${path}`);
|
||||
if (typeof v !== "string") throw new ConfigError(`seen marks file seen.json has a non-string value for ${plainKey(k)}`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
// #1873 (redact6): a control-board refusal names the config, the seen marks file or the seat
|
||||
// by label, never a host path. The sentinel is a component of every scratch path, so a refusal
|
||||
// that quotes a path quotes the sentinel too; each test also checks the scratch root and the label.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { ConfigError, loadConfig, loadRegistrations, loadSeen } from '../src/scan.mjs';
|
||||
import { readRegistration, registrationPath, SeatError } from '../../seat/src/seat.mjs';
|
||||
import { startServer } from '../src/serve.mjs';
|
||||
|
||||
const secret = 'sk-test-NOT-A-SECRET-1234';
|
||||
function scratch(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'r6-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const dir = join(root, secret);
|
||||
mkdirSync(dir);
|
||||
return { root, dir };
|
||||
}
|
||||
function clean(text, root) {
|
||||
assert.ok(!text.includes(root), text);
|
||||
assert.doesNotMatch(text, /sk-test|NOT-A-SECRET/);
|
||||
}
|
||||
function labelled(label, root) {
|
||||
return error => {
|
||||
assert.ok(error instanceof ConfigError, error.stack);
|
||||
assert.equal(error.message, label);
|
||||
clean(error.message, root);
|
||||
return true;
|
||||
};
|
||||
}
|
||||
function write(file, text) {
|
||||
mkdirSync(dirname(file), { recursive: true });
|
||||
writeFileSync(file, text);
|
||||
return file;
|
||||
}
|
||||
|
||||
test('config refusals name the config, not its path', t => {
|
||||
const { root, dir } = scratch(t);
|
||||
assert.throws(() => loadConfig(join(dir, 'config.json')), labelled('config not found', root));
|
||||
assert.throws(() => loadConfig(write(join(dir, 'array.json'), '[]')), labelled('config is not an object', root));
|
||||
assert.throws(() => loadConfig(write(join(dir, 'relative.json'), '{"dataRoot":"data"}')), labelled('config.dataRoot must be an absolute path', root));
|
||||
});
|
||||
|
||||
test('seen marks refusals name seen.json, not its path', t => {
|
||||
const { root, dir } = scratch(t);
|
||||
write(join(dir, 'seen.json'), '[]');
|
||||
assert.throws(() => loadSeen(dir), labelled('seen marks file seen.json must be a JSON object', root));
|
||||
write(join(dir, 'seen.json'), '{"a":1}');
|
||||
assert.throws(() => loadSeen(dir), labelled('seen marks file seen.json has a non-string value for "a"', root));
|
||||
});
|
||||
|
||||
test('a bad registration is reported by layout and seat, never by its path', t => {
|
||||
const { root, dir } = scratch(t);
|
||||
write(registrationPath(dir, 'builder', 'repo'), '{}');
|
||||
const { registrations, errors } = loadRegistrations(dir);
|
||||
assert.deepEqual(registrations, []);
|
||||
assert.equal(errors.length, 1);
|
||||
assert.match(errors[0], /^seat repo\/builder: .*: registration repo\/builder$/);
|
||||
clean(errors[0], root);
|
||||
});
|
||||
|
||||
// Review round (#1873): a key or name that could be a host path is described, never echoed.
|
||||
test('a seen marks key or a registration field that is not a plain name is described, never echoed', t => {
|
||||
const { root, dir } = scratch(t);
|
||||
writeFileSync(join(dir, 'seen.json'), JSON.stringify({ [join(dir, 'host')]: 1 }));
|
||||
assert.throws(() => loadSeen(dir), labelled('seen marks file seen.json has a non-string value for that is not a plain name', root));
|
||||
const f = registrationPath(dir, 'builder', 'repo');
|
||||
mkdirSync(dirname(f), { recursive: true });
|
||||
writeFileSync(f, JSON.stringify({ [join(dir, 'host')]: 1 }));
|
||||
assert.throws(() => readRegistration(dir, 'builder', 'repo'), e => {
|
||||
assert.ok(e instanceof SeatError, e.stack);
|
||||
assert.equal(e.exitCode, 2);
|
||||
assert.equal(e.message, 'registration has an unknown field: that is not a plain name: registration repo/builder');
|
||||
clean(e.message, root);
|
||||
return true;
|
||||
});
|
||||
});
|
||||
|
||||
test('an invalid seat name or layout states the constraint, never the value, and keeps exit 4', t => {
|
||||
const { root, dir } = scratch(t);
|
||||
assert.throws(() => registrationPath(dir, join(dir, 'x'), 'repo'), e => e instanceof SeatError && e.exitCode === 4 && e.message === 'invalid seat name (lowercase letters, digits, ., _ or -, at most 64)' && (clean(e.message, root), true));
|
||||
assert.throws(() => registrationPath(dir, 'builder', join(dir, 'x')), e => e instanceof SeatError && e.exitCode === 4 && e.message === 'invalid layout (one of repo, fleet, unknown)' && (clean(e.message, root), true));
|
||||
});
|
||||
|
||||
// The board API returns a scan refusal in its JSON body, so the HTTP body carries the label too.
|
||||
test('a seen marks refusal reaches the board API body by label, never by path', async t => {
|
||||
const { root, dir } = scratch(t);
|
||||
write(join(dir, 'seen.json'), '[]');
|
||||
const server = await startServer({ host: '127.0.0.1', port: 0, specs: [], boardDir: dir, isAlive: () => true, page: '<html></html>' });
|
||||
t.after(() => new Promise(done => server.close(done)));
|
||||
const res = await fetch(`http://127.0.0.1:${server.address().port}/api/board`);
|
||||
const body = await res.text();
|
||||
assert.equal(res.status, 500);
|
||||
assert.deepEqual(JSON.parse(body), { error: 'seen marks file seen.json must be a JSON object' });
|
||||
clean(body, root);
|
||||
});
|
||||
@@ -6,10 +6,13 @@ import { dirname, join } from 'node:path';
|
||||
const secret = 'sk-test-NOT-A-SECRET-1234';
|
||||
// JSON.parse quotes a prefix near this fault on Node 26; check partial leaks too.
|
||||
const malformed = secret;
|
||||
// #1873: the sentinel is a path component, so a refusal that quotes a path quotes it too.
|
||||
function scratch(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'r3-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
return root;
|
||||
const dir = join(root, secret);
|
||||
mkdirSync(dir);
|
||||
return dir;
|
||||
}
|
||||
function write(file, text = malformed) {
|
||||
mkdirSync(dirname(file), { recursive: true });
|
||||
@@ -21,6 +24,7 @@ function safe(label, ErrorType, properties = {}) {
|
||||
assert.ok(error instanceof ErrorType);
|
||||
for (const [key, value] of Object.entries(properties)) assert.equal(error[key], value);
|
||||
assert.ok(error.message.includes(label), error.message);
|
||||
assert.ok(!error.message.includes(tmpdir()), error.message);
|
||||
assert.doesNotMatch(error.message, /sk-test|Unexpected token/);
|
||||
assert.doesNotMatch(error.stack, /sk-test/);
|
||||
assert.equal(error.cause, undefined);
|
||||
@@ -30,13 +34,13 @@ function safe(label, ErrorType, properties = {}) {
|
||||
import { ConfigError, loadConfig, loadSeen } from '../src/scan.mjs';
|
||||
for (const kind of ['config', 'seen']) test(`${kind} JSON refusal names only the file`, t => {
|
||||
const root = scratch(t), file = write(join(root, kind === 'config' ? 'config.json' : 'seen.json'));
|
||||
assert.throws(() => kind === 'config' ? loadConfig(file) : loadSeen(root), safe(file, ConfigError));
|
||||
assert.throws(() => kind === 'config' ? loadConfig(file) : loadSeen(root), safe(kind === 'config' ? 'config is not valid JSON' : 'seen marks file seen.json is not valid JSON', ConfigError));
|
||||
});
|
||||
for (const kind of ['config', 'seen']) test(`${kind} read refusal retains the filesystem code`, t => {
|
||||
const root = scratch(t), file = join(root, kind === 'config' ? 'config.json' : 'seen.json');
|
||||
mkdirSync(file);
|
||||
assert.throws(() => kind === 'config' ? loadConfig(file) : loadSeen(root), error => {
|
||||
safe(file, ConfigError)(error);
|
||||
safe(kind === 'config' ? 'config is not valid JSON' : 'seen marks file seen.json is not valid JSON', ConfigError)(error);
|
||||
assert.match(error.message, /\(EISDIR\)$/);
|
||||
assert.doesNotMatch(error.message, /illegal operation/);
|
||||
return true;
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
import { parseJsonFileText } from "../../organization/src/util.mjs";
|
||||
import { appendFileSync, existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { DiscordError } from "./errors.mjs";
|
||||
import { DiscordError, quietFs, quietRead } from "./errors.mjs";
|
||||
|
||||
export const APPROVE_WORD = "approve";
|
||||
export const CUSTOM_ID_PREFIX = "approve:";
|
||||
@@ -95,19 +95,29 @@ export function appendApproval(dir, entry) {
|
||||
if (!isObject(entry) || typeof entry.kind !== "string") throw new DiscordError("approvals: entry needs a kind", 1);
|
||||
const line = JSON.stringify(entry);
|
||||
if (line.includes("\n")) throw new DiscordError("approvals: line must not contain a newline", 1);
|
||||
appendFileSync(approvalsPath(dir), line + "\n", { mode: 0o600 });
|
||||
try {
|
||||
appendFileSync(approvalsPath(dir), line + "\n", { mode: 0o600 });
|
||||
} catch (e) {
|
||||
throw quietFs(e, "approvals.jsonl", "append to");
|
||||
}
|
||||
}
|
||||
|
||||
export function readApprovals(dir) {
|
||||
const path = approvalsPath(dir);
|
||||
if (!existsSync(path)) return [];
|
||||
const out = [];
|
||||
for (const [i, line] of readFileSync(path, "utf8").split("\n").entries()) {
|
||||
let text;
|
||||
try {
|
||||
text = readFileSync(path, "utf8");
|
||||
} catch (e) {
|
||||
throw quietRead(e, "approvals.jsonl");
|
||||
}
|
||||
for (const [i, line] of text.split("\n").entries()) {
|
||||
if (line.length === 0) continue;
|
||||
try {
|
||||
out.push(parseJsonFileText(line, `${path}:${i + 1}`));
|
||||
} catch {
|
||||
throw new DiscordError(`${path}:${i + 1}: not valid JSON`);
|
||||
throw new DiscordError(`approvals.jsonl:${i + 1}: not valid JSON`);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
|
||||
@@ -22,14 +22,14 @@ import { parseJsonFileText } from "../../organization/src/util.mjs";
|
||||
import { existsSync, lstatSync, readFileSync, realpathSync, statSync } from "node:fs";
|
||||
import { loadWebConfig } from "./web.mjs";
|
||||
import { loadSetsparkConfig } from "./setspark.mjs";
|
||||
import { isAbsolute, join, resolve, sep } from "node:path";
|
||||
import { basename, isAbsolute, join, resolve, sep } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import { DiscordError } from "./errors.mjs";
|
||||
import { BINDING_NAME, DiscordError, plainKey, quietRead } from "./errors.mjs";
|
||||
import { TOOL_DEFAULTS } from "./tools.mjs";
|
||||
import { loadGitConfig } from "./git.mjs";
|
||||
|
||||
export const BINDING_VERSION = 1;
|
||||
export const BINDING_NAME = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
||||
export { BINDING_NAME };
|
||||
export const SNOWFLAKE = /^[0-9]{17,20}$/;
|
||||
export const CHANNEL_MODES = Object.freeze(["open", "mention"]);
|
||||
export const THINKING_LEVELS = Object.freeze(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
|
||||
@@ -56,16 +56,17 @@ export function defaultConfigPath(env = process.env) {
|
||||
}
|
||||
|
||||
// Only dataRoot is read here; scripts/mosaic-config.mjs owns full validation.
|
||||
// Refusals name the config, never its path (#1873).
|
||||
export function loadDataRoot(path = defaultConfigPath()) {
|
||||
if (!existsSync(path)) throw new DiscordError(`config not found: ${path}`);
|
||||
if (!existsSync(path)) throw new DiscordError("config not found");
|
||||
let raw;
|
||||
try {
|
||||
raw = parseJsonFileText(readFileSync(path, "utf8"), path);
|
||||
} catch (error) {
|
||||
throw new DiscordError(`config is not valid JSON: ${path}${error.code ? ` (${error.code})` : ""}`);
|
||||
throw new DiscordError(`config is not valid JSON${error.code ? ` (${error.code})` : ""}`);
|
||||
}
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new DiscordError(`config is not an object: ${path}`);
|
||||
if (typeof raw.dataRoot !== "string" || !isAbsolute(raw.dataRoot)) throw new DiscordError(`config.dataRoot must be an absolute path: ${path}`);
|
||||
if (!raw || typeof raw !== "object" || Array.isArray(raw)) throw new DiscordError("config is not an object");
|
||||
if (typeof raw.dataRoot !== "string" || !isAbsolute(raw.dataRoot)) throw new DiscordError("config.dataRoot must be an absolute path");
|
||||
return raw.dataRoot;
|
||||
}
|
||||
|
||||
@@ -73,13 +74,16 @@ export function discordDir(dataRoot) {
|
||||
return join(dataRoot, "discord");
|
||||
}
|
||||
|
||||
// The name is checked, never echoed: a bad one could be a host path (#1873).
|
||||
const INVALID_BINDING_NAME = "invalid binding name (lowercase letters, digits, ., _ or -, at most 64)";
|
||||
|
||||
export function bindingPath(dataRoot, name) {
|
||||
if (!BINDING_NAME.test(String(name))) throw new DiscordError(`invalid binding name: ${JSON.stringify(name)}`, 4);
|
||||
if (!BINDING_NAME.test(String(name))) throw new DiscordError(INVALID_BINDING_NAME, 4);
|
||||
return join(discordDir(dataRoot), `${name}.json`);
|
||||
}
|
||||
|
||||
export function bindingDataDir(dataRoot, name) {
|
||||
if (!BINDING_NAME.test(String(name))) throw new DiscordError(`invalid binding name: ${JSON.stringify(name)}`, 4);
|
||||
if (!BINDING_NAME.test(String(name))) throw new DiscordError(INVALID_BINDING_NAME, 4);
|
||||
return join(discordDir(dataRoot), name);
|
||||
}
|
||||
|
||||
@@ -89,7 +93,7 @@ function isObject(v) {
|
||||
|
||||
function onlyKeys(obj, allowed, where) {
|
||||
for (const key of Object.keys(obj)) {
|
||||
if (!allowed.includes(key)) throw new DiscordError(`${where}: unknown key ${JSON.stringify(key)}`);
|
||||
if (!allowed.includes(key)) throw new DiscordError(`${where}: unknown key ${plainKey(key)}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -274,38 +278,49 @@ export function reloadDiff(current, next) {
|
||||
}
|
||||
|
||||
// A private file: regular, not a symlink, owner-only (0600), non-empty.
|
||||
// `what` is the label a refusal names; the path never appears (#1873).
|
||||
export function checkPrivateFile(path, what) {
|
||||
let st;
|
||||
try {
|
||||
st = lstatSync(path);
|
||||
} catch {
|
||||
throw new DiscordError(`${what} not found: ${path}`);
|
||||
throw new DiscordError(`${what} not found`);
|
||||
}
|
||||
if (st.isSymbolicLink()) throw new DiscordError(`${what} must not be a symlink: ${path}`);
|
||||
if (!st.isFile()) throw new DiscordError(`${what} is not a regular file: ${path}`);
|
||||
if (st.isSymbolicLink()) throw new DiscordError(`${what} must not be a symlink`);
|
||||
if (!st.isFile()) throw new DiscordError(`${what} is not a regular file`);
|
||||
const mode = st.mode & 0o777;
|
||||
if (mode !== 0o600) throw new DiscordError(`${what} must be mode 0600, is ${mode.toString(8).padStart(4, "0")}: ${path}`);
|
||||
if (st.size === 0) throw new DiscordError(`${what} is empty: ${path}`);
|
||||
if (mode !== 0o600) throw new DiscordError(`${what} must be mode 0600, is ${mode.toString(8).padStart(4, "0")}`);
|
||||
if (st.size === 0) throw new DiscordError(`${what} is empty`);
|
||||
return st;
|
||||
}
|
||||
|
||||
// The binding is named by its file's name when that is a valid binding name
|
||||
// (bindingPath() makes it so), never by its path.
|
||||
export function loadBinding(path) {
|
||||
checkPrivateFile(path, "binding");
|
||||
const name = basename(String(path), ".json");
|
||||
const label = BINDING_NAME.test(name) ? `binding ${name}` : "binding";
|
||||
checkPrivateFile(path, label);
|
||||
let raw;
|
||||
try {
|
||||
raw = parseJsonFileText(readFileSync(path, "utf8"), path);
|
||||
} catch (error) {
|
||||
throw new DiscordError(`binding is not valid JSON: ${path}${error.code ? ` (${error.code})` : ""}`);
|
||||
throw new DiscordError(`${label} is not valid JSON${error.code ? ` (${error.code})` : ""}`);
|
||||
}
|
||||
return validateBinding(raw, `binding ${path}`);
|
||||
return validateBinding(raw, label);
|
||||
}
|
||||
|
||||
// The token is read once into memory and handed to the REST and gateway
|
||||
// clients. It is never printed, journaled, or put on a command line.
|
||||
export function readToken(binding) {
|
||||
checkPrivateFile(binding.tokenFile, "token file");
|
||||
const token = readFileSync(binding.tokenFile, "utf8").trim();
|
||||
if (!/^[A-Za-z0-9._-]{20,}$/.test(token)) throw new DiscordError(`token file does not hold a bot token: ${binding.tokenFile}`);
|
||||
// A file that changes after its check fails with a native error that carries the path (#1873).
|
||||
let token;
|
||||
try {
|
||||
token = readFileSync(binding.tokenFile, "utf8").trim();
|
||||
} catch (e) {
|
||||
throw quietRead(e, "token file");
|
||||
}
|
||||
if (!/^[A-Za-z0-9._-]{20,}$/.test(token)) throw new DiscordError("token file does not hold a bot token");
|
||||
return token;
|
||||
}
|
||||
|
||||
@@ -315,22 +330,34 @@ export function readToken(binding) {
|
||||
// the model's prompt, so this is the boundary that keeps host files out of
|
||||
// Discord Sage (Q14, Q16). Every file must be a regular non-empty file.
|
||||
export function resolveContextFiles(binding, repo) {
|
||||
const root = realpathSync(repo);
|
||||
return binding.context.files.map((f) => {
|
||||
let root;
|
||||
try {
|
||||
root = realpathSync(repo);
|
||||
} catch (e) {
|
||||
throw quietRead(e, "the repository");
|
||||
}
|
||||
// Refusals name the entry by its index in context.files, never its path (#1873).
|
||||
return binding.context.files.map((f, i) => {
|
||||
const at = `context.files[${i}]`;
|
||||
if (typeof f !== "string" || f.length === 0) throw new DiscordError("context file must be a non-empty string");
|
||||
if (isAbsolute(f)) throw new DiscordError(`context file must be repository-relative: ${f}`);
|
||||
if (f.split(/[\\/]/).includes("..")) throw new DiscordError(`context file must not escape the repository: ${f}`);
|
||||
if (isAbsolute(f)) throw new DiscordError(`context file must be repository-relative: ${at}`);
|
||||
if (f.split(/[\\/]/).includes("..")) throw new DiscordError(`context file must not escape the repository: ${at}`);
|
||||
const path = resolve(root, f);
|
||||
let st;
|
||||
try {
|
||||
st = lstatSync(path);
|
||||
} catch {
|
||||
throw new DiscordError(`missing context file: ${path}`);
|
||||
throw new DiscordError(`missing context file: ${at}`);
|
||||
}
|
||||
if (st.isSymbolicLink()) throw new DiscordError(`context file must not be a symlink: ${path}`);
|
||||
if (!st.isFile() || st.size === 0) throw new DiscordError(`context file is not a regular non-empty file: ${path}`);
|
||||
const real = realpathSync(path);
|
||||
if (real !== path || !real.startsWith(root + sep)) throw new DiscordError(`context file resolves outside the repository: ${f}`);
|
||||
if (st.isSymbolicLink()) throw new DiscordError(`context file must not be a symlink: ${at}`);
|
||||
if (!st.isFile() || st.size === 0) throw new DiscordError(`context file is not a regular non-empty file: ${at}`);
|
||||
let real;
|
||||
try {
|
||||
real = realpathSync(path);
|
||||
} catch (e) {
|
||||
throw quietRead(e, `context file ${at}`);
|
||||
}
|
||||
if (real !== path || !real.startsWith(root + sep)) throw new DiscordError(`context file resolves outside the repository: ${at}`);
|
||||
return path;
|
||||
});
|
||||
}
|
||||
@@ -363,18 +390,28 @@ function webConfig(raw, where) {
|
||||
|
||||
export function resolveToolRoots(binding, { dataRoot }) {
|
||||
if (!binding.tools) return null;
|
||||
const data = existsSync(dataRoot) ? realpathSync(dataRoot) : resolve(dataRoot);
|
||||
let data;
|
||||
try {
|
||||
data = existsSync(dataRoot) ? realpathSync(dataRoot) : resolve(dataRoot);
|
||||
} catch (e) {
|
||||
throw quietRead(e, "the data root");
|
||||
}
|
||||
const roots = binding.tools.roots.map((r) => {
|
||||
let st;
|
||||
try {
|
||||
st = lstatSync(r.path);
|
||||
} catch {
|
||||
throw new DiscordError(`tool root ${r.name} does not exist: ${r.path}`);
|
||||
throw new DiscordError(`tool root ${r.name} does not exist`);
|
||||
}
|
||||
if (st.isSymbolicLink()) throw new DiscordError(`tool root ${r.name} must not be a symlink: ${r.path}`);
|
||||
if (!st.isDirectory()) throw new DiscordError(`tool root ${r.name} is not a directory: ${r.path}`);
|
||||
const real = realpathSync(r.path);
|
||||
if (real === data || real.startsWith(data + sep) || data.startsWith(real + sep)) throw new DiscordError(`tool root ${r.name} overlaps the data root: ${r.path}`);
|
||||
if (st.isSymbolicLink()) throw new DiscordError(`tool root ${r.name} must not be a symlink`);
|
||||
if (!st.isDirectory()) throw new DiscordError(`tool root ${r.name} is not a directory`);
|
||||
let real;
|
||||
try {
|
||||
real = realpathSync(r.path);
|
||||
} catch (e) {
|
||||
throw quietRead(e, `tool root ${r.name}`);
|
||||
}
|
||||
if (real === data || real.startsWith(data + sep) || data.startsWith(real + sep)) throw new DiscordError(`tool root ${r.name} overlaps the data root`);
|
||||
return { name: r.name, path: real, write: r.write, ...(r.git ? { git: { branch: r.git.branch, identity: r.git.identity, tokenFile: r.git.tokenFile, author: `${r.git.author.name} <${r.git.author.email}>`, ...(r.git.protocol ? { protocol: r.git.protocol } : {}) } } : {}) };
|
||||
});
|
||||
// setspark carries only the keys loadSetsparkConfig accepts; the extension
|
||||
|
||||
@@ -46,22 +46,22 @@
|
||||
// 3 refused by a brake (STOP present or the binding held; a supervisor must
|
||||
// not retry); 4 usage.
|
||||
|
||||
import { existsSync, mkdirSync, mkdtempSync, writeFileSync, statSync, readdirSync } from "node:fs";
|
||||
import { existsSync, mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { createHash } from "node:crypto";
|
||||
import { DiscordError } from "./errors.mjs";
|
||||
import { DiscordError, printable } from "./errors.mjs";
|
||||
import { assertProcessing, processingStatus, ProcessingError } from "../../processing-control/src/state.mjs";
|
||||
import { registerRuntime, updateRuntime, unregisterRuntime, processIdentity } from "../../processing-control/src/runtimes.mjs";
|
||||
import { loadSeatSets } from "../../organization/src/library-seats.mjs";
|
||||
import { defaultConfigPath, loadDataRoot, bindingPath, bindingDataDir, loadBinding, readToken, resolveContextFiles, resolveToolRoots, reloadDiff } from "./binding.mjs";
|
||||
import { createRest } from "./rest.mjs";
|
||||
import { createGateway, CONNECTOR_INTENTS } from "./gateway.mjs";
|
||||
import { createEngine, buildPiArgs } from "./engine-pi.mjs";
|
||||
import { createEngine, buildPiArgs, sessionContinues } from "./engine-pi.mjs";
|
||||
import { TOOLS_ENV, enabledToolNames } from "./tools.mjs";
|
||||
import { createSetsparkApi } from "./setspark.mjs";
|
||||
import { assembleContext } from "./context.mjs";
|
||||
import { createConnector } from "./connector.mjs";
|
||||
import { ensureJournal, requestStop, requestProcessingStop, stopRequested, readPid, stopTarget, writePid, clearPid, unlock, recover, appendReload, BRAKE_EXIT } from "./journal.mjs";
|
||||
import { ensureJournal, requestStop, requestProcessingStop, stopRequested, readPid, stopTarget, writePid, clearPid, unlock, recover, appendReload, journalLabel, BRAKE_EXIT } from "./journal.mjs";
|
||||
|
||||
const USAGE = [
|
||||
"usage: mosaic-discord check <binding> [--config PATH] [--repo PATH] [--offline]",
|
||||
@@ -114,7 +114,7 @@ function checkLibraryEngine(configDirectory, binding) {
|
||||
try {
|
||||
sets = loadSeatSets({ dir: configDirectory });
|
||||
} catch (err) {
|
||||
throw new DiscordError(`discord-business-unreadable: ${err.message}`, 2);
|
||||
throw new DiscordError(`discord-business-unreadable: ${printable(err, "a business file")}`, 2);
|
||||
}
|
||||
if (!sets.librarySeats.has(binding.seat)) return;
|
||||
if (binding.engine.provider !== "mosaic-bus") {
|
||||
@@ -135,7 +135,7 @@ function prepare(opts) {
|
||||
const contextFiles = resolveContextFiles(binding, opts.repo);
|
||||
const toolRoots = resolveToolRoots(binding, { dataRoot });
|
||||
const pi = join(opts.repo, "node_modules", ".bin", "pi");
|
||||
if (!existsSync(pi)) throw new DiscordError(`pi not found at ${pi}; run npm ci in the repository`);
|
||||
if (!existsSync(pi)) throw new DiscordError("pi not found at node_modules/.bin/pi in the repository; run npm ci in the repository");
|
||||
const journalDir = bindingDataDir(dataRoot, binding.name);
|
||||
const sessionDir = join(dataRoot, "sessions", `discord-${binding.name}`);
|
||||
return { dataRoot, bindingFile, binding, contextFiles, toolRoots, pi, journalDir, sessionDir };
|
||||
@@ -205,7 +205,7 @@ async function run(opts) {
|
||||
const outcome = recover(journalDir);
|
||||
if (outcome === "cleared") warn("supervised start: run.lock left by a process that is gone was removed");
|
||||
}
|
||||
if (stopRequested(journalDir)) throw new DiscordError(`STOP is present in ${journalDir}; remove it to run`, BRAKE_EXIT);
|
||||
if (stopRequested(journalDir)) throw new DiscordError(`STOP is present in ${journalLabel(journalDir)}; remove it to run`, BRAKE_EXIT);
|
||||
writePid(journalDir, process.pid);
|
||||
let managed = null;
|
||||
const cleanupPid = () => {
|
||||
@@ -234,7 +234,7 @@ async function run(opts) {
|
||||
const promptFile = join(launch, "context.md");
|
||||
writeFileSync(promptFile, snapshot.text, { mode: 0o600 });
|
||||
writeFileSync(join(launch, "context.sha256"), `${snapshot.sha256} context.md\n`, { mode: 0o600 });
|
||||
const continueSession = existsSync(sessionDir) && statSync(sessionDir).isDirectory() && hasJsonl(sessionDir);
|
||||
const continueSession = sessionContinues(sessionDir);
|
||||
|
||||
const rest = createRest({ token, log: warn });
|
||||
const gw = await rest.getGatewayBot();
|
||||
@@ -272,7 +272,7 @@ async function run(opts) {
|
||||
clearInterval(processingWatch);
|
||||
exitCode = code;
|
||||
warn(immediate ? 'stopping: processing halted' : "stopping: finishing in-flight turns");
|
||||
connector.stop({ immediate }).catch((err) => warn(`stop failed: ${err.message}`)).finally(() => {
|
||||
connector.stop({ immediate }).catch((err) => warn(`stop failed: ${printable(err)}`)).finally(() => {
|
||||
cleanupPid();
|
||||
process.exit(exitCode);
|
||||
});
|
||||
@@ -317,8 +317,8 @@ async function run(opts) {
|
||||
const n = (d) => `+${d.added.length} -${d.removed.length} ~${d.changed.length}`;
|
||||
warn(`reload applied: channels ${n(diff.channels)}, users ${n(diff.users)}, limits ${diff.limits.length ? diff.limits.join(",") : "unchanged"}${diff.guildName ? ", guildName" : ""}`);
|
||||
} catch (err) {
|
||||
appendReload(journalDir, { at, outcome: "refused", error: err.message });
|
||||
warn(`reload refused, binding unchanged: ${err.message}`);
|
||||
appendReload(journalDir, { at, outcome: "refused", error: printable(err) });
|
||||
warn(`reload refused, binding unchanged: ${printable(err)}`);
|
||||
}
|
||||
};
|
||||
let reloadChain = Promise.resolve();
|
||||
@@ -332,13 +332,6 @@ async function run(opts) {
|
||||
}
|
||||
}
|
||||
|
||||
function hasJsonl(dir) {
|
||||
try {
|
||||
return readdirSync(dir).some((f) => f.endsWith(".jsonl"));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function stop(opts) {
|
||||
const dataRoot = loadDataRoot(opts.config);
|
||||
@@ -413,7 +406,8 @@ main().then((code) => {
|
||||
if (code !== null) process.exit(code);
|
||||
}).catch((err) => {
|
||||
const code = err instanceof ProcessingError ? BRAKE_EXIT : err instanceof DiscordError ? err.exitCode : 1;
|
||||
warn(err.message);
|
||||
// Never a native error's message: it carries the path (#1873).
|
||||
warn(printable(err));
|
||||
if (err.details && err.details.nonces) warn(`nonces: ${err.details.nonces.join(", ")}`);
|
||||
process.exit(code);
|
||||
});
|
||||
@@ -35,7 +35,7 @@ import { reloadDiff } from "./binding.mjs";
|
||||
import { envelope, splitReply } from "./context.mjs";
|
||||
import {
|
||||
ensureJournal, appendInbox, readInboxIds, appendOutbox, unresolvedOutbox, appendDrop,
|
||||
writeTurn, appendAdmission, countAdmissionsOn, appendNotice, noticeOn, utcDate, stopRequested,
|
||||
writeTurn, appendAdmission, countAdmissionsOn, appendNotice, noticeOn, utcDate, stopRequested, journalLabel,
|
||||
} from "./journal.mjs";
|
||||
import { RestOutcome } from "./rest.mjs";
|
||||
import { DiscordError } from "./errors.mjs";
|
||||
@@ -596,7 +596,7 @@ ${status === "not-posted" ? APPROVAL_LINES.pressAgain : APPROVAL_LINES.failed}`;
|
||||
async start() {
|
||||
processing();
|
||||
if (state.started) throw new DiscordError("connector already started", 1);
|
||||
if (stopRequested(journalDir)) throw new DiscordError(`STOP is present in ${journalDir}; remove it to run`, 1);
|
||||
if (stopRequested(journalDir)) throw new DiscordError(`STOP is present in ${journalLabel(journalDir)}; remove it to run`, 1);
|
||||
const reconciled = await reconcile();
|
||||
const approvals = await reconcileApprovals();
|
||||
processing();
|
||||
|
||||
@@ -32,8 +32,9 @@
|
||||
// without the connector noticing: the contract is start(), prompt(), stop().
|
||||
|
||||
import { spawn as nodeSpawn } from "node:child_process";
|
||||
import { existsSync, readdirSync, statSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { DiscordError } from "./errors.mjs";
|
||||
import { DiscordError, printable, quietFs } from "./errors.mjs";
|
||||
import { enabledToolNames } from "./tools.mjs";
|
||||
import { assertProcessing, processingStatus } from "../../processing-control/src/state.mjs";
|
||||
import { registerRuntime, updateRuntime, unregisterRuntime, processIdentity, runtime, runtimeGroupState, createRuntimeSignaller } from "../../processing-control/src/runtimes.mjs";
|
||||
@@ -51,6 +52,25 @@ export const TOOLS_EXTENSION = fileURLToPath(new URL("../extension/tools.mjs", i
|
||||
// Older name, kept for the tests and docs that still use it.
|
||||
export const READONLY_TOOLS_EXTENSION = TOOLS_EXTENSION;
|
||||
|
||||
// Whether pi continues the session in sessionDir: it is a directory holding a
|
||||
// .jsonl session file. A stat that fails names the binding's session directory,
|
||||
// never its path (#1873); a listing that fails means a new session, as before.
|
||||
export function sessionContinues(sessionDir) {
|
||||
if (!existsSync(sessionDir)) return false;
|
||||
let st;
|
||||
try {
|
||||
st = statSync(sessionDir);
|
||||
} catch (e) {
|
||||
throw quietFs(e, "the binding's session directory");
|
||||
}
|
||||
if (!st.isDirectory()) return false;
|
||||
try {
|
||||
return readdirSync(sessionDir).some((f) => f.endsWith(".jsonl"));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function buildPiArgs({ provider, model, thinking, sessionDir, appendSystemPromptFile, continueSession, tools = null }) {
|
||||
const args = [...PI_FIXED_ARGS];
|
||||
if (tools) args.push("--no-builtin-tools", "--extension", TOOLS_EXTENSION, "--tools", enabledToolNames(tools).join(","));
|
||||
@@ -393,9 +413,11 @@ export function createEngine({
|
||||
child.stderr.on("data", (chunk) => log(`pi: ${chunk.trimEnd()}`));
|
||||
child.on("error", (err) => {
|
||||
releaseRuntime();
|
||||
log(`engine spawn error: ${err.message}`);
|
||||
state.exited = { code: null, signal: null, error: err.message };
|
||||
failAll("engine-down", `engine failed: ${err.message}`);
|
||||
// A spawn error's message carries the command's path (#1873).
|
||||
const why = printable(err, "pi");
|
||||
log(`engine spawn error: ${why}`);
|
||||
state.exited = { code: null, signal: null, error: why };
|
||||
failAll("engine-down", `engine failed: ${why}`);
|
||||
onExit(state.exited);
|
||||
});
|
||||
child.on("exit", (code, signal) => {
|
||||
|
||||
@@ -1,6 +1,48 @@
|
||||
// One error class for the package. exitCode follows docs/TOOLS.md: 1 operation
|
||||
// failed, 2 invalid data or configuration, 3 refused by a brake (STOP or a
|
||||
// held binding; a supervisor must not retry), 4 usage.
|
||||
// A refusal that names an unknown key quotes it only when it is a plain name;
|
||||
// a key that could carry a host path or a secret is described, never echoed (#1873).
|
||||
export const plainKey = (k) => (/^[A-Za-z_][A-Za-z0-9_-]{0,63}$/.test(String(k)) ? JSON.stringify(k) : "that is not a plain name");
|
||||
// A binding (and seat) name. Defined here, not in binding.mjs, so journal.mjs can label a
|
||||
// journal directory without importing binding.mjs and its config readers; copies of
|
||||
// journal.mjs + errors.mjs (the queue test repo) stay self-contained. binding.mjs re-exports it.
|
||||
export const BINDING_NAME = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
||||
|
||||
// A filesystem call that fails names the file by label, the call and the
|
||||
// error code, never the path Node puts in the message. The same error object
|
||||
// is rethrown, so its type, code and the exit it maps to are unchanged, and
|
||||
// it is marked so the CLI's top-level handler prints it as it is (#1873).
|
||||
const QUIET = Symbol("discord.quiet");
|
||||
const SYSCALL = /^[a-z][a-z0-9_]{0,31}$/;
|
||||
const CODE = /^[A-Z][A-Z0-9_]{0,63}$/;
|
||||
const callOf = (e) => {
|
||||
const word = typeof e.syscall === "string" ? e.syscall.split(" ")[0] : "";
|
||||
return SYSCALL.test(word) ? word : "access";
|
||||
};
|
||||
const codeOf = (e) => (typeof e.code === "string" && CODE.test(e.code) ? e.code : "UNKNOWN");
|
||||
export function quietFs(e, label, verb = null) {
|
||||
if (e && typeof e === "object" && typeof e.code === "string") {
|
||||
e.message = `cannot ${verb ?? callOf(e)} ${label} (${codeOf(e)})`;
|
||||
e[QUIET] = true;
|
||||
}
|
||||
return e;
|
||||
}
|
||||
export const quietRead = (e, label) => quietFs(e, label, "read");
|
||||
|
||||
// What a top-level handler prints for an error (#1873). Our refusals and an
|
||||
// error already labelled at its source print their own message. A native
|
||||
// Node error (it carries a syscall, or a code and a path) that slipped past
|
||||
// every source label prints only its call and code, never its message,
|
||||
// which carries the path; `label` names the file when the error has one.
|
||||
export function printable(err, label = "a discord file") {
|
||||
if (err instanceof DiscordError || (err && err[QUIET] === true)) return String(err.message);
|
||||
if (err && typeof err === "object" && (typeof err.syscall === "string" || (typeof err.code === "string" && typeof err.path === "string"))) {
|
||||
return typeof err.path === "string" ? `cannot ${callOf(err)} ${label} (${codeOf(err)})` : `cannot ${callOf(err)} (${codeOf(err)})`;
|
||||
}
|
||||
return err && typeof err.message === "string" ? err.message : String(err);
|
||||
}
|
||||
|
||||
export class DiscordError extends Error {
|
||||
constructor(message, exitCode = 2, details = undefined) {
|
||||
super(message);
|
||||
|
||||
@@ -46,6 +46,7 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync, lstatSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { plainKey } from "./errors.mjs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
export const GIT_TOOL_NAMES = Object.freeze(["git_status", "git_commit", "git_pull", "git_push"]);
|
||||
@@ -109,7 +110,7 @@ const AUTHOR = /^([^<>\r\n]{1,64}?) <([^<>\s@]+@[^<>\s@]+)>$/;
|
||||
export function loadGitConfig(raw, where, real) {
|
||||
if (!isObject(raw)) throw new Error(`${where}: not an object`);
|
||||
for (const k of Object.keys(raw)) {
|
||||
if (!["branch", "identity", "tokenFile", "author", "protocol"].includes(k)) throw new Error(`${where}: unknown key ${JSON.stringify(k)}`);
|
||||
if (!["branch", "identity", "tokenFile", "author", "protocol"].includes(k)) throw new Error(`${where}: unknown key ${plainKey(k)}`);
|
||||
}
|
||||
if (typeof raw.branch !== "string" || !BRANCH_NAME.test(raw.branch) || raw.branch.includes("..")) throw new Error(`${where}.branch: must be a branch name`);
|
||||
if (typeof raw.identity !== "string" || !IDENTITY.test(raw.identity)) throw new Error(`${where}.identity: must match ${IDENTITY}`);
|
||||
@@ -125,7 +126,7 @@ export function loadGitConfig(raw, where, real) {
|
||||
} catch {
|
||||
// handled below
|
||||
}
|
||||
if (!st || !(st.isDirectory() || st.isFile())) throw new Error(`${where}: ${real} is not a git work tree`);
|
||||
if (!st || !(st.isDirectory() || st.isFile())) throw new Error(`${where}: the root is not a git work tree`);
|
||||
}
|
||||
if (process.execPath.includes("'") || CREDENTIAL_HELPER.includes("'")) throw new Error(`${where}: the node or helper path holds a quote and cannot be a credential helper`);
|
||||
return Object.freeze({
|
||||
@@ -139,12 +140,12 @@ function checkPrivateFile(path, what) {
|
||||
try {
|
||||
st = lstatSync(path);
|
||||
} catch {
|
||||
throw new Error(`${what}: not found: ${path}`);
|
||||
throw new Error(`${what}: not found`);
|
||||
}
|
||||
if (st.isSymbolicLink()) throw new Error(`${what}: must not be a symlink: ${path}`);
|
||||
if (!st.isFile()) throw new Error(`${what}: not a regular file: ${path}`);
|
||||
if ((st.mode & 0o777) !== 0o600) throw new Error(`${what}: must be mode 0600: ${path}`);
|
||||
if (st.size === 0) throw new Error(`${what}: is empty: ${path}`);
|
||||
if (st.isSymbolicLink()) throw new Error(`${what}: must not be a symlink`);
|
||||
if (!st.isFile()) throw new Error(`${what}: not a regular file`);
|
||||
if ((st.mode & 0o777) !== 0o600) throw new Error(`${what}: must be mode 0600`);
|
||||
if (st.size === 0) throw new Error(`${what}: is empty`);
|
||||
}
|
||||
|
||||
// The environment every child sees. No global or system git configuration,
|
||||
@@ -201,7 +202,7 @@ function cap(text) {
|
||||
function run(cmd, args, cwd, env, spawn) {
|
||||
const r = spawn(cmd, args, { cwd, env, encoding: "utf8", timeout: GIT_TIMEOUT_MS, maxBuffer: 4 * 1024 * 1024, stdio: ["ignore", "pipe", "pipe"] });
|
||||
if (r.error && r.error.code === "ETIMEDOUT") throw new GitRefusal(GIT_REFUSAL.TIMEOUT, `${cmd} ${args[0] || ""}`);
|
||||
if (r.error) throw new GitRefusal(GIT_REFUSAL.NO_GIT, `${cmd} could not start (${r.error.code || r.error.message})`);
|
||||
if (r.error) throw new GitRefusal(GIT_REFUSAL.NO_GIT, `${cmd} could not start (${r.error.code || "error"})`);
|
||||
return { status: r.status, stdout: cap(r.stdout || ""), stderr: cap(r.stderr || "") };
|
||||
}
|
||||
|
||||
@@ -209,8 +210,12 @@ function git(root, gitCfg, args, { remote = false, spawn = spawnSync } = {}) {
|
||||
return run("git", args, root.real, gitEnv(gitCfg, { remote }), spawn);
|
||||
}
|
||||
|
||||
// git output reaches a refusal detail, the journal and Discord. A line that
|
||||
// carries a host path (an absolute or home path, or a file: URL) is dropped,
|
||||
// never relayed; repo-relative lines such as a lock owner or a conflict stay (#1873).
|
||||
const HOST_PATH = /(^|[\s'"(=:,[<])~?\/(?!\/)|file:/i;
|
||||
function firstLines(text, n = 5) {
|
||||
return text.split("\n").filter((l) => l.trim().length > 0).slice(0, n).join("\n");
|
||||
return text.split("\n").filter((l) => l.trim().length > 0 && !HOST_PATH.test(l)).slice(0, n).join("\n");
|
||||
}
|
||||
|
||||
// The guard every verb runs first: on the named branch, nothing in
|
||||
|
||||
@@ -25,8 +25,16 @@ import {
|
||||
appendFileSync, closeSync, constants, existsSync, fstatSync, fsyncSync, mkdirSync, openSync, readdirSync, readFileSync, readSync, renameSync, rmdirSync, rmSync,
|
||||
unlinkSync, writeFileSync, writeSync,
|
||||
} from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { DiscordError } from "./errors.mjs";
|
||||
import { basename, join } from "node:path";
|
||||
import { BINDING_NAME, DiscordError, quietFs, quietRead } from "./errors.mjs";
|
||||
|
||||
// Where a refusal says a binding's journal is: its place under the data root
|
||||
// when the directory carries a valid binding name (bindingDataDir() makes it
|
||||
// so), never the host path (#1873).
|
||||
export function journalLabel(dir) {
|
||||
const name = basename(String(dir));
|
||||
return BINDING_NAME.test(name) ? `discord/${name} under the data root` : "the binding's journal directory";
|
||||
}
|
||||
|
||||
export const OUTBOX_STATUS = Object.freeze(["intent", "confirmed", "refused", "unknown"]);
|
||||
|
||||
@@ -38,19 +46,28 @@ export function ensureJournal(dir) {
|
||||
function appendLine(path, record) {
|
||||
const line = JSON.stringify(record);
|
||||
if (line.includes("\n")) throw new DiscordError("journal line must not contain a newline", 1);
|
||||
appendFileSync(path, line + "\n", { mode: 0o600 });
|
||||
try {
|
||||
appendFileSync(path, line + "\n", { mode: 0o600 });
|
||||
} catch (e) {
|
||||
throw quietFs(e, basename(path), "append to");
|
||||
}
|
||||
}
|
||||
|
||||
function readLines(path) {
|
||||
if (!existsSync(path)) return [];
|
||||
const out = [];
|
||||
const text = readFileSync(path, "utf8");
|
||||
let text;
|
||||
try {
|
||||
text = readFileSync(path, "utf8");
|
||||
} catch (e) {
|
||||
throw quietRead(e, basename(path));
|
||||
}
|
||||
for (const [i, line] of text.split("\n").entries()) {
|
||||
if (line.length === 0) continue;
|
||||
try {
|
||||
out.push(parseJsonFileText(line, `${path}:${i + 1}`));
|
||||
} catch {
|
||||
throw new DiscordError(`${path}:${i + 1}: not valid JSON`);
|
||||
throw new DiscordError(`${basename(path)}:${i + 1}: not valid JSON`);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
@@ -109,42 +126,61 @@ export function readDrops(dir) {
|
||||
const TURN_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
||||
|
||||
export function turnPath(dir, id) {
|
||||
if (!TURN_ID.test(String(id))) throw new DiscordError(`invalid turn id: ${JSON.stringify(id)}`, 1);
|
||||
if (!TURN_ID.test(String(id))) throw new DiscordError("invalid turn id (letters, digits, ., _ or -, at most 128)", 1);
|
||||
return join(dir, "turns", `${id}.json`);
|
||||
}
|
||||
|
||||
// A parse refusal or a failed read or write names the turn by its id, never by
|
||||
// host path (PR #1916, #1873); a listed file's id is named only when it passes TURN_ID.
|
||||
const turnLabel = (id) => (TURN_ID.test(id) ? `turn record ${id}` : "turn record");
|
||||
|
||||
export function writeTurn(dir, id, record) {
|
||||
const path = turnPath(dir, id);
|
||||
let fd;
|
||||
try {
|
||||
fd = openSync(path, "wx", 0o600);
|
||||
} catch (err) {
|
||||
if (err.code === "EEXIST") throw new DiscordError(`turn record already exists: ${path}`, 1);
|
||||
throw err;
|
||||
if (err.code === "EEXIST") throw new DiscordError(`turn record already exists: ${id}`, 1);
|
||||
throw quietFs(err, turnLabel(String(id)), "write");
|
||||
}
|
||||
try {
|
||||
writeSync(fd, JSON.stringify({ ...record, id }, null, 2) + "\n");
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
try {
|
||||
writeSync(fd, JSON.stringify({ ...record, id }, null, 2) + "\n");
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
} catch (err) {
|
||||
throw quietFs(err, turnLabel(String(id)), "write");
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
// A parse refusal names the turn by its id, never by host path (PR #1916); a
|
||||
// listed file's id is named only when it passes TURN_ID.
|
||||
const turnLabel = (id) => (TURN_ID.test(id) ? `turn record ${id}` : "turn record");
|
||||
function readTurnFile(path, id) {
|
||||
let text;
|
||||
try {
|
||||
text = readFileSync(path, "utf8");
|
||||
} catch (e) {
|
||||
throw quietRead(e, turnLabel(id));
|
||||
}
|
||||
return parseJsonFileText(text, path, turnLabel(id));
|
||||
}
|
||||
|
||||
export function readTurn(dir, id) {
|
||||
const path = turnPath(dir, id);
|
||||
return parseJsonFileText(readFileSync(path, "utf8"), path, turnLabel(String(id)));
|
||||
return readTurnFile(turnPath(dir, id), String(id));
|
||||
}
|
||||
|
||||
export function listTurns(dir) {
|
||||
const turns = join(dir, "turns");
|
||||
if (!existsSync(turns)) return [];
|
||||
return readdirSync(turns)
|
||||
let names;
|
||||
try {
|
||||
names = readdirSync(turns);
|
||||
} catch (e) {
|
||||
throw quietRead(e, `the turns directory in ${journalLabel(dir)}`);
|
||||
}
|
||||
return names
|
||||
.filter((f) => f.endsWith(".json"))
|
||||
.map((f) => parseJsonFileText(readFileSync(join(turns, f), "utf8"), join(turns, f), turnLabel(f.slice(0, -".json".length))));
|
||||
.map((f) => readTurnFile(join(turns, f), f.slice(0, -".json".length)));
|
||||
}
|
||||
|
||||
// The daily ceiling counts admissions on the current UTC date. An admission
|
||||
@@ -189,21 +225,38 @@ function stopLock(dir, action) {
|
||||
const lock = join(dir, '.stop.lock');
|
||||
try { mkdirSync(lock, { mode: 0o700 }); }
|
||||
catch { throw new DiscordError('STOP update is locked; refusing', 1); }
|
||||
try { return action(); } finally { rmdirSync(lock); }
|
||||
try { return action(); } finally {
|
||||
try { rmdirSync(lock); } catch (e) { throw quietFs(e, `.stop.lock in ${journalLabel(dir)}`, "remove"); }
|
||||
}
|
||||
}
|
||||
|
||||
// A STOP write or removal that fails names STOP in the journal, never its path (#1873).
|
||||
const stopLabel = (dir) => `STOP in ${journalLabel(dir)}`;
|
||||
|
||||
function appendStop(dir, reason) {
|
||||
const path = stopPath(dir);
|
||||
const fd = openSync(path, constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW, 0o600);
|
||||
try {
|
||||
writeSync(fd, JSON.stringify({ at: new Date().toISOString(), reason }) + "\n");
|
||||
fsyncSync(fd);
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
const fd = openSync(path, constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW, 0o600);
|
||||
try {
|
||||
writeSync(fd, JSON.stringify({ at: new Date().toISOString(), reason }) + "\n");
|
||||
fsyncSync(fd);
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
} catch (e) {
|
||||
throw quietFs(e, stopLabel(dir), "write");
|
||||
}
|
||||
return path;
|
||||
}
|
||||
|
||||
function removeStop(dir) {
|
||||
try {
|
||||
unlinkSync(stopPath(dir));
|
||||
} catch (e) {
|
||||
throw quietFs(e, stopLabel(dir), "remove");
|
||||
}
|
||||
}
|
||||
|
||||
const PROCESSING_GENERATION = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/;
|
||||
function processingStops(dir) {
|
||||
let fd;
|
||||
@@ -235,14 +288,13 @@ export function clearProcessingStop(dir, generation) {
|
||||
if (!rows?.length || rows.some(r => !r || Object.keys(r).sort().join(',') !== 'at,reason' ||
|
||||
r.reason !== `processing:${generation}` || !Number.isFinite(Date.parse(r.at)) ||
|
||||
new Date(r.at).toISOString() !== r.at)) return false;
|
||||
unlinkSync(stopPath(dir));
|
||||
removeStop(dir);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
export function clearStop(dir) {
|
||||
const path = stopPath(dir);
|
||||
if (existsSync(path)) unlinkSync(path);
|
||||
if (existsSync(stopPath(dir))) removeStop(dir);
|
||||
}
|
||||
|
||||
// The STOP lines as written, oldest first. A line that does not parse is
|
||||
@@ -250,7 +302,13 @@ export function clearStop(dir) {
|
||||
// treated as ours.
|
||||
export function readStop(dir) {
|
||||
if (!stopRequested(dir)) return [];
|
||||
return readFileSync(stopPath(dir), "utf8").split("\n").filter((l) => l.trim() !== "").map((l) => {
|
||||
let text;
|
||||
try {
|
||||
text = readFileSync(stopPath(dir), "utf8");
|
||||
} catch (e) {
|
||||
throw quietRead(e, stopLabel(dir));
|
||||
}
|
||||
return text.split("\n").filter((l) => l.trim() !== "").map((l) => {
|
||||
try {
|
||||
const v = JSON.parse(l);
|
||||
return v && typeof v === "object" && typeof v.reason === "string" ? v : { reason: null };
|
||||
@@ -396,7 +454,7 @@ function lockRefusal(dir, opts) {
|
||||
const existing = readPid(dir);
|
||||
const state = ownerState(existing, opts);
|
||||
if (state === "absent") return new DiscordError(`run.lock exists without an owner record: a start is in progress or was interrupted; ${UNLOCK_HINT}`, BRAKE_EXIT);
|
||||
if (state === "invalid") return new DiscordError(`run.lock has an owner record that cannot be read; refusing. Inspect ${ownerPath(dir)} by hand`, BRAKE_EXIT);
|
||||
if (state === "invalid") return new DiscordError(`run.lock has an owner record that cannot be read; refusing. Inspect run.lock/owner.json in ${journalLabel(dir)} by hand`, BRAKE_EXIT);
|
||||
if (state === "live") return new DiscordError(`another connector is running for this binding (pid ${existing.pid})`, BRAKE_EXIT);
|
||||
if (state === "unknown") return new DiscordError(`run.lock belongs to pid ${existing.pid}, which is alive but whose identity cannot be verified; refusing`, BRAKE_EXIT);
|
||||
return new DiscordError(`run.lock belongs to pid ${existing.pid}, which is gone or is a different process now; ${UNLOCK_HINT}`, BRAKE_EXIT);
|
||||
@@ -409,17 +467,21 @@ export function writePid(dir, pid, { now = Date.now(), identity = identityOf } =
|
||||
try {
|
||||
mkdirSync(lock, { mode: 0o700 });
|
||||
} catch (err) {
|
||||
if (err.code !== "EEXIST") throw err;
|
||||
if (err.code !== "EEXIST") throw quietFs(err, `run.lock in ${journalLabel(dir)}`, "create");
|
||||
throw lockRefusal(dir, { identity });
|
||||
}
|
||||
const tmp = join(lock, "owner.json.tmp");
|
||||
writeFileSync(tmp, JSON.stringify({ pid, start, boot, at: new Date(now).toISOString() }) + "\n", { mode: 0o600 });
|
||||
renameSync(tmp, ownerPath(dir));
|
||||
try {
|
||||
writeFileSync(tmp, JSON.stringify({ pid, start, boot, at: new Date(now).toISOString() }) + "\n", { mode: 0o600 });
|
||||
renameSync(tmp, ownerPath(dir));
|
||||
} catch (e) {
|
||||
throw quietFs(e, `run.lock/owner.json in ${journalLabel(dir)}`, "write");
|
||||
}
|
||||
// The gate: STOP written before this point (by `stop` or `unlock`) means
|
||||
// this claim must not stand, however it interleaved with an unlock.
|
||||
if (stopRequested(dir)) {
|
||||
clearPid(dir, pid);
|
||||
throw new DiscordError(`STOP is present in ${dir}; remove it to run`, BRAKE_EXIT);
|
||||
throw new DiscordError(`STOP is present in ${journalLabel(dir)}; remove it to run`, BRAKE_EXIT);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -439,9 +501,9 @@ export function unlock(dir, { beforeRemove = null, identity = identityOf, reason
|
||||
const state = ownerState(rec, { identity });
|
||||
if (state === "live") throw new DiscordError(`refusing to unlock: the connector is running (pid ${rec.pid}); use stop, and unlock only a lock whose owner is gone`, 1);
|
||||
if (state === "unknown") throw new DiscordError(`refusing to unlock: pid ${rec.pid} is alive and its identity cannot be verified; nothing removed. Stop that process first`, 1);
|
||||
if (state === "invalid") throw new DiscordError(`refusing to unlock: the owner record cannot be read; nothing removed. Inspect ${ownerPath(dir)} by hand`, 1);
|
||||
if (state === "invalid") throw new DiscordError(`refusing to unlock: the owner record cannot be read; nothing removed. Inspect run.lock/owner.json in ${journalLabel(dir)} by hand`, 1);
|
||||
if (beforeRemove) beforeRemove();
|
||||
rmSync(lock, { recursive: true, force: true, maxRetries: 10, retryDelay: 20 });
|
||||
removeLock(dir, { maxRetries: 10, retryDelay: 20 });
|
||||
return rec;
|
||||
}
|
||||
|
||||
@@ -468,14 +530,14 @@ export function unlock(dir, { beforeRemove = null, identity = identityOf, reason
|
||||
export function recover(dir, { identity = identityOf, beforeRemove = null } = {}) {
|
||||
const ours = (lines) => lines.length === 1 && lines[0].reason === RECOVER_REASON;
|
||||
if (stopRequested(dir)) {
|
||||
if (!ours(readStop(dir))) throw new DiscordError(`STOP is present in ${dir}; the brake is on. Remove it to run`, BRAKE_EXIT);
|
||||
if (!ours(readStop(dir))) throw new DiscordError(`STOP is present in ${journalLabel(dir)}; the brake is on. Remove it to run`, BRAKE_EXIT);
|
||||
clearStop(dir);
|
||||
}
|
||||
if (!existsSync(lockPath(dir))) return "clean";
|
||||
const state = ownerState(readPid(dir), { identity });
|
||||
if (state === "live" || state === "unknown" || state === "invalid") throw lockRefusal(dir, { identity });
|
||||
unlock(dir, { identity, reason: RECOVER_REASON, beforeRemove });
|
||||
if (!ours(readStop(dir))) throw new DiscordError(`STOP was written by an operator while recovering ${dir}; the brake stays. Remove it to run`, BRAKE_EXIT);
|
||||
if (!ours(readStop(dir))) throw new DiscordError(`STOP was written by an operator while recovering ${journalLabel(dir)}; the brake stays. Remove it to run`, BRAKE_EXIT);
|
||||
clearStop(dir);
|
||||
return "cleared";
|
||||
}
|
||||
@@ -489,7 +551,15 @@ export function stopTarget(dir, opts) {
|
||||
|
||||
export function clearPid(dir, pid) {
|
||||
const rec = readPid(dir);
|
||||
if (rec !== null && !rec.invalid && rec.pid === pid) rmSync(lockPath(dir), { recursive: true, force: true });
|
||||
if (rec !== null && !rec.invalid && rec.pid === pid) removeLock(dir);
|
||||
}
|
||||
|
||||
function removeLock(dir, retry = {}) {
|
||||
try {
|
||||
rmSync(lockPath(dir), { recursive: true, force: true, ...retry });
|
||||
} catch (e) {
|
||||
throw quietFs(e, `run.lock in ${journalLabel(dir)}`, "remove");
|
||||
}
|
||||
}
|
||||
|
||||
// --- reloads ---
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
import { lstatSync, readFileSync } from "node:fs";
|
||||
import { request as httpsRequest } from "node:https";
|
||||
import { request as httpRequest } from "node:http";
|
||||
import { plainKey, quietRead } from "./errors.mjs";
|
||||
|
||||
export const SETSPARK_DEFAULTS = Object.freeze({ timeoutMs: 15000, maxResponseBytes: 262144 });
|
||||
export const USER_AGENT = "mosaic-discord-sage/1 (Mosaic Stack Discord connector; setspark client)";
|
||||
@@ -69,7 +70,7 @@ const isObject = (v) => v !== null && typeof v === "object" && !Array.isArray(v)
|
||||
export function loadSetsparkConfig(raw, where = "setspark") {
|
||||
if (!isObject(raw)) throw new Error(`${where}: not an object`);
|
||||
for (const k of Object.keys(raw)) {
|
||||
if (!["baseUrl", "keyFile", "principal", "timeoutMs", "approvers"].includes(k)) throw new Error(`${where}: unknown key ${JSON.stringify(k)}`);
|
||||
if (!["baseUrl", "keyFile", "principal", "timeoutMs", "approvers"].includes(k)) throw new Error(`${where}: unknown key ${plainKey(k)}`);
|
||||
}
|
||||
if (typeof raw.baseUrl !== "string") throw new Error(`${where}.baseUrl: must be a url string`);
|
||||
let u;
|
||||
@@ -108,12 +109,12 @@ function checkPrivateFile(path, what) {
|
||||
try {
|
||||
st = lstatSync(path);
|
||||
} catch {
|
||||
throw new Error(`${what}: not found: ${path}`);
|
||||
throw new Error(`${what}: not found`);
|
||||
}
|
||||
if (st.isSymbolicLink()) throw new Error(`${what}: must not be a symlink: ${path}`);
|
||||
if (!st.isFile()) throw new Error(`${what}: not a regular file: ${path}`);
|
||||
if ((st.mode & 0o777) !== 0o600) throw new Error(`${what}: must be mode 0600: ${path}`);
|
||||
if (st.size === 0) throw new Error(`${what}: is empty: ${path}`);
|
||||
if (st.isSymbolicLink()) throw new Error(`${what}: must not be a symlink`);
|
||||
if (!st.isFile()) throw new Error(`${what}: not a regular file`);
|
||||
if ((st.mode & 0o777) !== 0o600) throw new Error(`${what}: must be mode 0600`);
|
||||
if (st.size === 0) throw new Error(`${what}: is empty`);
|
||||
}
|
||||
|
||||
// Read the key for one call. The file is re-checked every time, so a key
|
||||
@@ -132,7 +133,14 @@ export function readKey(config) {
|
||||
throw new SetsparkRefusal(SETSPARK_REFUSAL.KEY_FILE);
|
||||
}
|
||||
if (st.size > KEY_MAX_BYTES) throw new SetsparkRefusal(SETSPARK_REFUSAL.KEY_SHAPE);
|
||||
const text = readFileSync(config.keyFile, "utf8");
|
||||
// A file that changes after its checks fails with a native error that carries the path: it
|
||||
// keeps its type and code and names the field (#1873).
|
||||
let text;
|
||||
try {
|
||||
text = readFileSync(config.keyFile, "utf8");
|
||||
} catch (e) {
|
||||
throw quietRead(e, "keyFile");
|
||||
}
|
||||
// Either one bare key line, or the mint's own JSON output
|
||||
// ({"key_id", "key", "note"}) stored as is; only "key" is used.
|
||||
let key = text;
|
||||
|
||||
@@ -177,7 +177,8 @@ test("cli: check refuses a missing context file and a missing binding with exit
|
||||
assert.match(r.stderr, /missing context file/);
|
||||
r = runCli(["check", "other-seat", "--config", dep.config, "--repo", repo]);
|
||||
assert.equal(r.status, 2);
|
||||
assert.match(r.stderr, /binding not found/);
|
||||
assert.match(r.stderr, /binding other-seat not found/);
|
||||
assert.ok(!r.stderr.includes(root), r.stderr);
|
||||
r = runCli(["check"]);
|
||||
assert.equal(r.status, 4);
|
||||
r = runCli(["frobnicate", "x"]);
|
||||
|
||||
@@ -230,7 +230,7 @@ test("restart: an inbox with three ids and a replay of the same three produces z
|
||||
test("stop: STOP present refuses start; STOP written while running refuses new turns and the current one finishes", async () => {
|
||||
const { journalDir, rest, engine, connector } = setup({ replies: [{ text: "done", delayMs: 30 }] });
|
||||
requestStop(journalDir, "test");
|
||||
await assert.rejects(connector.start(), /STOP is present/);
|
||||
await assert.rejects(connector.start(), (err) => /STOP is present/.test(err.message) && !err.message.includes(journalDir));
|
||||
assert.equal(engine.started, false);
|
||||
writeFileSync(join(journalDir, "STOP"), ""); // still present; clear it
|
||||
const { unlinkSync } = await import("node:fs");
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
// #1873 (redact6, re-check 2): a native fs error never prints its path. The journal and
|
||||
// approvals readers and writers name the file by label at the source, and the CLI's
|
||||
// top-level handler prints any native error that slipped through as its call and code
|
||||
// only. The sentinel is a component of every scratch path, so an error message that
|
||||
// quotes a path quotes the sentinel too.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs, { existsSync, mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { syncBuiltinESMExports } from 'node:module';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { appendApproval } from '../src/approvals.mjs';
|
||||
import { DiscordError } from '../src/errors.mjs';
|
||||
import {
|
||||
appendInbox, clearStop, ensureJournal, listTurns, lockPath, readTurn, recover, requestStop, stopPath, turnPath, writePid, writeTurn,
|
||||
} from '../src/journal.mjs';
|
||||
import { makeDeployment, makeRepo } from './helpers.mjs';
|
||||
import { initializeControl } from '../../processing-control/src/state.mjs';
|
||||
|
||||
const secret = 'sk-test-NOT-A-SECRET-1234';
|
||||
const cli = fileURLToPath(new URL('../src/cli.mjs', import.meta.url));
|
||||
|
||||
// A journal directory named like a binding, under a sentinel path component, so its
|
||||
// label is "discord/seat under the data root".
|
||||
function scratch(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'r6n-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const dir = join(root, secret, 'seat');
|
||||
mkdirSync(dir, { recursive: true });
|
||||
return { root, dir };
|
||||
}
|
||||
const where = 'discord/seat under the data root';
|
||||
function quiet(text, root) {
|
||||
assert.ok(text.includes(root) === false, text);
|
||||
assert.doesNotMatch(text, /sk-test|NOT-A-SECRET/);
|
||||
}
|
||||
function labelled(label, root) {
|
||||
return error => {
|
||||
assert.ok(error instanceof Error, String(error));
|
||||
assert.equal(error instanceof DiscordError, false, 'a native fs error keeps its type');
|
||||
assert.ok(error.message.includes(label), error.message);
|
||||
quiet(error.message, root);
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
// Swap one node:fs function so the target goes away right before the real call, which
|
||||
// then fails with a genuine error that names the path. One swap per test.
|
||||
function race(t, fn, target, remove) {
|
||||
const original = fs[fn];
|
||||
const swapped = function (p, ...rest) {
|
||||
if (String(p) === target) remove();
|
||||
return original.call(this, p, ...rest);
|
||||
};
|
||||
if (original.native) swapped.native = original.native;
|
||||
fs[fn] = swapped;
|
||||
syncBuiltinESMExports();
|
||||
t.after(() => {
|
||||
fs[fn] = original;
|
||||
syncBuiltinESMExports();
|
||||
});
|
||||
}
|
||||
|
||||
// --- the four named sites ---
|
||||
|
||||
test('recover: a STOP removed between its check and its read names STOP in the journal', t => {
|
||||
const { root, dir } = scratch(t);
|
||||
ensureJournal(dir);
|
||||
writeFileSync(stopPath(dir), JSON.stringify({ at: new Date().toISOString(), reason: 'cli stop' }) + '\n', { mode: 0o600 });
|
||||
race(t, 'readFileSync', stopPath(dir), () => rmSync(stopPath(dir)));
|
||||
assert.throws(() => recover(dir), labelled(`cannot read STOP in ${where} (ENOENT)`, root));
|
||||
});
|
||||
|
||||
test('readTurn: a turn record removed before its read names the turn by id', t => {
|
||||
const { root, dir } = scratch(t);
|
||||
ensureJournal(dir);
|
||||
writeTurn(dir, 't1', { startedAt: new Date().toISOString() });
|
||||
race(t, 'readFileSync', turnPath(dir, 't1'), () => rmSync(turnPath(dir, 't1')));
|
||||
assert.throws(() => readTurn(dir, 't1'), labelled('cannot read turn record t1 (ENOENT)', root));
|
||||
});
|
||||
|
||||
test('listTurns: a turns directory removed between its check and its listing names the turns directory', t => {
|
||||
const { root, dir } = scratch(t);
|
||||
ensureJournal(dir);
|
||||
const turns = join(dir, 'turns');
|
||||
race(t, 'readdirSync', turns, () => rmSync(turns, { recursive: true }));
|
||||
assert.throws(() => listTurns(dir), labelled(`cannot read the turns directory in ${where} (ENOENT)`, root));
|
||||
});
|
||||
|
||||
test('listTurns: a turn record removed between the listing and its read names the turn by id', t => {
|
||||
const { root, dir } = scratch(t);
|
||||
ensureJournal(dir);
|
||||
writeTurn(dir, 't1', { startedAt: new Date().toISOString() });
|
||||
race(t, 'readFileSync', turnPath(dir, 't1'), () => rmSync(turnPath(dir, 't1')));
|
||||
assert.throws(() => listTurns(dir), labelled('cannot read turn record t1 (ENOENT)', root));
|
||||
});
|
||||
|
||||
test("run: a session directory removed between its check and its stat names the binding's session directory", async t => {
|
||||
const { root, dir } = scratch(t);
|
||||
const { sessionContinues } = await import('../src/engine-pi.mjs');
|
||||
assert.equal(typeof sessionContinues, 'function', 'engine-pi.mjs exports sessionContinues');
|
||||
const session = join(dir, 'session');
|
||||
assert.equal(sessionContinues(session), false, 'no directory: a new session');
|
||||
mkdirSync(session);
|
||||
assert.equal(sessionContinues(session), false, 'an empty directory: a new session');
|
||||
writeFileSync(join(session, 'a.jsonl'), '');
|
||||
assert.equal(sessionContinues(session), true, 'a .jsonl file: the session continues');
|
||||
race(t, 'statSync', session, () => rmSync(session, { recursive: true }));
|
||||
assert.throws(() => sessionContinues(session), labelled("cannot stat the binding's session directory (ENOENT)", root));
|
||||
});
|
||||
|
||||
// --- journal and approvals writers (the connector logs err.message at runtime) ---
|
||||
|
||||
test('a journal line that cannot be appended names the journal file, never its path', t => {
|
||||
const { root, dir } = scratch(t);
|
||||
ensureJournal(dir);
|
||||
mkdirSync(join(dir, 'inbox.jsonl'));
|
||||
assert.throws(() => appendInbox(dir, { id: '1' }), labelled('cannot append to inbox.jsonl (EISDIR)', root));
|
||||
});
|
||||
|
||||
test('an approvals line that cannot be appended names approvals.jsonl, never its path', t => {
|
||||
const { root, dir } = scratch(t);
|
||||
ensureJournal(dir);
|
||||
mkdirSync(join(dir, 'approvals.jsonl'));
|
||||
assert.throws(() => appendApproval(dir, { kind: 'opened' }), labelled('cannot append to approvals.jsonl (EISDIR)', root));
|
||||
});
|
||||
|
||||
test('a turn record that cannot be created names the turn by id; an existing one keeps its refusal', t => {
|
||||
const { root, dir } = scratch(t);
|
||||
assert.throws(() => writeTurn(dir, 't1', {}), labelled('cannot write turn record t1 (ENOENT)', root));
|
||||
ensureJournal(dir);
|
||||
writeTurn(dir, 't1', {});
|
||||
assert.throws(() => writeTurn(dir, 't1', {}), e => e instanceof DiscordError && e.exitCode === 1 && /turn record already exists: t1/.test(e.message));
|
||||
});
|
||||
|
||||
test('a STOP that cannot be written names STOP in the journal; the lock is released', t => {
|
||||
const { root, dir } = scratch(t);
|
||||
mkdirSync(stopPath(dir));
|
||||
assert.throws(() => requestStop(dir, 'cli stop'), labelled(`cannot write STOP in ${where} (EISDIR)`, root));
|
||||
assert.equal(existsSync(join(dir, '.stop.lock')), false);
|
||||
});
|
||||
|
||||
test('a STOP removed between its check and its removal names STOP in the journal', t => {
|
||||
const { root, dir } = scratch(t);
|
||||
writeFileSync(stopPath(dir), '{}\n', { mode: 0o600 });
|
||||
race(t, 'unlinkSync', stopPath(dir), () => rmSync(stopPath(dir)));
|
||||
assert.throws(() => clearStop(dir), labelled(`cannot remove STOP in ${where} (ENOENT)`, root));
|
||||
});
|
||||
|
||||
test('a run.lock that cannot be created names run.lock in the journal', t => {
|
||||
const { root, dir } = scratch(t);
|
||||
const gone = join(dir, 'gone');
|
||||
assert.throws(() => writePid(gone, process.pid), e => {
|
||||
assert.ok(e.message.includes('cannot create run.lock in discord/gone under the data root (ENOENT)'), e.message);
|
||||
quiet(e.message, root);
|
||||
return true;
|
||||
});
|
||||
assert.equal(existsSync(lockPath(gone)), false);
|
||||
});
|
||||
|
||||
test('engine: a pi that cannot be spawned is logged and recorded as "cannot spawn pi", never its path', async t => {
|
||||
const { root, dir } = scratch(t);
|
||||
const { createEngine } = await import('../src/engine-pi.mjs');
|
||||
const logs = [];
|
||||
let exited = null;
|
||||
const engine = createEngine({ command: join(dir, 'pi'), args: [], cwd: dir, env: {}, log: (m) => logs.push(m), onExit: (e) => (exited = e) });
|
||||
engine.start();
|
||||
for (let i = 0; i < 200 && exited === null; i += 1) await new Promise((res) => setTimeout(res, 10));
|
||||
assert.ok(exited, 'the spawn error reached onExit');
|
||||
assert.equal(exited.error, 'cannot spawn pi (ENOENT)');
|
||||
assert.ok(logs.includes('engine spawn error: cannot spawn pi (ENOENT)'), logs.join('\n'));
|
||||
quiet(JSON.stringify({ logs, exited }), root);
|
||||
});
|
||||
|
||||
// --- the choke point ---
|
||||
|
||||
test('printable: a native error prints its call and code, a refusal or a labelled error its own message', async () => {
|
||||
const { printable, quietRead } = await import('../src/errors.mjs');
|
||||
assert.equal(typeof printable, 'function', 'errors.mjs exports printable');
|
||||
const path = `/x/${secret}/discord/seat/STOP`;
|
||||
const native = Object.assign(new Error(`ENOENT: no such file or directory, open '${path}'`), { code: 'ENOENT', syscall: 'open', path, errno: -2 });
|
||||
assert.equal(printable(native), 'cannot open a discord file (ENOENT)');
|
||||
const pathOnly = Object.assign(new Error(`EACCES ${path}`), { code: 'EACCES', path });
|
||||
assert.equal(printable(pathOnly), 'cannot access a discord file (EACCES)');
|
||||
const spawn = Object.assign(new Error(`spawn ${path} ENOENT`), { code: 'ENOENT', syscall: `spawn ${path}`, path });
|
||||
assert.equal(printable(spawn), 'cannot spawn a discord file (ENOENT)');
|
||||
const net = Object.assign(new Error('connect ECONNREFUSED 127.0.0.1:1'), { code: 'ECONNREFUSED', syscall: 'connect' });
|
||||
assert.equal(printable(net), 'cannot connect (ECONNREFUSED)');
|
||||
const odd = Object.assign(new Error(path), { code: `X ${path}`, syscall: path, path });
|
||||
assert.equal(printable(odd), 'cannot access a discord file (UNKNOWN)');
|
||||
const labelledRead = quietRead(Object.assign(new Error(path), { code: 'ENOENT', syscall: 'open', path }), 'token file');
|
||||
assert.equal(printable(labelledRead), 'cannot read token file (ENOENT)');
|
||||
assert.equal(printable(new DiscordError('STOP is present in discord/seat under the data root; remove it to run', 3)), 'STOP is present in discord/seat under the data root; remove it to run');
|
||||
assert.equal(printable(new Error('plain refusal')), 'plain refusal');
|
||||
for (const v of [native, pathOnly, spawn, odd]) assert.doesNotMatch(printable(v), /sk-test|NOT-A-SECRET/);
|
||||
});
|
||||
|
||||
test('cli: a native fs error reaching the top-level handler prints its call and code, never the path, and exits 1', t => {
|
||||
const { root } = scratch(t);
|
||||
const base = join(root, secret, 'dep');
|
||||
mkdirSync(base, { recursive: true });
|
||||
const repo = makeRepo(base);
|
||||
const dep = makeDeployment(base);
|
||||
initializeControl(dep.dataRoot);
|
||||
const run = (args) => spawnSync(process.execPath, [cli, ...args, '--config', dep.config, '--repo', repo], { encoding: 'utf8' });
|
||||
|
||||
// stop: the journal directory is a regular file, so ensureJournal's mkdir fails.
|
||||
writeFileSync(dep.journalDir, 'not a directory');
|
||||
let r = run(['stop', 'test-seat']);
|
||||
assert.equal(r.status, 1, r.stderr);
|
||||
assert.equal(r.stderr, 'discord: cannot mkdir a discord file (ENOTDIR)\n');
|
||||
quiet(r.stderr + r.stdout, root);
|
||||
rmSync(dep.journalDir);
|
||||
|
||||
// run: the sessions directory is a regular file, so the claimed run's mkdir fails
|
||||
// after the claim; the claim is released and the exit stays 1.
|
||||
writeFileSync(join(dep.dataRoot, 'sessions'), 'not a directory');
|
||||
r = run(['run', 'test-seat']);
|
||||
assert.equal(r.status, 1, r.stderr);
|
||||
assert.equal(r.stderr, 'discord: cannot mkdir a discord file (ENOTDIR)\n');
|
||||
quiet(r.stderr + r.stdout, root);
|
||||
assert.equal(existsSync(lockPath(dep.journalDir)), false, 'the claim is released');
|
||||
});
|
||||
@@ -75,6 +75,6 @@ test("notify: no dmRecipient, a non-0600 binding or token, or a missing binding
|
||||
assert.throws(() => openDirect({ dataRoot: none.dataRoot, name: "test-seat", fetch }), (e) => e instanceof DiscordError && e.exitCode === 2 && /no dmRecipient/.test(e.message));
|
||||
const loose = makeDeployment(makeRoot(), { dmRecipient: IDS.owner }, { tokenMode: 0o644 });
|
||||
assert.throws(() => openDirect({ dataRoot: loose.dataRoot, name: "test-seat", fetch }), (e) => e instanceof DiscordError && /mode 0600/.test(e.message) && !e.message.includes(TOKEN));
|
||||
assert.throws(() => openDirect({ dataRoot: none.dataRoot, name: "absent", fetch }), (e) => e instanceof DiscordError && /binding not found/.test(e.message));
|
||||
assert.throws(() => openDirect({ dataRoot: none.dataRoot, name: "absent", fetch }), (e) => e instanceof DiscordError && /binding absent not found/.test(e.message) && !e.message.includes(none.dataRoot));
|
||||
assert.equal(calls, 0);
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
// #1873 (redact6): a discord refusal names the file, field or binding, never a host path.
|
||||
// The sentinel is a component of every scratch path, so a refusal that quotes a path
|
||||
// quotes the sentinel too; each test also checks the scratch root and the label.
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import fs, { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
|
||||
import { syncBuiltinESMExports } from 'node:module';
|
||||
import { readApprovals } from '../src/approvals.mjs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { DiscordError } from '../src/errors.mjs';
|
||||
import { bindingDataDir, bindingPath, loadDataRoot, readToken, resolveContextFiles, resolveToolRoots, validateBinding } from '../src/binding.mjs';
|
||||
import { ensureJournal, lockPath, ownerPath, readInboxIds, recover, requestStop, turnPath, unlock, writeTurn } from '../src/journal.mjs';
|
||||
import { gitPull, loadGitConfig } from '../src/git.mjs';
|
||||
import { loadSetsparkConfig, readKey } from '../src/setspark.mjs';
|
||||
|
||||
const secret = 'sk-test-NOT-A-SECRET-1234';
|
||||
function scratch(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'r6-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
const dir = join(root, secret);
|
||||
mkdirSync(dir);
|
||||
return { root, dir };
|
||||
}
|
||||
function labelled(label, root, Type = DiscordError) {
|
||||
return error => {
|
||||
assert.ok(error instanceof Type, error.stack);
|
||||
assert.ok(error.message.includes(label), error.message);
|
||||
assert.ok(!error.message.includes(root), error.message);
|
||||
assert.doesNotMatch(error.message, /sk-test|NOT-A-SECRET/);
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
test('config not found names the config, not its path', t => {
|
||||
const { root, dir } = scratch(t);
|
||||
assert.throws(() => loadDataRoot(join(dir, 'config.json')), labelled('config not found', root));
|
||||
});
|
||||
|
||||
test('token file refusals name the token file, not its path', t => {
|
||||
const { root, dir } = scratch(t);
|
||||
assert.throws(() => readToken({ tokenFile: join(dir, 'token') }), labelled('token file not found', root));
|
||||
const short = join(dir, 'short');
|
||||
writeFileSync(short, 'x', { mode: 0o600 });
|
||||
assert.throws(() => readToken({ tokenFile: short }), labelled('token file does not hold a bot token', root));
|
||||
const open = join(dir, 'open');
|
||||
writeFileSync(open, 'x'.repeat(30), { mode: 0o644 });
|
||||
assert.throws(() => readToken({ tokenFile: open }), labelled('token file must be mode 0600, is 0644', root));
|
||||
});
|
||||
|
||||
test('a context file refusal names its index in context.files', t => {
|
||||
const { root, dir } = scratch(t);
|
||||
const repo = join(dir, 'repo');
|
||||
mkdirSync(repo);
|
||||
assert.throws(() => resolveContextFiles({ context: { files: [join(dir, 'abs.md')] } }, repo), labelled('context file must be repository-relative: context.files[0]', root));
|
||||
assert.throws(() => resolveContextFiles({ context: { files: ['ok.md', `${secret}.md`] } }, repo), labelled('missing context file: context.files[0]', root));
|
||||
});
|
||||
|
||||
test('a tool root refusal names the root, not its path', t => {
|
||||
const { root, dir } = scratch(t);
|
||||
const binding = { tools: { roots: [{ name: 'docs', path: join(dir, 'docs'), write: false }] } };
|
||||
assert.throws(() => resolveToolRoots(binding, { dataRoot: join(dir, 'data') }), labelled('tool root docs does not exist', root));
|
||||
mkdirSync(join(dir, 'data', 'docs'), { recursive: true });
|
||||
binding.tools.roots[0].path = join(dir, 'data', 'docs');
|
||||
assert.throws(() => resolveToolRoots(binding, { dataRoot: join(dir, 'data') }), labelled('tool root docs overlaps the data root', root));
|
||||
});
|
||||
|
||||
test('git and setspark key file refusals name the field', t => {
|
||||
const { root, dir } = scratch(t);
|
||||
const git = { branch: 'main', identity: 'sage', tokenFile: join(dir, 'gitea'), author: 'Sage <[email protected]>' };
|
||||
assert.throws(() => loadGitConfig(git, 'tools.roots[0].git'), labelled('tools.roots[0].git.tokenFile: not found', root, Error));
|
||||
const setspark = { baseUrl: 'https://setspark.example', keyFile: join(dir, 'key'), principal: 'sage' };
|
||||
assert.throws(() => loadSetsparkConfig(setspark), labelled('setspark.keyFile: not found', root, Error));
|
||||
});
|
||||
|
||||
for (const [name, label] of [['bot', 'discord/bot under the data root'], [secret, "the binding's journal directory"]]) {
|
||||
test(`journal refusals name ${label}`, t => {
|
||||
const { root, dir } = scratch(t);
|
||||
const journal = join(dir, name);
|
||||
ensureJournal(journal);
|
||||
requestStop(journal, 'operator');
|
||||
assert.throws(() => recover(journal), labelled(`STOP is present in ${label}`, root));
|
||||
rmSync(join(journal, 'STOP'));
|
||||
mkdirSync(lockPath(journal));
|
||||
writeFileSync(ownerPath(journal), secret, { mode: 0o600 });
|
||||
assert.throws(() => unlock(journal), labelled(`Inspect run.lock/owner.json in ${label} by hand`, root));
|
||||
writeTurn(journal, 't1', {});
|
||||
assert.throws(() => writeTurn(journal, 't1', {}), labelled('turn record already exists: t1', root));
|
||||
});
|
||||
}
|
||||
|
||||
// Review round (#1873): keys, names, read errors and git output that could carry a host path.
|
||||
test('an unknown key that is not a plain name is described, never echoed; a plain one is still quoted', t => {
|
||||
const { root, dir } = scratch(t);
|
||||
const doc = { [join(dir, 'host')]: 1 };
|
||||
assert.throws(() => validateBinding(doc), labelled('unknown key that is not a plain name', root));
|
||||
assert.throws(() => loadGitConfig(doc, 'tools.roots[0].git'), labelled('tools.roots[0].git: unknown key that is not a plain name', root, Error));
|
||||
assert.throws(() => loadSetsparkConfig(doc, 'setspark'), labelled('setspark: unknown key that is not a plain name', root, Error));
|
||||
assert.throws(() => validateBinding({ colour: 1 }), labelled('unknown key "colour"', root));
|
||||
});
|
||||
|
||||
test('an invalid binding name or turn id states the constraint, never the value, and keeps its exit', t => {
|
||||
const { root, dir } = scratch(t);
|
||||
for (const f of [bindingPath, bindingDataDir]) {
|
||||
assert.throws(() => f(dir, join(dir, 'bad')), e => labelled('invalid binding name (lowercase letters, digits, ., _ or -, at most 64)', root)(e) && e.exitCode === 4);
|
||||
}
|
||||
assert.throws(() => turnPath(dir, join(dir, 'bad')), e => labelled('invalid turn id (letters, digits, ., _ or -, at most 128)', root)(e) && e.exitCode === 1);
|
||||
});
|
||||
|
||||
test('an unreadable journal or approvals file, or a missing repository, names the file and code, never the path', t => {
|
||||
const { root, dir } = scratch(t);
|
||||
writeFileSync(join(dir, 'approvals.jsonl'), '{}\n', { mode: 0o000 });
|
||||
writeFileSync(join(dir, 'inbox.jsonl'), '{}\n', { mode: 0o000 });
|
||||
assert.throws(() => readApprovals(dir), e => labelled('cannot read approvals.jsonl (EACCES)', root, Error)(e) && e.code === 'EACCES' && !(e instanceof DiscordError));
|
||||
assert.throws(() => readInboxIds(dir), e => labelled('cannot read inbox.jsonl (EACCES)', root, Error)(e) && e.code === 'EACCES');
|
||||
assert.throws(() => resolveContextFiles({ context: { files: ['x'] } }, join(dir, 'missing')), e => labelled('cannot read the repository (ENOENT)', root, Error)(e) && e.code === 'ENOENT');
|
||||
});
|
||||
|
||||
test('a git refusal drops stderr lines that carry a host path and a spawn failure names only its code', t => {
|
||||
const { root, dir } = scratch(t);
|
||||
const rootCfg = { real: dir, name: 'docs', git: { branch: 'main', identity: 'sage', tokenFile: join(dir, 'never-read'), author: { name: 'Sage', email: '[email protected]' } } };
|
||||
let calls = 0;
|
||||
const spawn = () => ++calls === 1 ? { status: 0, stdout: 'main\n', stderr: '' } : { status: 128, stdout: '', stderr: `fatal: cannot access ${join(dir, 'repo')}\n` };
|
||||
assert.throws(() => gitPull(rootCfg, { spawn }), e => {
|
||||
assert.equal(e.reason, 'that root has no git');
|
||||
assert.equal(e.message, e.reason);
|
||||
assert.ok(!e.detail, String(e.detail));
|
||||
return labelled(e.reason, root, Error)(e);
|
||||
});
|
||||
assert.equal(calls, 2);
|
||||
// A repo-relative line survives: the detail still says what git said.
|
||||
calls = 0;
|
||||
const kept = () => ++calls === 1 ? { status: 0, stdout: 'main\n', stderr: '' } : { status: 128, stdout: '', stderr: `fatal: not a git repository: .git\n` };
|
||||
assert.throws(() => gitPull(rootCfg, { spawn: kept }), e => e.detail === 'fatal: not a git repository: .git');
|
||||
const failed = () => ({ error: Object.assign(new Error(`spawn ${join(dir, 'git')} ENOENT`), { code: 'ENOENT' }) });
|
||||
assert.throws(() => gitPull(rootCfg, { spawn: failed }), e => labelled('git could not start (ENOENT)', root, Error)(e));
|
||||
});
|
||||
|
||||
// Re-check (#1873): a file removed between its check and its read surfaces a native
|
||||
// error. Each site names the file by label and code; the error's path never reaches the
|
||||
// message. `race` swaps one node:fs function so the target goes away right before the
|
||||
// real call, which then fails with a genuine ENOENT that names the path.
|
||||
function race(t, fn, target, remove) {
|
||||
const original = fs[fn];
|
||||
const swapped = function (p, ...rest) {
|
||||
if (String(p) === target) remove();
|
||||
return original.call(this, p, ...rest);
|
||||
};
|
||||
if (original.native) swapped.native = original.native;
|
||||
fs[fn] = swapped;
|
||||
syncBuiltinESMExports();
|
||||
t.after(() => {
|
||||
fs[fn] = original;
|
||||
syncBuiltinESMExports();
|
||||
});
|
||||
}
|
||||
|
||||
test('a token file removed between its check and its read names the token file', t => {
|
||||
const { root, dir } = scratch(t);
|
||||
const tokenFile = join(dir, 'token');
|
||||
writeFileSync(tokenFile, 'x'.repeat(30), { mode: 0o600 });
|
||||
race(t, 'readFileSync', tokenFile, () => rmSync(tokenFile));
|
||||
assert.throws(() => readToken({ tokenFile }), labelled('cannot read token file (ENOENT)', root, Error));
|
||||
});
|
||||
|
||||
test('a context file removed between its check and its realpath names its index', t => {
|
||||
const { root, dir } = scratch(t);
|
||||
const repo = join(dir, 'repo');
|
||||
mkdirSync(repo);
|
||||
writeFileSync(join(repo, 'ok.md'), 'ok');
|
||||
writeFileSync(join(repo, `${secret}.md`), 'x');
|
||||
const target = join(repo, `${secret}.md`);
|
||||
race(t, 'realpathSync', target, () => rmSync(target));
|
||||
assert.throws(() => resolveContextFiles({ context: { files: ['ok.md', `${secret}.md`] } }, repo), labelled('cannot read context file context.files[1] (ENOENT)', root, Error));
|
||||
});
|
||||
|
||||
test('a tool root removed between its check and its realpath names the root', t => {
|
||||
const { root, dir } = scratch(t);
|
||||
const docs = join(dir, 'docs');
|
||||
mkdirSync(docs);
|
||||
const binding = { tools: { roots: [{ name: 'docs', path: docs, write: false }] } };
|
||||
race(t, 'realpathSync', docs, () => rmSync(docs, { recursive: true }));
|
||||
assert.throws(() => resolveToolRoots(binding, { dataRoot: join(dir, 'data') }), labelled('cannot read tool root docs (ENOENT)', root, Error));
|
||||
});
|
||||
|
||||
test('a data root removed between its check and its realpath names the data root', t => {
|
||||
const { root, dir } = scratch(t);
|
||||
const docs = join(dir, 'docs');
|
||||
mkdirSync(docs);
|
||||
const binding = { tools: { roots: [{ name: 'docs', path: docs, write: false }] } };
|
||||
const data = join(dir, 'data');
|
||||
mkdirSync(data);
|
||||
race(t, 'realpathSync', data, () => rmSync(data, { recursive: true }));
|
||||
assert.throws(() => resolveToolRoots(binding, { dataRoot: data }), labelled('cannot read the data root (ENOENT)', root, Error));
|
||||
});
|
||||
|
||||
test('a setspark key file removed between its check and its read names keyFile', t => {
|
||||
const { root, dir } = scratch(t);
|
||||
const keyFile = join(dir, 'key');
|
||||
writeFileSync(keyFile, 'ssk_' + 'a'.repeat(40), { mode: 0o600 });
|
||||
const config = loadSetsparkConfig({ baseUrl: 'https://setspark.example', keyFile, principal: 'sage' });
|
||||
race(t, 'readFileSync', keyFile, () => rmSync(keyFile));
|
||||
assert.throws(() => readKey(config), labelled('cannot read keyFile (ENOENT)', root, Error));
|
||||
});
|
||||
@@ -6,10 +6,13 @@ import { dirname, join } from 'node:path';
|
||||
const secret = 'sk-test-NOT-A-SECRET-1234';
|
||||
// JSON.parse quotes a prefix near this fault on Node 26; check partial leaks too.
|
||||
const malformed = secret;
|
||||
// #1873: the sentinel is a path component, so a refusal that quotes a path quotes it too.
|
||||
function scratch(t) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'r3-'));
|
||||
t.after(() => rmSync(root, { recursive: true, force: true }));
|
||||
return root;
|
||||
const dir = join(root, secret);
|
||||
mkdirSync(dir);
|
||||
return dir;
|
||||
}
|
||||
function write(file, text = malformed) {
|
||||
mkdirSync(dirname(file), { recursive: true });
|
||||
@@ -21,6 +24,7 @@ function safe(label, ErrorType, properties = {}) {
|
||||
assert.ok(error instanceof ErrorType);
|
||||
for (const [key, value] of Object.entries(properties)) assert.equal(error[key], value);
|
||||
assert.ok(error.message.includes(label), error.message);
|
||||
assert.ok(!error.message.includes(tmpdir()), error.message);
|
||||
assert.doesNotMatch(error.message, /sk-test|Unexpected token/);
|
||||
assert.doesNotMatch(error.stack, /sk-test/);
|
||||
assert.equal(error.cause, undefined);
|
||||
@@ -33,15 +37,15 @@ import { readApprovals } from '../src/approvals.mjs';
|
||||
import { readInboxIds } from '../src/journal.mjs';
|
||||
import extension from '../extension/tools.mjs';
|
||||
import { TOOLS_ENV } from '../src/tools.mjs';
|
||||
for (const [name, read, relative] of [
|
||||
['config', (file) => loadDataRoot(file), 'config.json'],
|
||||
['binding', (file) => loadBinding(file), 'binding.json'],
|
||||
['approvals', (_file, root) => readApprovals(root), 'approvals.jsonl'],
|
||||
['journal', (_file, root) => readInboxIds(root), 'inbox.jsonl'],
|
||||
for (const [name, read, relative, label] of [
|
||||
['config', (file) => loadDataRoot(file), 'config.json', 'config is not valid JSON'],
|
||||
['binding', (file) => loadBinding(file), 'binding.json', 'binding binding is not valid JSON'],
|
||||
['approvals', (_file, root) => readApprovals(root), 'approvals.jsonl', 'approvals.jsonl:2: not valid JSON'],
|
||||
['journal', (_file, root) => readInboxIds(root), 'inbox.jsonl', 'inbox.jsonl:2: not valid JSON'],
|
||||
]) test(`${name} JSON refusal omits parser bytes`, t => {
|
||||
const root = scratch(t), ledger = name === 'approvals' || name === 'journal';
|
||||
const file = write(join(root, relative), ledger ? '{}\n' + malformed : malformed);
|
||||
assert.throws(() => read(file, root), safe(ledger ? `${file}:2` : file, DiscordError, { exitCode: 2 }));
|
||||
assert.throws(() => read(file, root), safe(label, DiscordError, { exitCode: 2 }));
|
||||
});
|
||||
test('tools environment JSON refusal names the config key without its bytes', () => {
|
||||
const previous = process.env[TOOLS_ENV];
|
||||
@@ -52,7 +56,7 @@ test('tools environment JSON refusal names the config key without its bytes', ()
|
||||
test('config read refusal retains the filesystem code', t => {
|
||||
const file = scratch(t);
|
||||
assert.throws(() => loadDataRoot(file), error => {
|
||||
safe(file, DiscordError, { exitCode: 2 })(error);
|
||||
safe('config is not valid JSON', DiscordError, { exitCode: 2 })(error);
|
||||
assert.match(error.message, /\(EISDIR\)$/);
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -159,7 +159,7 @@ function readSkill(name, root, size) {
|
||||
try { return lstatSync(path); } catch (e) { if (e.code === "ENOENT") throw skillNotEnabled(name); throw e; }
|
||||
};
|
||||
// scripts/skill.sh activate moves a real directory in, so a symlink here is refused, not followed.
|
||||
if (!found(join(root, name)).isDirectory()) throw new BundleError(`skill ${name} isn't a plain directory under ${root}`);
|
||||
if (!found(join(root, name)).isDirectory()) throw new BundleError(`skill ${name} isn't a plain directory under the enabled skills directory`);
|
||||
if (!found(join(root, name, "SKILL.md")).isFile()) throw new BundleError(`skill ${name}: SKILL.md isn't a plain file`);
|
||||
walk(join(root, name), "");
|
||||
return files;
|
||||
@@ -222,34 +222,36 @@ function readPersona(resolved, personaDir) {
|
||||
throw new BundleError(`instance ${resolved.instance} has a persona, but the bundle has no persona directory`, "persona-invalid");
|
||||
}
|
||||
const path = join(personaDir, resolved.business, `${resolved.instance}.md`);
|
||||
// #1873: a refusal names the file under the persona directory, never its host path.
|
||||
const shown = `${resolved.business}/${resolved.instance}.md in the persona directory`;
|
||||
let fd;
|
||||
try {
|
||||
fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
|
||||
} catch (e) {
|
||||
if (e.code === "ENOENT" && digest === undefined) return null;
|
||||
if (e.code === "ENOENT") throw new BundleError(`instance ${resolved.instance}: persona file ${path} doesn't exist`, "persona-invalid");
|
||||
if (e.code === "ELOOP") throw new BundleError(`instance ${resolved.instance}: persona file ${path} is a symlink`, "persona-invalid");
|
||||
throw new BundleError(`instance ${resolved.instance}: persona file ${path} can't be opened (${e.code})`, "persona-invalid");
|
||||
if (e.code === "ENOENT") throw new BundleError(`instance ${resolved.instance}: persona file ${shown} doesn't exist`, "persona-invalid");
|
||||
if (e.code === "ELOOP") throw new BundleError(`instance ${resolved.instance}: persona file ${shown} is a symlink`, "persona-invalid");
|
||||
throw new BundleError(`instance ${resolved.instance}: persona file ${shown} can't be opened (${e.code})`, "persona-invalid");
|
||||
}
|
||||
let data;
|
||||
try {
|
||||
const stat = fstatSync(fd);
|
||||
if (!stat.isFile()) throw new BundleError(`instance ${resolved.instance}: persona file ${path} isn't a regular file`, "persona-invalid");
|
||||
if (stat.uid !== process.getuid()) throw new BundleError(`instance ${resolved.instance}: persona file ${path} isn't owned by uid ${process.getuid()}`, "persona-invalid");
|
||||
if (stat.mode & 0o077) throw new BundleError(`instance ${resolved.instance}: persona file ${path} has group or other mode bits`, "persona-invalid");
|
||||
if (!stat.isFile()) throw new BundleError(`instance ${resolved.instance}: persona file ${shown} isn't a regular file`, "persona-invalid");
|
||||
if (stat.uid !== process.getuid()) throw new BundleError(`instance ${resolved.instance}: persona file ${shown} isn't owned by uid ${process.getuid()}`, "persona-invalid");
|
||||
if (stat.mode & 0o077) throw new BundleError(`instance ${resolved.instance}: persona file ${shown} has group or other mode bits`, "persona-invalid");
|
||||
// Four bytes is the most one character takes in UTF-8.
|
||||
if (stat.size > PERSONA_CHARS * 4) throw new BundleError(`instance ${resolved.instance}: persona file ${path} is over ${PERSONA_CHARS} characters`, "persona-invalid");
|
||||
if (stat.size > PERSONA_CHARS * 4) throw new BundleError(`instance ${resolved.instance}: persona file ${shown} is over ${PERSONA_CHARS} characters`, "persona-invalid");
|
||||
data = readFileSync(fd);
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
if (sha256(data) !== digest) {
|
||||
throw new BundleError(`instance ${resolved.instance}: persona file ${path} isn't the file persona.sha256 names`, "persona-mismatch");
|
||||
throw new BundleError(`instance ${resolved.instance}: persona file ${shown} isn't the file persona.sha256 names`, "persona-mismatch");
|
||||
}
|
||||
let text;
|
||||
try { text = utf8.decode(data); } catch { throw new BundleError(`instance ${resolved.instance}: persona file ${path} isn't UTF-8 text`, "persona-invalid"); }
|
||||
try { text = utf8.decode(data); } catch { throw new BundleError(`instance ${resolved.instance}: persona file ${shown} isn't UTF-8 text`, "persona-invalid"); }
|
||||
const fault = personaTextFault(text);
|
||||
if (fault) throw new BundleError(`instance ${resolved.instance}: persona file ${path} ${fault}`, "persona-invalid");
|
||||
if (fault) throw new BundleError(`instance ${resolved.instance}: persona file ${shown} ${fault}`, "persona-invalid");
|
||||
return { name: resolved.vars["persona.name"], sha256: digest, text };
|
||||
}
|
||||
// Between the contract and "## This session". The contract wins on a conflict, and the broker's
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
Loaded 100 of 189 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user