Compare commits

...
29 Commits
Author SHA1 Message Date
jason.woltje ebe2af86de fix(console): D1 security re-check fixes for the dev-owner start (#1873)
The agent-run refusal on a dev-local-owner start now checks the starting
process's own environment as well as the environment given for the
Console, so a caller cannot pass a clean env to get past it.

The start lock now names its holder (pid and start time). A detached
start that times out and kills its supervisor's process group clears the
lock only when it names that supervisor and the supervisor is gone, so a
forced kill no longer blocks every later start.
2026-11-11 14:16:09 -06:00
jason.woltje 1947a056fb fix(console): D1 review fixes for the detached dev-owner supervisor (#1873)
Build review P2: the supervisor ignores a lost reply pipe and keeps
supervising. Security P2: a stop signal during start is held until the
start settles, and the CLI's start timeout signals the supervisor's
process group. Security P1 (part): console start --dev-local-owner
refuses inside an agent run. Tests cover review, confirm and reverse on
the org autonomy route; TOOLS.md and the webui README are corrected.
2026-11-11 14:16:09 -06:00
jason.woltje 7ffe932250 feat(console): dev-local-owner Console writes autonomy and restarts when detached (#1873)
A --dev-local-owner Console's organization pages read writable:false because
the per-org options had no autonomy writer and the route fell back to the
request transport, which org options never set. The org options now carry an
autonomy writer on the organization's console transport, fenced to the four
owner autonomy verbs. The route prefers it and keeps requestWrite otherwise.

mosaic console start --dev-local-owner --detach now runs the Console under a
detached supervisor, so Restart Console (exit 75) relaunches it on the same
address. A non-dev detached Console is unchanged. A deployed --sso --send
Console has no organization routes and keeps its root autonomy transport;
a test pins both.
2026-11-11 14:16:09 -06:00
jason.woltje 900daf5213 fix(org): D2 security review: live identity refuses, clear only inspected entries, no revive from an agent run
- busRecover: a live pid with the recorded start time is the recorded process, so it refuses
  ("bus host identity is uncertain") whatever its argv. It used to read a mismatching argv as a dead
  host. The pinned recovery test now covers a reused pid (live, other start time) as the wrong
  process, and a new test covers the refusal.
- busRecover clears through setAside: each inspected entry is renamed aside and checked by device
  and inode; a swapped entry puts everything back and refuses before any receipt or deletion, and
  the deletion names only the aside entries.
- mosaic bus recover refuses inside an agent run (refuseInsideAgent), like mosaic org reload. The
  Console-start revive does nothing when the Console's own environment carries an agent marker.
- Tests: org-recovery (identity, agent refusal), org-revive (setAside swap), bus-revive (markers);
  the other reviveBuses tests pass an explicit env.
2026-11-11 14:03:18 -06:00
jason.woltje f9dae1ebad fix(org): D2 build review: pin pm:false, wait for the start-up revive, say when a clear was followed by a failed start
- The Console-start revive asks busRevive for pm:false; reviveStartOptions holds the rule (never
  wider than the dead host's record) and is tested on its own. A dropped --pm is logged with the
  way back.
- A Restart bus press waits for that organization's start-up revive (revivePending) before it reads
  the bus, so it no longer races it for the lifecycle lock; a press that still meets org-locked gets
  409 bus-busy, nothing changed.
- busRevive marks an error from busStart after a successful clear (error.recovered); the trail, the
  Console log and the press response then say "failed", not "refused". A non-CliError logs its code.
- Tests: bus-revive.test.mjs (new), org-admin and org-revive cases for the above.
2026-11-11 14:03:18 -06:00
jason.woltje f063158875 fix(org): Console start reattaches to org buses and revives a provably dead host (P0-3)
A Console unit stop (KillMode=mixed) SIGKILLs the bus hosts the Console
started, leaving host.json, host.lock and the socket behind, and the
dev Console's Restart bus refused 409 on that stale state.

- busRevive (org-bus): busRecover, then busStart detached on the
  options the dead host recorded. busRecover still refuses a live host
  pid, a lock that is not the dead host's, a live writer pid, or a
  socket that answers or is uncertain.
- reviveBuses (webui): when a dev-local-owner Console starts, a bus that
  answers is left (reattached); a bus with stale state goes to busRevive
  with pm:false, trailed as bus.revive before anything changes.
- Restart bus (org-admin): for the exact dev owner only, a stale-state
  refusal goes to busRevive; a signed-in owner keeps the refusal.
- The receipt reads "Dead bus host cleared; bus started".
2026-11-11 14:03:18 -06:00
jason.woltje ff20efb819 fix(autonomy): review round for #1873 account coverage
A replaced installed harness answers null for countsAccount until the
bus restarts and reads the new module. A runtime test pins the
startBroker wiring for claude-code (covered) and pi (refused). The
plugin README says countsAccount also decides authority coverage and
is read once at start.
2026-11-11 13:46:22 -06:00
jason.woltje cfe7535533 fix(autonomy): account quota coverage reads the harness module's countsAccount, not a name (#1873)
authorityBudgetCovered named claude-code to decide whether an account
quota covers every future launch, which fails the row 65 scan. The bus
host now passes each harness module's own countsAccount answer, read
once with its levels in harnessLevels, through autonomyHost to the
broker. Without the lookup no account quota counts (fail closed); the
provider/model branch is unchanged.
2026-11-11 13:39:44 -06:00
jason.woltje b1fc96f4ae fix(routes): name Disable only when it makes the save apply (P0-4 review)
The catalogue refusals no longer tell every user to press Disable model
proxy catalogue. The server text names the proxy setup step. The Routing
page adds the Disable step only when cliproxy is listed and every moved
model comes from another listed provider, and sends a tester to the
owner. The switch mismatch message no longer claims a setting changed.

Tests: a fallback thinking-only change saves with no catalogue; a move
between model-proxy models refuses before and after cliproxy is removed.
2026-11-11 13:35:56 -06:00
jason.woltje 967025f749 fix(routes): fixture routes, thinking and limits save without a model catalogue (P0-4)
A fixture-only organization reads a ready catalogue with no model proxy
client: the catalogue asks the proxy only when a module calls it.
A thinking-only change, and a limits patch that keeps harness, model and
entry, no longer asks the catalogue. A change that truly needs it still
refuses, and the WebUI names the next step: keep the model and change
thinking, or press Disable model proxy catalogue. The Routing switch
follows whether cliproxy is listed and removes or adds only cliproxy.
2026-11-11 13:35:55 -06:00
jason.woltje 70504ddddb fix(webui): build the inbox hire form after the read; pass through only pre-insert hire codes (#1873)
D4 review fix round.

P2: the org inbox took the hire form's HTML before its workspace read, so typing or a
submit result that landed during a slow read was painted over with the older draft.
inboxPage now takes a function and calls it while building the page after the read.
Two browser cases hold the inbox read: typing during it, and a refused start that
resolves during it, both survive the repaint (both fail on the previous commit).

P3: a hire start passes a bus code through only when the broker raises it before it
records the decision (hire-decisions prepare: the model route codes,
definition-not-hireable, definition-shared, no-chief-of-staff). Every other code,
including revision-conflict-revoked, request-timeout, invalid-envelope,
incomplete-request and codes the resolve or apply steps can raise, stays
outcome-unknown. Messages are worded for hire, fire and change alike.
2026-11-11 13:01:54 -06:00
jason.woltje c3293ef4ef feat(webui): hire form on the org inbox; a definite hire refusal keeps its code (#1873)
D4, readiness P0-1 and P1-2.

On an org Console, #/inbox now carries the hire, fire and change form
below "Answered today". It posts to the current organization's bus
through the same decision endpoint and verb (hire.start.console); the
organization field is read-only. Inputs carry focus keys, so a poll
repaint keeps the draft, focus and caret.

A hire start the bus definitely refused now keeps its own code. The
model route codes (model-provider-unavailable and its kin) get a next
step. Any other definite code is named, and the message never claims
that nothing was recorded. A lost or unreadable reply is still
outcome-unknown. The templates, confirmation and resolve steps are
unchanged.
2026-11-11 12:45:12 -06:00
jason.woltje 88a94ddd34 test(bus): pin the autonomy budget helper import (#1873) 2026-11-11 10:27:41 -06:00
jason.woltje 2d4b07e516 fix(org): retain committed inverse status through completion IO failure (#1873) 2026-11-11 10:27:41 -06:00
jason.woltje e3232b814f fix(org): enforce autonomy budgets and durable inverse boundaries (#1873) 2026-11-11 10:27:41 -06:00
jason.woltje 0ef6326084 feat(org): add per-class autonomy and guarded reversals (#1873) 2026-11-11 10:27:41 -06:00
jason.woltje ce4fe048bb fix(discord): the connector's STOP refusal names the journal by label (#1873)
Re-check 3 found connector.start() still quoted the journal directory in
its STOP refusal, and the CLI passes a DiscordError through unchanged.
It now uses journalLabel like the CLI and journal refusals. The connector
test asserts the path is absent: 22/1 on the old source, 23/0 after.
2026-11-11 10:22:14 -06:00
jason.woltje 63e2a574af fix(discord): a native fs error never prints its path: CLI choke point and journal labels (#1873)
The discord CLI's top-level handler prints a native Node error that is not
already a labelled refusal as 'cannot <syscall> <label> (<CODE>)'. The journal
and approvals readers and writers name the file by label at the source, so
conversation, control-board and webui callers get the label too. The session
directory check moves to engine-pi.mjs as sessionContinues(). Exit codes and
refusal codes are unchanged.
2026-11-11 10:11:11 -06:00
jason.woltje d9023e28db fix(discord): check-then-read gaps name the file by label, never the path (#1873)
A token file, context file, tool root, data root or setspark key file
that changes between its check and its read failed with a native error
whose message carried the host path. Each read now names the file by
label and code (quietRead): same error object, type and code, so exit
codes, refusal codes and check order are unchanged.
2026-11-11 09:50:14 -06:00
jason.woltje c3fe6b77ad fix(discord): journal.mjs takes BINDING_NAME from errors.mjs, not binding.mjs (#1873)
The journal label import pulled binding.mjs and its config readers into
every journal.mjs importer. The queue test repo copies only journal.mjs
and errors.mjs, so its genesis failed with ERR_MODULE_NOT_FOUND (webui
reads.test.mjs red, 4 tests). BINDING_NAME now lives in errors.mjs and
binding.mjs re-exports it, so its export is unchanged.
2026-11-11 08:53:31 -06:00
jason.woltje 809b1b0100 test(control-board): board API body carries the seen marks label, not the path (#1873)
The board API returns a scan refusal in its JSON error body. The test
starts a board on port 0 over a seen.json that is not an object and
checks the body names seen.json by label, with no sentinel or scratch
path. It fails on BASE, where the body quotes the host path.
2026-11-11 08:53:31 -06:00
jason.woltje 4a9d9d4b89 fix(business): old-path re-export for system-config-problem.mjs (#1873)
rename-b-identity pins one packages/business/src module per moved
organization module; the review round's new module needs its shim.
2026-11-11 08:53:31 -06:00
jason.woltje 87db40e0cd fix(cli,seat,discord,organization,control-board,harness,plugins,webui): review round: launcher reasons, session, discord reads, mosaic-config and keys by label (#1873)
Review "changes 8" fix round for org-redact6.

- launcher: a harness module's throw names its system code or class,
  never its message; preflight, loader, bundle, checkout, sandbox and
  route text is relayed with host paths replaced (relay()).
- claude-code and pi preflights name MOSAIC_CLAUDE_BIN and the
  repository's node_modules, never a path; persona and skill bundle
  errors and sandboxProblem name their file or the sandbox by label.
- seat session: thread store, legacy store, overlap, busy lock and
  newRun refusals name the place under the data root.
- discord: unknown keys, binding names and turn ids are described when
  not plain; approvals, journal and repository read errors name the file
  and code; git drops stderr lines that carry a host path and a spawn
  failure names its code only.
- seat, control-board: registration fields and seen keys are described
  when not plain; invalid seat name and layout state the constraint.
- organization: the registry names the config directory and registry
  file by label; --project is quoted only when it is a plain name;
  systemConfigProblem moves to its own module.
- scripts/mosaic-config.mjs: the config by where it came from, the data
  root and its marker by role, keys and arguments only when plain, and
  error codes in place of raw messages. Exit codes unchanged.
- webui sso-config: an unknown console key is named only when plain.

Sentinel tests in each package's path-redaction-labels file fail on
BASE; pins that quoted a path now assert the label and its absence.
2026-11-11 08:53:31 -06:00
jason.woltje f3f33a6171 fix(organization,cli,discord,seat,control-board): refusals name the field or file by label, never a host path (#1873)
redact6. A refusal names the field, the file or the instance. It never quotes a
config value, a host path, or a raw e.message or stderr that carries one.

- organization: checkCredentialRef names the field and each forbidden root by
  label; OrgError keeps .path only for a document field ($...); the registry,
  fence and lock refusals use the quiet helpers (call and code).
- cli: loadSystem never relays mosaic-config stderr: instance refusals keep
  their code and names, everything else reads as a field and its constraint
  against a labelled config. Project-key, access warning, halt gate, sessions
  file, launch socket, harness relays and the workspace rename name labels.
  Launch-log reasons read a Node system error as its call and code.
- scripts/mosaic-config: configuration not found names where the config came from.
- discord, seat, control-board: config, token, context, tool-root, journal,
  binding, registration and seen-marks refusals name labels.

Exit codes, refusal codes and check order are unchanged. Each new or changed
test fails on BASE be698e602.
2026-11-11 08:53:31 -06:00
jason.woltje 5320e3d2c0 test(cli): the lifecycle fixture host answers definitionScan (#1873)
A4's hire writes an org:hire-<uuid> definition, so the chief's in-session
launch of its hire now reaches the launcher's held-secret check. The fixture's
op table lacked definitionScan and refused invalid-host-request; process.mjs
answers it.
2026-11-11 08:49:15 -06:00
jason.woltje 16cdf5cdc1 fix(organization): an approved hire and the boot copy definitions run the definition check (#1873)
The hire service takes the bus's definition check as scanDefinition and runs
it on the stored proposal entry before preview, check or apply goes further,
and on the approved entry again just before the writers. An approval whose
definition holds a credential the store came to hold after the raise refuses
definition-holds-credential before the decision is consumed; nothing is
written. Without the hook the entry still goes through assertClean whole.

Bus start and credentials.issue also check the boot copy snapshot's
definitions (A4 root agents file definitions, re-applied on every reload),
which no organization file holds.
2026-11-11 08:49:15 -06:00
jason.woltje 1a45a7274b fix(organization): credentials issue and rotate refuse a value already in an organization definition (#1873)
credentials.issue runs a check after it normalizes the value and before the store, the
organization file or memory changes: the value must not appear in the definitions of any
organization the bus serves (instructions, personality and every other field or key). A hit
refuses definition-holds-credential naming the organization, definition and key, never the
value or the credential. An organization file the check cannot read refuses
definition-scan-unreadable (new TI entry). The check runs on the bus loop inside issue, so a
launch admission can no longer pass and then see its definition text become held before spawn.
The final admission re-scan stays as the backstop for values held outside the verb.
2026-11-11 08:49:15 -06:00
jason.woltje 17eecb2d65 fix(organization): final launch admission re-checks the org definition against held credentials (#1873)
Review round (P2): a credential issued between the launcher's definition scan and
spawn could make text already in the definition a held value; a same-date rotate
leaves the organization file's revision unchanged, so the revision fence missed it.
The bus's final request.admission for an instance, the last bus op before spawn,
now runs the held-secret check on the org: definition the instance names, on the
bus loop that issue runs on, and refuses definition-holds-credential.

Self-found: check() takes the scanned entry from the same read as resolveInstance,
before any await can let another check replace the shared organization.
2026-11-11 08:49:15 -06:00
jason.woltje 8fec494694 fix(organization): bus start and launch check org definitions against the held credentials (#1873)
T1 follow-up. A definition's text becomes an agent's contract, but a load ran the
held-secret check with an empty store. Bus start now checks every definitions entry
(the boot copy's org: entries and the file's own) with the live store, and the
launch check asks the bus the same question for the org: definition it just read,
before a contract exists. A hit refuses as definition-holds-credential, a new code
in the refusal table; the refusal names the definition and key, never the text or
the credential. bus start records the refusal in the boot status.
2026-11-11 08:49:15 -06:00
137 changed files with 5665 additions and 537 deletions

No files matched your search

+1 -1
View File
@@ -333,7 +333,7 @@ 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 --dev-local-owner` refuses inside an agent run (exit 3), with or without `--detach`, checking the starting process's own environment as well as the one given for the Console; a start without `--dev-local-owner` is unchanged. A detached start whose supervisor does not reply within 60 seconds signals the supervisor's process group, then kills it, and clears `console/start.lock` only when the lock names that supervisor (pid and start time) and it is gone. Any other Console answers `not-supervised`. `console restart` waits up to 30 seconds for the relaunched Console to answer and prints `{"outcome":"restarted","from":OLD,"boot":NEW}`. `org reload ORG` restarts a running bus or starts a stopped one and prints the Console's done receipt. A request that left but got no answer is reported as outcome unknown (exit 1) and is never retried; check `mosaic console status`, `mosaic bus status ORG` and the trail. `mosaic bus restart ORG` remains the trail-less host verb.
The instance processing switch is separate from per-business launches off/on.
`mosaic halt` durably stops managed processing across every business in the
+91
View File
@@ -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();
},
};
}
+13
View File
@@ -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);
}
+302 -36
View File
@@ -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,6 +602,7 @@ export class Broker {
#builtinBinding;
#organization;
#hires;
#autonomyHost;
#pluginService;
#credentialVerbs;
#proxyInit;
@@ -660,7 +666,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, proxyPage = null, hostGrant = null, hostKeys = null, processing = null, testerOn = null, grantScope = null, roleFile = null, giteaGrant = null, giteaKeys = null, access = null, accessStamp = null, compactions = null, requestLifecycle = null, requirementsHost = null, workspaceRead = null, taskTracker = null }) {
if (!object(businesses) || !Object.keys(businesses).length) fail('invalid-business');
// Row 194 b15 (sage-2501): the function-argument checks come before anything reads the store.
if (harnessOn !== null && typeof harnessOn !== 'function') throw new TypeError('harnessOn must be a function');
@@ -684,6 +690,7 @@ 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;
@@ -3341,24 +3348,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 +3379,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 +3392,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 +4810,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 +5056,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 +5073,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 +5159,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 +5186,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 +5215,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 +5249,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;
});
@@ -5711,6 +5764,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);
});
@@ -6096,7 +6150,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');
@@ -6292,6 +6358,8 @@ export class Broker {
result = this.#credentialVerbs(s.business, verb, a, s.tester ? 'tester' : s.human);
} catch (e) {
if (e instanceof BusError && e.code.startsWith('credential-')) throw e;
// org-defscan (#1873): the issue's definition check; its reason names where, never the value.
if (e instanceof BusError && ['definition-holds-credential', 'definition-scan-unreadable'].includes(e.code)) throw e;
if (e.code === 'revision-conflict' || e.code?.startsWith('organization-')) fail(e.code);
if (e.name === 'BusinessError') fail('invalid-request');
throw new BusError('outcome-unknown');
@@ -6311,6 +6379,100 @@ export class Broker {
}
return result;
}
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;
@@ -6338,15 +6500,76 @@ export class Broker {
if(((p.flagged.length || p.credentialLines.length) && !caller.owner) || (p.level==='root' && !caller.instanceOwner))fail('hire-owner-required');
}
const action=`role.${p.op}`;
const level=decisionAutonomy('authority',b.autonomy);
let route='human';
if(!immediate && level!=='in the loop' && p.level!=='root' && !p.flagged.length && !p.credentialLines.length) {
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.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 || 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']);
@@ -6406,25 +6629,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');
@@ -6468,7 +6695,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.
@@ -7101,12 +7329,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, []);
@@ -7253,14 +7508,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');
@@ -7336,6 +7592,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,
@@ -7401,13 +7658,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(?,?,?,?,?,?,?)',
@@ -7420,6 +7685,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)
@@ -7844,19 +8110,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",
+12 -3
View File
@@ -1,5 +1,6 @@
import { BusError } from './broker.mjs';
import { isHostKind } from '../../organization/src/vocabulary.mjs';
import { scanOrganizations } from './definition-scan.mjs';
// Row 199 (#1697, design section 6): issue, rotate, revoke and delete of one agent's credentials, for
// the broker's human-only verbs. The broker checks the session, the keys and a running claim first.
// Each verb leaves one receipt in the organization's changes.jsonl: the actor and a detail naming the
@@ -10,7 +11,10 @@ const dateKey = (service) => (isHostKind(service) ? 'rotateBy' : DATE_KEY[servic
const deny = (code) => {
throw new BusError(code);
};
export function credentialVerbs({ credentials, writer }) {
// `organizations` lists every business this bus serves; issue checks each one's definitions. Without
// it, only the issuing business's. `boot` is the bus's boot copy snapshot's definitions, { org,
// definitions }, checked as well.
export function credentialVerbs({ credentials, writer, organizations = null, boot = null }) {
const state = (instance, service) =>
credentials.status().find((e) => e.instance === instance && e.service === service)?.state;
return (id, verb, args, actor) => {
@@ -27,8 +31,13 @@ export function credentialVerbs({ credentials, writer }) {
const next = structuredClone(document);
next.roles[agent].credentials[service] = { store: true, [dateKey(service)]: date };
const detail = { verb: kind, agent, kind: [service], date };
const status = credentials.issue(instance, service, token, date, () =>
writer.write(id, next, { revision, actor, detail }),
// org-defscan (#1873): the value must not already sit in any organization's definitions. The
// check runs inside issue, before the store, the file or memory changes, on this bus loop, so
// no launch admission can run between the check and the value becoming held.
const status = credentials.issue(
instance, service, token, date,
() => writer.write(id, next, { revision, actor, detail }),
(clean) => scanOrganizations({ organizations: organizations?.() ?? [id], own: { id, document }, readRaw: (b) => writer.readRaw(b), boot }, clean),
);
// Row 210 (design D9): a host key's public line and fingerprint, for ops-01's authorized_keys.
const host = status.fingerprint ? { publicKey: status.publicKey, fingerprint: status.fingerprint } : {};
+18 -9
View File
@@ -87,6 +87,19 @@ export function parseHostKey(text) {
}
return { text: lines.join('\n') + '\n', publicLine, fingerprint, seed, needles };
}
// 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) {
let encoded;
try {
encoded = JSON.stringify(value);
} catch {
deny('invalid-request');
}
for (const { token, model } of entries)
if ([token, ...(model?.retiring ?? []).map(r => r.key)].some(k => encoded?.includes(k))) deny('credential-leak');
}
// D8: refuses credential-leak when the value's JSON text holds any part of the key.
export function assertCleanKey(value, key) {
let encoded;
@@ -458,10 +471,13 @@ export class Credentials {
// Row 199 (design section 6): write the token to the agent's store path (tmp, fsync, rename), then
// `commit` (the business file's reference becomes store), then memory. The token comes from the
// caller's stdin and is never logged. Returns the entry's new status.
issue(instance, service, token, date, commit = () => {}) {
// org-defscan (#1873): `check` runs before anything changes. It gets an assertClean for this value
// alone, the test assertClean makes once the value is held; a throw leaves the store as it was.
issue(instance, service, token, date, commit = () => {}, check = () => {}) {
this.#known(instance, service);
if (!day(date)) deny('credential-date');
const value = Credentials.#format(service, token);
check((v) => assertNotHeld(v, [value]));
this.#invalidateModelProof(instance, service);
const file = this.#storePath(instance, service);
const agentDir = dirname(file);
@@ -600,14 +616,7 @@ export class Credentials {
return this.use(session.business + '/' + session.role, service, operation, options);
}
assertClean(value) {
let encoded;
try {
encoded = JSON.stringify(value);
} catch {
deny('invalid-request');
}
for (const { token, model } of this.#tokens.values())
if ([token, ...(model?.retiring ?? []).map(r => r.key)].some(k => encoded?.includes(k))) deny('credential-leak');
assertNotHeld(value, this.#tokens.values());
}
// Row 210 (design D5): whether a host.run for this agent and host is in flight.
busy(instance, service) {
+114
View File
@@ -0,0 +1,114 @@
// #1873 T1 follow-up (org-defscan): the live credential store's held-secret check on an org
// definition's text. A definition is role text written into the organization file; launch hands its
// instructions to an agent as the contract. A file edited by hand, or while the bus was down, never
// went through the writer's check, so bus start and every launch run this one.
//
// The answer names where a held credential sits, never the text there. A key that itself holds a
// credential is named by its parent ("personality key"). Not imported by broker.mjs (the sandbox
// loads that file); runtime.mjs and credential-verbs.mjs are its callers.
import { BusError } from './broker.mjs';
export const DEFINITION_HOLDS_CREDENTIAL = 'definition-holds-credential';
// An organization file credentials.issue could not read to check: the issue refuses.
export const DEFINITION_SCAN_UNREADABLE = 'definition-scan-unreadable';
// A definition name the scan quotes: the organization loader's id pattern. Anything else is "*".
const NAME = /^[a-z0-9][a-z0-9-]{0,63}$/;
// A path segment the scan quotes; anything else becomes "*" (the hit is still reported).
const SEGMENT = /^[A-Za-z0-9_-]{1,64}$/;
const MAX_DEPTH = 32;
const isClean = (assertClean, value) => {
try {
assertClean(value);
return true;
} catch {
return false;
}
};
// The first place in `entry` whose text the store holds, as a path ("instructions",
// "personality.traits[1]", "role key"), or null when the entry is clean. A hit the walk can't place
// (a value JSON.stringify sees and the walk doesn't) is "(entry)": the check fails closed.
export function heldPath(entry, assertClean) {
if (isClean(assertClean, entry)) return null;
const join = (path, key) => {
const segment = SEGMENT.test(key) && isClean(assertClean, key) ? key : '*';
return path ? `${path}.${segment}` : segment;
};
const walk = (value, path, depth) => {
if (typeof value === 'string') return isClean(assertClean, value) ? null : path || '(entry)';
if (value === null || typeof value !== 'object' || depth > MAX_DEPTH) return null;
if (Array.isArray(value)) {
for (let i = 0; i < value.length; i++) {
const hit = walk(value[i], `${path}[${i}]`, depth + 1);
if (hit) return hit;
}
return null;
}
for (const [key, child] of Object.entries(value)) {
if (!isClean(assertClean, key)) return path ? `${path} key` : 'key';
const hit = walk(child, join(path, key), depth + 1);
if (hit) return hit;
}
return null;
};
return walk(entry, '', 0) ?? '(entry)';
}
// The refusal for one definition. `name` is the definition's name without "org:". At issue time
// (`organization` given) it names the organization too, and the value is the one being issued.
export function definitionRefusal(name, path, { organization, boot = false } = {}) {
const quoted = typeof name === 'string' && NAME.test(name) ? name : '*';
const where = `${boot ? 'boot copy ' : ''}${name === null ? 'definitions' : `definitions.${quoted} ${path}`}`;
if (organization === undefined) return new BusError(DEFINITION_HOLDS_CREDENTIAL, `${where} holds a credential this bus holds`);
const org = typeof organization === 'string' && NAME.test(organization) ? organization : '*';
return new BusError(DEFINITION_HOLDS_CREDENTIAL, `organization ${org} ${where} holds the credential being issued`);
}
// The first entry of a `definitions` map that holds a credential, as { name, path }, or null.
// `prefix` is stripped from each name ("org:" for a business's resolved definitions). A map that is
// not an object is { name: null } when its text holds one.
export function heldDefinition(definitions, assertClean, { prefix = '' } = {}) {
if (definitions === undefined) return null; // an organization with no definitions key
if (definitions === null || typeof definitions !== 'object' || Array.isArray(definitions))
return isClean(assertClean, definitions) ? null : { name: null, path: null };
for (const [key, entry] of Object.entries(definitions)) {
if (prefix && !key.startsWith(prefix)) continue;
if (!isClean(assertClean, key)) return { name: '*', path: 'key' };
const path = heldPath(entry, assertClean);
if (path !== null) return { name: key.slice(prefix.length), path };
}
return null;
}
// Every entry of a `definitions` map; throws the refusal for the first that holds a credential.
export function scanDefinitions(definitions, assertClean, options = {}) {
const hit = heldDefinition(definitions, assertClean, options);
if (hit) throw definitionRefusal(hit.name, hit.path, options);
}
// org-defscan (#1873, Lead ruling on the re-check P2): credentials.issue and rotate refuse a value
// that already sits in any organization's definitions, so no definition text becomes a held value
// after its launch checks passed. `organizations` is every business this bus serves; `own` is the
// issuing one's document, already read. Any other file is read raw, as bus start reads it, so a
// definition no instance names is covered; a file that can't be read refuses. `boot` is the bus's boot
// copy snapshot ({ org, definitions }, #1873 A3/A4): the root agents file's definitions merged with the
// organization's at boot. Every reload re-applies it, so its text is live for the bus's lifetime.
export function scanOrganizations({ organizations, own, readRaw, boot = null }, assertClean) {
for (const organization of organizations) {
let definitions;
if (organization === own.id) definitions = own.document.definitions;
else {
let raw;
try {
raw = readRaw(organization);
} catch {
raw = { readable: false };
}
if (raw?.readable !== true) throw new BusError(DEFINITION_SCAN_UNREADABLE);
definitions = raw.document.definitions;
}
scanDefinitions(definitions, assertClean, { organization });
}
if (boot?.definitions !== undefined) scanDefinitions(boot.definitions, assertClean, { organization: boot.org, boot: true });
}
+12 -1
View File
@@ -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) ?? [];
+5 -2
View File
@@ -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) {
+2 -2
View File
@@ -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);
+7 -1
View File
@@ -2,6 +2,7 @@
// never command arguments, stdout or service-token-bearing environment variables.
import { startBroker } from './runtime.mjs';
import { BusError } from './broker.mjs';
import { DEFINITION_HOLDS_CREDENTIAL } from './definition-scan.mjs';
const LAUNCH_OPS = new Set([
'identity',
'authorizeLaunch',
@@ -9,6 +10,7 @@ const LAUNCH_OPS = new Set([
'endLaunch',
'recoverLaunch',
'credentialStatus',
'definitionScan',
'modelPreflight',
'modelAdmission',
'modelStall',
@@ -90,6 +92,8 @@ function launchOp(m) {
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 === '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 });
if (m.op === 'modelAdmission') return runtime.modelAdmission(m);
if (m.op === 'modelPreflight') return runtime.modelPreflight(m);
if (m.op === 'modelStall') return runtime.modelStall(m);
@@ -163,7 +167,9 @@ if (!process.send) {
}
process.send({ ok: true, path: runtime.path, launches: runtime.launches, readers: runtime.readers });
} catch (e) {
process.send?.(tag(message, { ok: false, error: e instanceof BusError ? e.code : 'startup-refused' }), () =>
// 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 } : {};
process.send?.(tag(message, { ok: false, error: e instanceof BusError ? e.code : 'startup-refused', ...reason }), () =>
close(2),
);
}
+15
View File
@@ -9,6 +9,11 @@ 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.");
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.");
@@ -90,6 +95,16 @@ group(
"Role configuration and protected instance controls",
"Read the permitted configuration/status and ask the owner or configured chief to repair it through the supported path. Do not edit policy to bypass the refusal."
);
group(
"definition-holds-credential",
"R1: no held credential in an organization definition's role text",
"Ask the organization owner to remove the credential from the named definition and key, then restart the bus, relaunch the agent or issue the credential again. For a hire, deny it and have the chief raise it again without that text. Do not copy the text anywhere to inspect it."
);
group(
"definition-scan-unreadable",
"R1/R3: a credential is issued only after every organization's definitions are checked",
"Ask the instance owner to repair the organization file the bus cannot read, then issue the credential again. Nothing was stored."
);
group(
"field-writer task-already-archived task-archived task-conflict task-create-conflict task-create-uncertain task-not-archived task-not-found task-position-full task-project-refused task-scope-required task-subject-required",
"Role task ownership, allowed fields and current task state",
+68 -21
View File
@@ -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';
@@ -38,6 +39,8 @@ import { ModelKeys, modelOrganization, stopModelRuns, MODEL_STOP_TIMEOUT_MS } fr
import { instanceSettings } from '../../organization/src/instance-settings.mjs';
import { organizationWriter, OrganizationError, setReplyLimits } from '../../organization/src/organization.mjs';
import { definitionVerbs, DEFINITION_BUS_VERBS } from '../../organization/src/definition-verbs.mjs';
import { ORG_PREFIX, orgDefinitionName } from '../../organization/src/definitions.mjs';
import { DEFINITION_HOLDS_CREDENTIAL, heldPath, scanDefinitions } from './definition-scan.mjs';
import { ROLE_RIGHTS } from '../../organization/src/org-rights.mjs';
import { getProjectRemote } from '../../organization/src/project-remotes.mjs';
import { credentialVerbs } from './credential-verbs.mjs';
@@ -96,6 +99,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 = [];
@@ -104,22 +110,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
@@ -387,6 +408,18 @@ export async function startBroker({
mkdirSync(storeRoot, { recursive: true, mode: 0o700 });
credentials = new Credentials({ references, repoRoots: protectedRoots, dataRoot, storeRoot, held,
modelAgents: proxy ? Object.entries(businesses).flatMap(([id, b]) => Object.keys(b.roles).map(role => id + '/' + role)) : [] });
// #1873 T1 follow-up (org-defscan): no organization definition's text holds a credential this store
// holds. The boot copy's org: definitions first, then every entry of the file itself, named or not.
// A hit refuses the start; the refusal names the definition and key, never the text.
const clean = value => credentials.assertClean(value);
for (const [id, b] of Object.entries(businesses)) {
scanDefinitions(b.definitions, clean, { prefix: ORG_PREFIX });
if (!organization) continue;
const raw = organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir, dataRoot: instanceRoot ?? dataRoot }).readRaw(id);
if (raw.readable) scanDefinitions(raw.document.definitions, clean);
}
// 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 });
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,
@@ -655,9 +688,19 @@ export async function startBroker({
if (instance !== undefined && (typeof instance !== 'string' || typeof launched !== 'string')) return no('project-unknown');
if (instance !== undefined) {
let now;
try { now = writer?.read(id).revision; } catch { return no('organization-unreadable'); }
if (now === undefined) return no('organization-unreadable');
if (now !== launched) return no('organization-changed');
try { now = writer?.read(id); } catch { return no('organization-unreadable'); }
if (now?.revision === undefined) return no('organization-unreadable');
if (now.revision !== launched) return no('organization-changed');
// org-defscan (#1873): the launch boundary. A credential issued since the launcher's scan
// runs on this same loop, so the org: definition the instance names is checked again here,
// in the reply the launcher spawns after with no further wait.
const name = orgDefinitionName(now.document.roles?.[instance]?.definition);
if (name !== null) {
const clean = value => credentials.assertClean(value);
let held;
try { clean(name); held = heldPath(now.document.definitions?.[name], clean); } catch { held = 'name'; }
if (held !== null) return no(DEFINITION_HOLDS_CREDENTIAL);
}
}
for (const p of projects) {
const result = admission({ business: id, project: p });
@@ -866,23 +909,11 @@ 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),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});
return authorityHost(service,{modelKeys,reload,proxyOrganization});
})() : null,
roleFile: organization ? (name) => loadRole(organization.rolesDir, name) : null,
secretCheck: (value) => credentials.assertClean(value),
@@ -900,7 +931,8 @@ export async function startBroker({
return settings;
} : null,
credentialVerbs: organization
? credentialVerbs({ credentials, writer: organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir, dataRoot:instanceRoot ?? dataRoot }) })
? 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 })
: null,
// Row 65: a new launch binds only on a harness its business switches on, read from the file
// as it stands now; an unreadable file switches none on.
@@ -1148,6 +1180,20 @@ export async function startBroker({
.filter((c) => c.instance === business + '/' + role)
.map(({ service, date, state, keyId, issuedAt }) => ({ service, date, state, ...(service === 'model' ? { keyId, issuedAt } : {}) }));
}
// #1873 T1 follow-up (org-defscan): the launcher's check of the org: definition it is about to
// launch, as it read it from the organization file. Answers where a held credential sits, or null;
// never the text. A malformed ask refuses, so the launch refuses.
function definitionScan({ business, definition, entry } = {}) {
if (typeof business !== 'string' || !Object.hasOwn(businesses, business) || typeof definition !== 'string'
|| !definition.startsWith(ORG_PREFIX) || definition.length > 128 || entry === null || typeof entry !== 'object' || Array.isArray(entry))
throw new BusError('invalid-request');
let size;
try { size = JSON.stringify(entry).length; } catch { throw new BusError('invalid-request'); }
if (size > 4 * 1024 * 1024) throw new BusError('invalid-request');
const clean = value => credentials.assertClean(value);
try { clean(definition); } catch { return { held: 'name' }; }
return { held: heldPath(entry, clean) };
}
// C2-03a: each library entry with a sync user fetches once at start, then every 60 s. A pass
// still running skips the tick. Only codes are logged, never git's output.
const timed = Object.values(businesses).flatMap(business => Object.entries(business.projects ?? {})
@@ -1321,6 +1367,7 @@ export async function startBroker({
sessionClearStart: (r) => broker.sessionClearStart(r),
refuse: (cap, code, subject) => broker.refuse(cap, code, subject),
credentialStatus,
definitionScan,
launches: bound,
readers: readCaps,
async close() {
@@ -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');}
});
+70
View File
@@ -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);
});
+164
View File
@@ -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});
});
+3 -2
View File
@@ -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' });
+336
View File
@@ -0,0 +1,336 @@
// #1873 T1 follow-up (org-defscan): bus start runs the live credential store's held-secret check on
// every organization definition, and the launch check asks the bus the same question. A hit names
// the definition and the key, never the text there or the credential's name.
import test from 'node:test';
import assert from 'node:assert/strict';
import { readdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { BusError } from '../src/broker.mjs';
import { REFUSALS } from '../src/refusals.mjs';
import { DEFINITION_HOLDS_CREDENTIAL, DEFINITION_SCAN_UNREADABLE, heldPath, scanDefinitions, scanOrganizations } from '../src/definition-scan.mjs';
import { startBroker } from '../src/runtime.mjs';
import { organizationWriter } from '../../organization/src/organization.mjs';
import { bootConfig, loadSystem } from '../../cli/src/config.mjs';
import { fixture, tmp } from '../../cli/tests/helpers.mjs';
import { entryFromTemplate, readTemplate } from '../../organization/src/definitions.mjs';
import { REPO_ROLES, writeJson } from '../../organization/tests/helpers.mjs';
// The fixture bus holds SENTINEL as coder's gitea token.
const SENTINEL = 'sk-test-NOT-A-SECRET-1234';
const UNHELD = 'sk-test-UNHELD-LOOKS-LIKE-A-TOKEN-0000';
function files(dir) {
return readdirSync(dir, { withFileTypes: true }).flatMap((e) => e.isDirectory() ? files(join(dir, e.name)) : e.isFile() ? [join(dir, e.name)] : []);
}
// An organization whose coder names org:builder, a copy of the coder template; `edit` changes the
// builder entry before the file is written. `held` is the coder's gitea token at start.
function org(t, edit = (entry) => entry, { held = SENTINEL } = {}) {
const root = tmp(t, 'defscan-');
const builder = edit(entryFromTemplate(readTemplate(REPO_ROLES, 'coder')));
const f = fixture(root, 'acme', (doc) => ({ ...doc, definitions: { builder }, roles: { ...doc.roles, coder: { ...doc.roles.coder, definition: 'org:builder' } } }));
writeFileSync(f.doc.roles.coder.credentials.gitea.file, held);
const file = join(root, 'config/businesses/acme.json');
const boot = () => bootConfig({ system: loadSystem({ env: f.env }), businessId: 'acme', env: f.env });
const start = (b = boot()) => startBroker({ dataRoot: f.dataRoot, businesses: b.businesses, organization: b.organization });
return { root, f, file, boot, start };
}
function refusedWithout(e, reason) {
assert.ok(e instanceof BusError, String(e));
assert.equal(e.code, DEFINITION_HOLDS_CREDENTIAL);
assert.equal(e.reason, reason);
for (const text of [e.message, e.reason, String(e.stack), JSON.stringify(e)]) {
assert.ok(!text.includes(SENTINEL), 'the refusal never carries the value');
assert.ok(!/gitea/.test(text), 'the refusal never names the credential');
}
return true;
}
// The start's outcome: the refusal, or "started" after closing a bus that should not have started.
const outcome = (start) => start.then(async (bus) => { await bus.close(); return 'started'; }, (e) => e);
function nothingWritten(root) {
for (const path of files(root)) {
if (path.endsWith('.token') || path.endsWith('acme.json')) continue;
assert.ok(!readFileSync(path).includes(SENTINEL), path);
}
}
test('org-defscan: the refusal code is in the TI refusal table with a next step', () => {
const entry = REFUSALS[DEFINITION_HOLDS_CREDENTIAL];
assert.ok(entry, 'definition-holds-credential is a known refusal');
assert.match(entry.rail, /^R\d+:/);
assert.match(entry.nextStep, /definition/);
assert.match(entry.nextStep, /issue the credential again/);
assert.match(entry.nextStep, /For a hire, deny it/);
// The issue-time check's fail-closed code for an organization file it cannot read.
const unreadable = REFUSALS[DEFINITION_SCAN_UNREADABLE];
assert.ok(unreadable, 'definition-scan-unreadable is a known refusal');
assert.match(unreadable.rail, /^R\d+/);
assert.match(unreadable.nextStep, /organization file/);
assert.equal(new BusError(DEFINITION_SCAN_UNREADABLE).nextStep, unreadable.nextStep);
});
test('org-defscan: a held credential in a definition\'s instructions refuses bus start, naming definition and key only', async (t) => {
const o = org(t, (entry) => ({ ...entry, instructions: `${entry.instructions}\nThe forge token is ${SENTINEL}.\n` }));
refusedWithout(await outcome(o.start()), 'definitions.builder instructions holds a credential this bus holds');
nothingWritten(o.root);
});
test('org-defscan: a held credential in a personality field refuses bus start the same way', async (t) => {
const o = org(t, (entry) => ({ ...entry, personality: { summary: `Careful. Key ${SENTINEL}.`, traits: ['plain'] } }));
refusedWithout(await outcome(o.start()), 'definitions.builder personality.summary holds a credential this bus holds');
const p = org(t, (entry) => ({ ...entry, personality: { summary: 'Careful.', traits: ['plain', `x${SENTINEL}`] } }));
refusedWithout(await outcome(p.start()), 'definitions.builder personality.traits[1] holds a credential this bus holds');
nothingWritten(o.root);
nothingWritten(p.root);
});
test('org-defscan: a definition in the file but not in the boot copy is checked too', async (t) => {
const o = org(t);
const boot = o.boot();
// Written after the load and before the bus starts: no instance names it, so only the raw read sees it.
const doc = JSON.parse(readFileSync(o.file, 'utf8'));
doc.definitions.spare = { ...doc.definitions.builder, instructions: `Spare: ${SENTINEL}\n` };
writeJson(o.file, doc);
refusedWithout(await outcome(o.start(boot)), 'definitions.spare instructions holds a credential this bus holds');
});
test('org-defscan: a clean file starts, and token-looking text the store does not hold starts', async (t) => {
for (const edit of [(entry) => entry, (entry) => ({ ...entry, instructions: `${entry.instructions}\nExample: ${UNHELD}\n`, personality: { summary: `Says ${UNHELD}.` } })]) {
const o = org(t, edit);
const bus = await o.start();
try {
// The launch check's question: a clean entry answers null, a held one names the key only.
const entry = o.boot().businesses.acme.definitions['org:builder'];
assert.deepEqual(bus.definitionScan({ business: 'acme', definition: 'org:builder', entry }), { held: null });
const hit = bus.definitionScan({ business: 'acme', definition: 'org:builder', entry: { ...entry, instructions: `x ${SENTINEL}` } });
assert.deepEqual(hit, { held: 'instructions' });
// A malformed question refuses rather than answering clean.
for (const bad of [{}, { business: 'acme', definition: 'builder', entry }, { business: 'other', definition: 'org:builder', entry },
{ business: 'acme', definition: 'org:builder', entry: null }, { business: 'acme', definition: 'org:builder', entry: [entry] }])
assert.throws(() => bus.definitionScan(bad), (e) => e.code === 'invalid-request');
} finally {
await bus.close();
}
}
});
// The coder's gitea credential as the human verb issues or rotates it.
function verbs(o, bus) {
const human = bus.broker.bindHuman({ business: 'acme', human: o.f.doc.human, via: 'cli', outsideAgent: true });
return {
issue: (token) => bus.broker.request(human, { verb: 'credentials.issue', args: { agent: 'coder', service: 'gitea', token, date: '2099-01-01' } }),
revoke: () => bus.broker.request(human, { verb: 'credentials.revoke', args: { agent: 'coder', service: 'gitea', reason: 'test' } }),
};
}
// Whether the bus holds `value` now, asked the way the launch check asks.
const holds = (bus, value) => bus.definitionScan({ business: 'acme', definition: 'org:builder', entry: { instructions: `x ${value}` } }).held !== null;
const state = (bus) => bus.credentials.status().find((e) => e.instance === 'acme/coder' && e.service === 'gitea')?.state;
function issueRefused(e, value, reason) {
assert.ok(e instanceof BusError, String(e));
assert.equal(e.code, DEFINITION_HOLDS_CREDENTIAL);
assert.equal(e.reason, reason);
for (const text of [e.message, e.reason, String(e.stack), JSON.stringify(e)]) {
assert.ok(!text.includes(value), 'the refusal never carries the value');
assert.ok(!/gitea/.test(text), 'the refusal never names the credential');
}
return true;
}
// No file under the root holds `value` except the organization file it was written into and that
// file's own history copies.
function onlyInOrganization(root, value) {
for (const path of files(root))
if (!path.endsWith('acme.json') && !path.includes('/businesses/.history/acme/')) assert.ok(!readFileSync(path).includes(value), path);
}
test('org-defscan: a credential issued between the final launch admission and spawn is refused at issue; the store stays as it was', async (t) => {
// The definition carries text that is not held at start. After the launch's final admission answers
// ready, the human rotates the coder's token to that text with the same date. On the bus before this
// fix the rotate succeeded and the agent spawned with a held value in its contract.
const FIRST = 'fake_first_token_0123456789';
const o = org(t, (entry) => ({ ...entry, instructions: `${entry.instructions}\nExample ${SENTINEL}\n` }), { held: 'fake_boot_token_5555555555' });
const boot = o.boot();
const bus = await o.start(boot);
t.after(() => bus.close());
const v = verbs(o, bus);
// A clean issue still works.
assert.equal(v.issue(FIRST).state, 'valid');
const writer = organizationWriter({ dir: boot.organization.dir, rolesDir: boot.organization.rolesDir, dataRoot: o.f.dataRoot });
const revision = writer.read('acme').revision;
const changes = files(o.root).filter((p) => p.endsWith('changes.jsonl')).map((p) => [p, readFileSync(p, 'utf8')]);
const entry = o.boot().businesses.acme.definitions['org:builder'];
assert.deepEqual(bus.definitionScan({ business: 'acme', definition: 'org:builder', entry }), { held: null });
const final = () => bus.admission({ business: 'acme', projects: [], instance: 'coder', revision });
assert.equal(final().ready, true);
// Between the admission's answer and the spawn: the rotate.
assert.throws(() => v.issue(SENTINEL), (e) => issueRefused(e, SENTINEL, 'organization acme definitions.builder instructions holds the credential being issued'));
// Nothing changed: the old token is held, the new one is not, the file and its receipts are as they were.
assert.ok(holds(bus, FIRST) && !holds(bus, SENTINEL));
assert.equal(state(bus), 'valid');
assert.equal(writer.read('acme').revision, revision);
for (const [p, text] of changes) assert.equal(readFileSync(p, 'utf8'), text, p);
onlyInOrganization(o.root, SENTINEL);
assert.equal(final().ready, true, 'the contract the agent gets holds no held value');
});
test('org-defscan: issue over a revoked entry refuses the same way for a personality field, and a clean issue then works', async (t) => {
const o = org(t, (entry) => ({ ...entry, personality: { summary: 'Careful.', traits: ['plain', `uses ${SENTINEL}`] } }), { held: 'fake_boot_token_5555555555' });
const bus = await o.start();
t.after(() => bus.close());
const v = verbs(o, bus);
v.revoke();
assert.equal(state(bus), 'revoked');
assert.throws(() => v.issue(SENTINEL), (e) => issueRefused(e, SENTINEL, 'organization acme definitions.builder personality.traits[1] holds the credential being issued'));
assert.equal(state(bus), 'revoked');
assert.ok(!holds(bus, SENTINEL));
onlyInOrganization(o.root, SENTINEL);
const issued = v.issue('fake_clean_token_4444444444');
assert.deepEqual([issued.verb, issued.state], ['issue', 'valid']);
});
test('org-defscan: a value held without the issue verb still refuses the launch at its final admission, even when the file revision is unchanged', async (t) => {
// Defence behind the issue check: the store comes to hold text already in the definition by a path
// other than the human verb, which leaves the organization file's bytes, so its revision, as they were.
const ROTATED = 'fake_rotated_token_9876543210';
const o = org(t, (entry) => ({ ...entry, instructions: `${entry.instructions}\nExample ${ROTATED}\n` }));
const boot = o.boot();
const bus = await o.start(boot);
t.after(() => bus.close());
const writer = organizationWriter({ dir: boot.organization.dir, rolesDir: boot.organization.rolesDir, dataRoot: o.f.dataRoot });
const revision = writer.read('acme').revision;
const final = () => bus.admission({ business: 'acme', projects: [], instance: 'coder', revision });
assert.equal(final().ready, true);
bus.credentials.issue('acme/coder', 'gitea', ROTATED, '2099-01-01');
assert.equal(writer.read('acme').revision, revision, 'the store change left the file as it was');
assert.deepEqual(final(), { ready: false, revision: null, code: DEFINITION_HOLDS_CREDENTIAL });
// An instance on a shipped template is not asked about a definition.
assert.equal(bus.admission({ business: 'acme', projects: [], instance: 'reviewer', revision }).ready, true);
});
test('org-defscan: issue checks the other organizations the bus serves, and refuses when one\'s file cannot be read', async (t) => {
// The bus serves acme and beta. beta's file has a definition holding text acme's coder then tries to
// be issued; later beta's file stops being JSON. Neither issue changes acme's store or file.
const o = org(t, (entry) => entry, { held: 'fake_boot_token_5555555555' });
const boot = o.boot();
const betaFile = join(o.root, 'config/businesses/beta.json');
const acme = JSON.parse(readFileSync(o.file, 'utf8'));
const spare = { ...acme.definitions.builder, personality: { summary: 'Spare.', voice: `quotes ${SENTINEL}` } };
writeJson(betaFile, { ...acme, id: 'beta', definitions: { spare } });
const bus = await o.start({ ...boot, businesses: { ...boot.businesses, beta: { ...boot.businesses.acme, id: 'beta' } } });
t.after(() => bus.close());
const v = verbs(o, bus);
const writer = organizationWriter({ dir: boot.organization.dir, rolesDir: boot.organization.rolesDir, dataRoot: o.f.dataRoot });
const revision = writer.read('acme').revision;
assert.throws(() => v.issue(SENTINEL), (e) => issueRefused(e, SENTINEL, 'organization beta definitions.spare personality.voice holds the credential being issued'));
writeFileSync(betaFile, '{ not json');
const CLEAN = 'fake_clean_token_4444444444';
assert.throws(() => v.issue(CLEAN), (e) => e instanceof BusError && e.code === DEFINITION_SCAN_UNREADABLE && !JSON.stringify(e).includes(CLEAN) && !String(e.stack).includes(CLEAN));
for (const value of [SENTINEL, CLEAN]) assert.ok(!holds(bus, value));
assert.equal(state(bus), 'valid');
assert.equal(writer.read('acme').revision, revision);
onlyInOrganization(o.root, CLEAN);
// With beta's file readable again, the clean issue goes through.
writeJson(betaFile, { ...acme, id: 'beta', definitions: {} });
assert.equal(v.issue(CLEAN).state, 'valid');
assert.ok(holds(bus, CLEAN));
});
test('org-defscan: the issue check covers every organization the bus serves, and an unreadable file refuses', () => {
const held = 'HELD-VALUE-77';
const assertClean = (value) => { if ((JSON.stringify(value) ?? '').includes(held)) throw new BusError('credential-leak'); };
const own = { id: 'acme', document: { definitions: { builder: { instructions: 'fine' } } } };
const files = { beta: { readable: true, document: { definitions: { spare: { instructions: 'fine', personality: { voice: `say ${held}` } } } } },
gamma: { readable: true, document: {} }, broken: { readable: false } };
const reads = [];
const readRaw = (id) => { reads.push(id); if (id === 'throws') throw new Error('EACCES'); return files[id]; };
assert.doesNotThrow(() => scanOrganizations({ organizations: ['acme', 'gamma'], own, readRaw }, assertClean));
assert.deepEqual(reads, ['gamma'], 'the issuing organization is the document already read');
assert.throws(() => scanOrganizations({ organizations: ['acme', 'gamma', 'beta'], own, readRaw }, assertClean),
(e) => e.code === DEFINITION_HOLDS_CREDENTIAL && e.reason === 'organization beta definitions.spare personality.voice holds the credential being issued' && !JSON.stringify(e).includes(held));
assert.throws(() => scanOrganizations({ organizations: ['acme', { ...own, id: 'x' }.id], own: { id: 'acme', document: { definitions: { [held]: {} } } }, readRaw: () => ({ readable: true, document: {} }) }, assertClean),
(e) => e.reason === 'organization acme definitions.* key holds the credential being issued' && !e.reason.includes(held));
for (const bad of ['broken', 'throws', 'missing'])
assert.throws(() => scanOrganizations({ organizations: ['acme', bad], own, readRaw }, assertClean), (e) => e.code === DEFINITION_SCAN_UNREADABLE && e.reason === undefined);
});
test('org-defscan: heldPath names the first place, a key by its parent, and never the text', () => {
const held = 'HELD-VALUE-77';
const assertClean = (value) => { if ((JSON.stringify(value) ?? '').includes(held)) throw new BusError('credential-leak'); };
assert.equal(heldPath({ instructions: 'fine', personality: { traits: ['a', 'b'] } }, assertClean), null);
assert.equal(heldPath({ instructions: 'fine', personality: { traits: ['a', `b${held}`] } }, assertClean), 'personality.traits[1]');
assert.equal(heldPath({ role: { tools: [held] } }, assertClean), 'role.tools[0]');
assert.equal(heldPath({ personality: { [held]: 'x' } }, assertClean), 'personality key');
assert.equal(heldPath({ [`${held}`]: 'x' }, assertClean), 'key');
assert.throws(() => scanDefinitions({ 'org:a': { instructions: 'ok' }, 'org:b': { instructions: held } }, assertClean, { prefix: 'org:' }),
(e) => e.code === DEFINITION_HOLDS_CREDENTIAL && e.reason === 'definitions.b instructions holds a credential this bus holds');
// A name outside the loader's pattern is not quoted.
assert.throws(() => scanDefinitions({ 'Bad Name': { instructions: held } }, assertClean), (e) => e.reason === 'definitions.* instructions holds a credential this bus holds');
assert.throws(() => scanDefinitions({ [held]: { instructions: 'ok' } }, assertClean), (e) => e.reason === 'definitions.* key holds a credential this bus holds' && !e.reason.includes(held));
assert.doesNotThrow(() => scanDefinitions(undefined, assertClean));
});
test('org-defscan: an approved hire whose definition holds a credential issued after the raise refuses before the decision is consumed', async (t) => {
// The chief raises a hire whose instructions carry text the store does not hold yet. The proposal is
// private, so the issue-time check cannot see it, and the store then comes to hold that text. The
// approval runs the same definition check as start, launch and issue: the refusal names the code, the
// decision stays open, and the organization file is as it was.
const LATER = 'fake_later_token_0123456789';
const o = org(t, (entry) => entry, { held: 'fake_boot_token_5555555555' });
const boot = o.boot();
const bus = await o.start(boot);
t.after(() => bus.close());
const chief = bus.broker.bindLaunch({ business: 'acme', role: 'chief', run: 'chief-run', harness: 'pi' });
bus.broker.request(chief, { verb: 'role.claim' });
const inline = { tools: ['read'], network: 'none', authority: { withinRole: [], crossRole: [] }, credentials: [], instructions: `Read and report.\nExample ${LATER}\n` };
const raised = await bus.broker.request(chief, { verb: 'hire.raise', args: { op: 'hire', level: 'org', name: 'scout', role: { inline }, route: { harness: 'pi', model: 'glm-5.3-flash' }, reason: 'Need help' } });
// Issued through the store, so the organization file and its revision stay as they were and the
// approval reaches the definition check rather than the revision check.
bus.credentials.issue('acme/coder', 'gitea', LATER, '2099-01-01');
assert.ok(holds(bus, LATER));
const writer = organizationWriter({ dir: boot.organization.dir, rolesDir: boot.organization.rolesDir, dataRoot: o.f.dataRoot });
const before = readFileSync(o.file);
const revision = writer.read('acme').revision;
const owner = bus.broker.bindConsole({ business: 'acme', human: o.f.doc.human, subject: { provider: 'test', iss: 'https://issuer.test', sub: 'owner' } });
const refused = (e) => {
assert.equal(e.code, DEFINITION_HOLDS_CREDENTIAL);
for (const text of [e.message, String(e.reason), String(e.stack), JSON.stringify(e)]) assert.ok(!text.includes(LATER), 'the refusal never carries the value');
return true;
};
await assert.rejects(bus.broker.request(owner, { verb: 'hire.resolve.console', args: { id: raised.id, choice: 'yes', digest: raised.hire.digest } }), refused);
// Nothing was written.
assert.deepEqual(readFileSync(o.file), before);
assert.equal(writer.read('acme').revision, revision);
assert.ok(!Object.hasOwn(writer.read('acme').document.roles, 'scout'));
// The decision was not consumed: a second approval meets the same check, not decision-closed.
await assert.rejects(bus.broker.request(owner, { verb: 'hire.resolve.console', args: { id: raised.id, choice: 'yes', digest: raised.hire.digest } }), refused);
assert.deepEqual(readFileSync(o.file), before);
// A hire with clean text, raised and approved on the same bus, is written as before.
const clean = await bus.broker.request(chief, { verb: 'hire.raise', args: { op: 'hire', level: 'org', name: 'helper', role: { inline: { ...inline, instructions: 'Read and report.\n' } }, route: { harness: 'pi', model: 'glm-5.3-flash' }, reason: 'Need help' } });
assert.equal((await bus.broker.request(owner, { verb: 'hire.resolve.console', args: { id: clean.id, choice: 'yes', digest: clean.hire.digest } })).choice, 'yes');
assert.ok(Object.hasOwn(writer.read('acme').document.roles, 'helper'));
onlyInOrganization(join(o.root, 'config/businesses'), LATER);
});
test('org-defscan: the boot copy snapshot\'s definitions (the root agents file\'s) are checked at start and at issue', async (t) => {
// A4: a root definition reaches an organization through the boot copy snapshot, which every reload
// re-applies. Its text is in no organization file, so the start and issue checks read the snapshot.
const LATER = 'fake_later_token_0123456789';
const snapshot = (instructions) => ({ org: 'acme', root: 'ok', rootAgents: {}, copies: {},
definitions: { rooted: { ...entryFromTemplate(readTemplate(REPO_ROLES, 'coder')), instructions } } });
const startWith = (o, copies, b = o.boot()) => startBroker({ dataRoot: o.f.dataRoot, businesses: b.businesses, organization: b.organization, copies });
// Held at start: the start refuses, naming the snapshot's definition and key.
const held = org(t);
refusedWithout(await outcome(startWith(held, snapshot(`Root copy ${SENTINEL}\n`))), 'boot copy definitions.rooted instructions holds a credential this bus holds');
// Not held at start: the bus starts, then issuing that text refuses and the store is as it was.
const o = org(t, (entry) => entry, { held: 'fake_boot_token_5555555555' });
const bus = await startWith(o, snapshot(`Root copy ${LATER}\n`));
t.after(() => bus.close());
assert.throws(() => verbs(o, bus).issue(LATER), (e) => issueRefused(e, LATER, 'organization acme boot copy definitions.rooted instructions holds the credential being issued'));
assert.ok(!holds(bus, LATER));
assert.equal(state(bus), 'valid');
assert.equal(verbs(o, bus).issue('fake_clean_token_4444444444').state, 'valid');
});
@@ -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);
});
+1 -1
View File
@@ -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);
});
+3 -3
View File
@@ -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)
+7
View File
@@ -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);
});
+4 -1
View File
@@ -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/);
});
+1 -1
View File
@@ -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']);
});
+3
View File
@@ -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, []);
});
+2 -2
View File
@@ -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 }),
+15
View File
@@ -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');
});
+2
View File
@@ -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/system-config-problem.mjs (rename design r2, stage B).
export * from "../../organization/src/system-config-problem.mjs";
+4 -3
View File
@@ -37,9 +37,10 @@ function write(dataRoot, status, event) {
appendFileSync(bootEventsFile(dataRoot), `${JSON.stringify(event)}\n`, { mode: 0o600 });
}
// A start refused: keeps the time of the last good load from the status it replaces.
export function recordBootRefused(dataRoot, { business, file, reason, at = new Date().toISOString() }) {
const prior = readBootStatus(dataRoot);
// A start refused: keeps the time of the last good load from the status it replaces. `prior` is the
// status from before this start, for a refusal that comes after the start recorded its load (org-defscan:
// the broker's held-secret check on the definitions).
export function recordBootRefused(dataRoot, { business, file, reason, at = new Date().toISOString(), prior = readBootStatus(dataRoot) }) {
const lastGood = prior?.ok ? prior.at : prior?.lastGood ?? null;
const text = String(reason).slice(0, REASON_MAX);
write(dataRoot, { ok: false, at, business, file, reason: text, lastGood }, { event: "bus.boot.refused", at, business, file, reason: text });
+12 -3
View File
@@ -519,7 +519,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`);
@@ -594,6 +595,8 @@ async function consoleCommand(rest, io, deps) {
// org-lan-bind: refused here, before any config is read, unless both flags come with
// --dev-local-owner. ADDR is 0.0.0.0 or an IPv4 address; NAME[,NAME...] has no ports.
const devOwner = parsed.flags.has("--dev-local-owner"), bind = parsed.values["--dev-insecure-bind"] ?? null;
// D1: an agent run never starts a dev-local-owner Console (org-console.mjs consoleStart checks again).
if (devOwner) { refuseInsideAgent(process.env); refuseInsideAgent(io.env); }
const names = Object.hasOwn(parsed.values, "--dev-allow-host") ? [parsed.values["--dev-allow-host"]] : [];
let lan;
try { lan = devLanOptions({ devOwner, bind, names }); } catch (err) { throw usage(err.message); }
@@ -651,9 +654,14 @@ async function bus(rest, io, deps) {
throw e;
}
if (instance) boot.consoleOwners = [...(boot.consoleOwners ?? []), DEV_OWNER];
const priorBoot = readBootStatus(org.root);
try { recordBootOk(org.root, { business: businessId, file }); } catch { /* status is advisory */ }
const binding = readNotifyConfig(org.root, businessId);
const host = await startHost({ boot, business: businessId, notifier: binding ? { binding } : null, refuseStale: true, options: { devLocalOwner: Boolean(instance), pm: parsed.flags.has("--pm"), noLaunch: !launches } });
const host = await startHost({ boot, business: businessId, notifier: binding ? { binding } : null, refuseStale: true, options: { devLocalOwner: Boolean(instance), pm: parsed.flags.has("--pm"), noLaunch: !launches } }).catch((e) => {
// org-defscan: the broker refused a definition that holds a credential it holds; the load was not good after all.
if (e.code === "definition-holds-credential") try { recordBootRefused(org.root, { business: businessId, file, reason: e.message, prior: priorBoot }); } catch { /* the refusal still throws */ }
throw e;
});
try {
// A dev fixture serves only the organizations that use it; their launches carry no provider credential.
const orgDoc = instance ? JSON.parse(readFileSync(file, "utf8")) : undefined;
@@ -697,7 +705,8 @@ 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`);
if (sub === "recover") devOwnerInstance(io.env);
// D2 security review: recovery clears host state, so an agent run is refused before any read.
if (sub === "recover") { refuseInsideAgent(io.env); devOwnerInstance(io.env); }
const ctx = organizationContext(io, deps), id = parsed.positional[0];
const result = sub === "stop" ? await busStop(ctx, id) : sub === "restart" ? await busRestart(ctx, id) : sub === "recover" ? await busRecover(ctx, id) : await busStatus(ctx, id);
io.stdout.write(`${JSON.stringify(result, null, parsed.flags.has("--json") ? 2 : 0)}\n`);
+37 -6
View File
@@ -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 };
}
+39
View File
@@ -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);
+4 -1
View File
@@ -228,7 +228,10 @@ export async function startHost({ boot, business, notifier = null, options = nul
if (ready?.ok !== true) {
await ended(broker, 5000);
release();
throw new CliError(`broker refused to start: ${typeof ready?.error === "string" ? ready.error : "startup-refused"}`, 3);
const code = typeof ready?.error === "string" ? ready.error : "startup-refused";
// org-defscan: the broker's reason names the definition and key that hold a credential, never the text.
const why = code === "definition-holds-credential" && typeof ready?.reason === "string" ? `: ${ready.reason.slice(0, 300)}` : "";
throw Object.assign(new CliError(`broker refused to start: ${code}${why}`, 3), code === "definition-holds-credential" ? { code } : {});
}
// Every step from here to host.json shares one failure path: it stops the
+50 -25
View File
@@ -145,6 +145,7 @@ 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 { selectModelProvider } from "../../plugins/src/model-provider-selection.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}`);
}
}
@@ -272,7 +284,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.
@@ -380,11 +393,13 @@ export function createLauncher({
// workspace and the thread's store, so no session of the instance starts while one lives.
if (threaded && [...registry.sessions, ...leftover, ...survivors.filter(liveRun)].some((s) => s.business === b.id && s.instance === instance && !s.session))
throw new Refusal("legacy-run-live", `a run of ${instance} with no session is still live`);
let resolved;
let resolved, definitionEntry;
try {
resolved = resolveInstance({ system: vars, business: b, instance, enforcePlugins:true, pluginRoot:system.instanceRoot ?? dataRoot });
// 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.
@@ -407,17 +422,23 @@ 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");
const fallback = hasFallback(resolved) ? routeOf("fallback") : null;
if (!resolved.contract && typeof resolved.instructions !== "string") throw new Refusal("no-role-contract", `role ${resolved.definition} has no contract file`);
// org-defscan (#1873): an org: definition, as this check just read it, passes the bus's held-secret check before a contract exists.
if (String(resolved.definition).startsWith("org:")) {
const { held } = (await host.op({ op: "definitionScan", business: b.id, definition: resolved.definition, entry: definitionEntry })) ?? {};
if (typeof held === "string") throw new Refusal("definition-holds-credential", `${instance}'s definition ${resolved.definition} holds a credential in ${held}`);
if (held !== null) throw new Refusal("organization-refused", `the bus did not check ${instance}'s definition ${resolved.definition}`);
}
// Row 64 (#1557): every skill on the role's list is enabled and readable before a run exists.
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.
@@ -429,7 +450,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}`);
@@ -476,7 +499,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,
@@ -524,7 +547,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
@@ -534,7 +558,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];
}
@@ -656,7 +680,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) {
@@ -853,7 +877,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,
@@ -1033,7 +1057,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}:{}) });
}
});
}
@@ -1135,7 +1159,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;
}
@@ -1195,7 +1219,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 } : {}) });
}
});
}
@@ -1240,7 +1264,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 } : {}) });
@@ -1348,18 +1372,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 });
}
@@ -1430,7 +1455,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 {
@@ -1445,7 +1470,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 });
}
});
@@ -1459,8 +1484,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;
}
@@ -1511,7 +1536,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) => {
+62 -10
View File
@@ -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;
}
+81 -2
View File
@@ -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,76 @@ 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. Other starts are unchanged. This process's own
// environment is checked as well as the one given for the Console (security re-check P1).
if (devLocalOwner) { refuseInsideAgent(process.env); if (ctx.env) refuseInsideAgent(ctx.env); }
if (detach && devLocalOwner) return detachedSupervisor(ctx, { port, bind, allowHosts, warn });
const release = takeLock(ctx);
let run, args;
try {
+3 -1
View File
@@ -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);
}
});
+1 -1
View File
@@ -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"`);
+31 -5
View File
@@ -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.
+129
View File
@@ -0,0 +1,129 @@
// #1873 T1 follow-up (org-defscan): a definition that holds a credential the bus holds refuses at
// launch (the launch check asks the bus before a contract exists) and at `mosaic bus start`, and the
// value reaches no refusal, record or log.
import test from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { createLauncher } from '../src/launcher.mjs';
import { bootConfig, loadSystem } from '../src/config.mjs';
import { readBootStatus, bootEventsFile } from '../src/boot-status.mjs';
import { hostFile } from '../src/host.mjs';
import { startBroker } from '../../bus/src/runtime.mjs';
import { initializeControl } from '../../processing-control/src/state.mjs';
import { launchLogFile } from '../../seat/src/session.mjs';
import { entryFromTemplate, readTemplate } from '../../organization/src/definitions.mjs';
import { reserveOrg, activateOrg } from '../../organization/src/org-registry.mjs';
import { REPO_ROLES, writeJson } from '../../organization/tests/helpers.mjs';
import { fixture, notifyConfig, tmp, withoutHostCredentials } from './helpers.mjs';
// The fixture bus holds SENTINEL as coder's gitea token.
const SENTINEL = 'sk-test-NOT-A-SECRET-1234';
const CLI = fileURLToPath(new URL('../src/cli.mjs', import.meta.url));
function files(dir) {
if (!existsSync(dir)) return [];
return readdirSync(dir, { withFileTypes: true }).flatMap((e) => e.isDirectory() ? files(join(dir, e.name)) : e.isFile() ? [join(dir, e.name)] : []);
}
const withBuilder = (builder) => (doc) => ({ ...doc, definitions: { builder }, roles: { ...doc.roles, coder: { ...doc.roles.coder, definition: 'org:builder' } } });
test('org-defscan: a file edited after start to hold a held credential refuses the next launch of an instance naming it; a template launch still works', async (t) => {
const root = tmp(t);
const f = fixture(root, 'acme', withBuilder(entryFromTemplate(readTemplate(REPO_ROLES, 'coder'))));
const file = join(root, 'config/businesses/acme.json');
writeFileSync(f.doc.roles.coder.credentials.gitea.file, SENTINEL);
withoutHostCredentials(f.env, { piKey: true });
initializeControl(f.dataRoot);
const boot = bootConfig({ system: loadSystem({ env: f.env }), businessId: 'acme', env: f.env });
const bus = await startBroker({ dataRoot: f.dataRoot, businesses: boot.businesses, organization: boot.organization });
t.after(() => bus.close());
// The real host's op path for the scan, over a structured clone as IPC carries it; the fixture
// stops at authorization, so "reached authorizeLaunch" is "would have launched".
const ops = [];
const host = { business: 'acme', beforeClose: () => {}, op: async (x) => {
ops.push(x.op);
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' });
if (x.op === 'definitionScan') return structuredClone(bus.definitionScan(structuredClone({ business: x.business, definition: x.definition, entry: x.entry })));
return {};
} };
const launcher = createLauncher({ host, system: JSON.parse(readFileSync(f.config, 'utf8')), env: f.env, bwrap: '/usr/bin/true', log: () => {} });
t.after(() => launcher.close?.());
// Clean: the scan passes and the launch reaches authorization.
assert.equal((await launcher.fromSession('fixture-cap', 'coder')).error, 'fixture-policy');
assert.ok(ops.includes('definitionScan'));
// Edited by hand while the bus runs.
const doc = JSON.parse(readFileSync(file, 'utf8'));
doc.definitions.builder.instructions += `\nThe forge token is ${SENTINEL}.\n`;
writeJson(file, doc);
ops.length = 0;
assert.deepEqual(await launcher.fromSession('fixture-cap', 'coder'), { ok: false, error: 'definition-holds-credential' });
assert.ok(!ops.includes('authorizeLaunch'), 'nothing launches');
const event = JSON.parse(readFileSync(launchLogFile(f.dataRoot, 'acme'), 'utf8').trim().split('\n').at(-1));
assert.equal(event.code, 'definition-holds-credential');
assert.equal(event.instance, 'coder');
assert.match(event.why, /coder's definition org:builder holds a credential in instructions/);
// A personality field is checked the same way.
doc.definitions.builder.instructions = doc.definitions.builder.instructions.replace(SENTINEL, 'REDACTED');
doc.definitions.builder.personality = { summary: 'Plain.', voice: `Signs off with ${SENTINEL}` };
writeJson(file, doc);
assert.deepEqual(await launcher.fromSession('fixture-cap', 'coder'), { ok: false, error: 'definition-holds-credential' });
assert.match(JSON.parse(readFileSync(launchLogFile(f.dataRoot, 'acme'), 'utf8').trim().split('\n').at(-1)).why, /in personality\.voice$/);
// An instance on a shipped template launches as before, and asks no scan.
ops.length = 0;
assert.equal((await launcher.fromSession('fixture-cap', 'reviewer')).error, 'fixture-policy');
assert.ok(!ops.includes('definitionScan'));
// The value reached no record the bus or the launcher wrote.
for (const path of files(f.dataRoot)) assert.ok(!readFileSync(path).includes(SENTINEL), path);
});
// A bootstrapped instance with an active organization whose file names org:builder.
async function orgFixture(t, builder) {
const root = tmp(t, 'o-');
const f = fixture(root, 'acme', withBuilder(builder));
const configDir = join(root, '.config', 'mosaic-boot');
const env = { ...f.env, HOME: root, MOSAIC_CONFIG: join(configDir, 'config.json') };
const bootstrap = spawnSync(join(process.cwd(), 'scripts/bootstrap.sh'), ['--instance', 'boot'], { env, encoding: 'utf8' });
assert.equal(bootstrap.status, 0, bootstrap.stderr);
mkdirSync(join(configDir, 'businesses'), { recursive: true, mode: 0o700 });
const file = join(configDir, 'businesses', 'acme.json');
writeFileSync(file, readFileSync(join(root, 'config/businesses/acme.json')), { mode: 0o600 });
writeFileSync(f.doc.roles.coder.credentials.gitea.file, SENTINEL);
const dataRoot = join(root, '.mosaic-boot');
const ctx = { configDir, dataRoot, instance: 'boot' };
const org = await reserveOrg(ctx, 'acme');
await activateOrg(ctx, 'acme');
// A notifier config, so a start that passes the load goes on to start the broker.
notifyConfig(org.root, 'acme', null);
return { env, file, dataRoot, orgRoot: org.root };
}
test('org-defscan: mosaic bus start refuses a held credential in a definition, records why, and the value is nowhere', async (t) => {
const builder = entryFromTemplate(readTemplate(REPO_ROLES, 'coder'));
const o = await orgFixture(t, { ...builder, instructions: `${builder.instructions}\nThe forge token is ${SENTINEL}.\n` });
const run = (...args) => spawnSync(process.execPath, [CLI, ...args], { env: o.env, encoding: 'utf8', timeout: 60000 });
const r = run('bus', 'start', 'acme', '--detach', '--no-launch');
// Should a start get through (it must not), stop it before the fixture's directory goes.
if (r.status === 0 || existsSync(hostFile(o.orgRoot))) run('bus', 'stop', 'acme');
assert.equal(r.status, 3, r.stderr);
assert.ok(!existsSync(join(o.orgRoot, 'bus', 'broker.sock')), 'no socket');
const s = readBootStatus(o.orgRoot);
assert.equal(s.ok, false);
assert.equal(s.business, 'acme');
assert.match(s.reason, /definition-holds-credential: definitions\.builder instructions holds a credential this bus holds/);
assert.equal(s.lastGood, null, 'the load that the broker then refused is not a good load');
const events = readFileSync(bootEventsFile(o.orgRoot), 'utf8').trim().split('\n').map(JSON.parse);
assert.equal(events.at(-1).event, 'bus.boot.refused');
const status = run('bus', 'status', 'acme', '--json');
assert.equal(JSON.parse(status.stdout).boot.ok, false);
for (const text of [r.stdout, r.stderr, status.stdout, status.stderr]) assert.ok(!text.includes(SENTINEL));
for (const path of files(o.dataRoot)) assert.ok(!readFileSync(path).includes(SENTINEL), path);
});
+6 -4
View File
@@ -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 => {
+6 -1
View File
@@ -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 => {
+1 -1
View File
@@ -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=>{
+7 -4
View File
@@ -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.
+6
View File
@@ -91,6 +91,8 @@ async function start(t, { f, adapter }, options = {}) {
refuse: (m) => runtime.refuse(m.cap, m.code, m.subject),
endLaunch: (m) => runtime.endLaunch(m.record),
credentialStatus: (m) => runtime.credentialStatus(m.business, m.role),
// org-defscan (#1873): an org: definition's launch asks the bus's held-secret check, as process.mjs answers it.
definitionScan: (m) => runtime.definitionScan({ business: m.business, definition: m.definition, entry: m.entry }),
threadsState: (m) => runtime.threadsState(m.business),
threadIdle: (m) => runtime.threadIdle(m.record),
mayLaunch: (m) => runtime.mayLaunch(m),
@@ -402,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"));
+209 -7
View File
@@ -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,208 @@ 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.
test('an agent run cannot start a dev-local-owner console; a start without it 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, 0, plain.stderr);
assert.deepEqual(Object.keys(JSON.parse(plain.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,6 +21,7 @@ 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 stop();
if (x.op === 'definitionScan') return { held: null };
return {};
} };
+27 -4
View File
@@ -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]) {
+162
View File
@@ -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');
});
@@ -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);
@@ -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);
+14 -6
View File
@@ -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);
}
});
+9 -8
View File
@@ -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;
+14 -4
View File
@@ -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;
+72 -35
View File
@@ -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
+13 -19
View File
@@ -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);
});
+2 -2
View File
@@ -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();
+26 -4
View File
@@ -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) => {
+42
View File
@@ -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);
+14 -9
View File
@@ -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
+108 -38
View File
@@ -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 ---
+15 -7
View File
@@ -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;
+2 -1
View File
@@ -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"]);
+1 -1
View File
@@ -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');
});
+1 -1
View File
@@ -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;
});
+13 -11
View File
@@ -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
+29
View File
@@ -625,3 +625,32 @@ other two-file journals, a crash or IO failure between replacement and receipt
append can leave committed state without its receipt; an observed append failure
has `committed: true` and must be inspected before retrying. It never rolls back
committed authority silently or fabricates a success receipt.
### Organization autonomy (AU)
The optional `autonomy` object selects `in the loop`, `near the loop` or
`out of the loop` for `cross-role`, `merge`, `policy` and `authority`.
Without it, cross-role, merge and policy retain their arbiter routing and
plain hire/fire/change retain person approval. Only the owner Console writes
this setting; every change records the authenticated owner and writer receipt.
In the loop, the arbiter resolves and a person confirms or returns the decision
with a reason. Near the loop, the agent's resolution takes effect and the owner
receives a Console notice. `reverseWindowHours` is an integer from 1 to 168
(default 24). `notices: true` is required near the loop. `authorityResolver`
names a distinct organization instance holding `decision.resolve.authority`
withinRole; flagged grants, credential lines and root agents always retain
owner approval. No setting changes hard waits or the PRD request-mode switch.
Moving toward the loop needs no prerequisites. Moving away requires the class's
resolver, inverse, notices and review/limit prerequisites. Moving from near to
out also requires an actual near-loop resolution and a later owner trail review. Authority near requires an enabled limit check and an aggregate quota covering every selectable model (or the shared account when every enabled harness uses it). Empty budgets and existing-instance caps do not cover future hires. Authority out refuses `authority-limits-unavailable` until per-definition limits exist. A task inverse restores its prior
stored fields if no later edit intervened; a merge inverse lands a gated revert
commit. Hire/fire/change inverses reuse the existing lifecycle and writer paths,
never restore credential bindings, and refuse stale targets. Authority inverses check the window immediately before file publication. After a durable commit, completion-event, reload or relaunch failures are reported as recovery metadata. The inverse is recorded as committed; if that trail write itself fails, the response says `trailPending` rather than recording a false reversal failure. A refusal after stop/revoke leaves the agent stopped; inspect its trail before relaunching. External task
adapters without host-owned inverse evidence refuse near-loop consumption.
After the pinned window, use an ordinary new decision.
Policy has no effect executor. Per the Lead ruling, its in and existing out
levels work, while near refuses `policy-reverse-unavailable` and directs the
owner to choose in or out. Policy near and its reverse wait for that executor.
+47
View File
@@ -0,0 +1,47 @@
// AU: policy data only. Runtime prerequisites and caller identity belong to the host.
export const AUTONOMY_CLASSES = Object.freeze(['cross-role','merge','policy','authority']);
export const AUTONOMY_LEVELS = Object.freeze(['in the loop','near the loop','out of the loop']);
export const AUTONOMY_DEFAULTS = Object.freeze({'cross-role':'out of the loop',merge:'out of the loop',policy:'out of the loop',authority:'in the loop'});
const refuse = () => { const e=new Error('invalid-autonomy');e.code='invalid-autonomy';throw e; };
export function checkAutonomy(value) {
if(value===undefined)return undefined;
if(!value || typeof value!=='object' || Array.isArray(value) || ![Object.prototype,null].includes(Object.getPrototypeOf(value)))refuse();
for(const [key,v]of Object.entries(value)) {
if(AUTONOMY_CLASSES.includes(key)) {if(!AUTONOMY_LEVELS.includes(v))refuse();if(key==='policy' && v==='near the loop')throw Object.assign(new Error('policy-reverse-unavailable'),{code:'policy-reverse-unavailable'});}
else if(key==='reverseWindowHours') {if(!Number.isSafeInteger(v)||v<1||v>168)refuse();}
else if(key==='notices') {if(typeof v!=='boolean')refuse();}
else if(key==='authorityResolver') {if(typeof v!=='string'||!/^[a-z0-9][a-z0-9._-]{0,63}$/.test(v))refuse();}
else refuse();
}
return structuredClone(value);
}
export function decisionAutonomy(name, setting) {
if(!AUTONOMY_CLASSES.includes(name))refuse();
checkAutonomy(setting);
return setting?.[name]??AUTONOMY_DEFAULTS[name];
}
export function autonomyClass(action) {
return action==='git.merge.protected'?'merge':action==='policy.change'?'policy':['role.hire','role.fire','role.change'].includes(action)?'authority':'cross-role';
}
export function resolverSubject(business, resolver, target) {
if(target==null)return false;
const definition=business.roles?.[resolver]?.definition;
return target===resolver || target===definition || target===definition?.replace(/^org:/,'') || (definition!==undefined && business.roles?.[target]?.definition===definition);
}
// Aggregate provider/model limits cover names that do not exist yet. An account
// quota covers every future launch only when every enabled harness's module declares
// that its launches spend launch.account (its countsAccount export). The host passes
// that answer as countsAccount(name) -> true|false|null; core names no harness, and
// without the lookup no account quota counts.
export function authorityBudgetCovered(business, {countsAccount=()=>null}={}) {
const launch=business.launch, tokens=launch?.limits?.tokens, checks=business.plugins?.['limit-check']??[];
const quota=x=>Number.isSafeInteger(x?.limit) && x.limit>=0 && typeof x.since==='string' && !Number.isNaN(Date.parse(x.since));
const account=launch?.account?.name, harnesses=business.plugins?.harness??[];
if(account && harnesses.length && harnesses.every(h=>countsAccount(h)===true) &&
(checks.includes('daily-quota') && quota(tokens?.daily?.[account]) ||
checks.includes('token-limit') && quota(tokens?.keys?.[account]) && tokens.keys[account].reset===undefined))return true;
const models=Object.values(launch?.models??{});
return checks.includes('token-limit') && models.length>0 && models.every(({provider,model})=>
quota(tokens?.providers?.[provider]) || quota(tokens?.models?.[provider]?.[model]));
}
+16 -24
View File
@@ -27,6 +27,7 @@ import { configDir, loadOrganization } from "./organization-config.mjs";
import { loadProject, projectFilePath } from "./project.mjs";
import { checkCredentialRef } from "./credentials.mjs";
import { systemVars, resolveInstance } from "./resolve.mjs";
import { systemConfigProblem } from "./system-config-problem.mjs";
const REPO = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..", "..");
@@ -67,21 +68,7 @@ export function runConfigCommand(argv, { stdout = process.stdout, stderr = proce
maxBuffer: 1024 * 1024,
env,
});
// Render known key paths with fixed constraints; never relay child stderr.
if (proc.status !== 0) {
const problem = proc.stderr.trim();
const field = problem.startsWith('mosaic-config: execution "dataRoot"') ? "dataRoot"
: /^mosaic-config: (?:unsupported )?"?((?:configVersion|environment|dataRoot|execution|console|federation|compactionReadBytes|compactionStreams)(?:\.[A-Za-z][A-Za-z0-9]*|\[\d+\])*)"?(?=[: ])/u.exec(problem)?.[1];
const name = "a 1-200 character name starting with a letter or digit and containing only letters, digits, . _ : / -";
const expected = { configVersion: "1", environment: "development or production", dataRoot: "a canonical absolute instance data path",
"execution.backend": "docker", "execution.adapter": "pi or mock", "execution.provider": name, "execution.model": name };
const detail = problem.startsWith("mosaic-config: configuration is not valid JSON (")
? "configuration is not valid JSON; expected readable JSON: "
: field ? `${field}: expected ${expected[field] ?? "a value satisfying the system configuration schema"}: `
: "expected valid system configuration: ";
// Name the config by where it came from, never by host path (PR #1916).
fail(3, `system config problem (mosaic-config exit ${proc.status}): ${detail}${env.MOSAIC_CONFIG ? "the system config MOSAIC_CONFIG names" : "the default system config"}${proc.error?.code ? ` (${proc.error.code})` : ""}`);
}
if (proc.status !== 0) fail(3, systemConfigProblem(proc, env));
try {
return JSON.parse(proc.stdout);
} catch {
@@ -90,19 +77,23 @@ export function runConfigCommand(argv, { stdout = process.stdout, stderr = proce
};
const load = (id) => loadOrganization(id, { dir: configDir(env), rolesDir: resolve(env.MOSAIC_ROLES_DIR || join(REPO, "roles")) });
// Check credential references; return the problems and warnings.
// Check credential references, each a [field, ref] pair; return the problems
// and warnings. They name the field and the roots by label, never a path (#1873).
const checkRefs = (refs, forbiddenRoots) => {
const problems = [];
const warnings = [];
for (const ref of refs) {
const result = checkCredentialRef(ref, { forbiddenRoots, env });
for (const [where, ref] of refs) {
const result = checkCredentialRef(ref, { forbiddenRoots, env, where });
problems.push(...result.problems);
warnings.push(...result.warnings);
}
return { problems, warnings };
};
const forbiddenRoots = (config, organization) =>
[REPO, config.dataRoot, ...Object.values(organization.projects).filter((p) => p.kind !== "library").map((p) => p.root)];
const forbiddenRoots = (config, organization) => [
{ path: REPO, label: "the repository" },
{ path: config.dataRoot, label: "the data root" },
...Object.entries(organization.projects).filter(([, p]) => p.kind !== "library").map(([id, p]) => ({ path: p.root, label: `the root of project ${id}` })),
];
const validate = (args) => {
if (args.length !== 1) usage();
@@ -125,8 +116,8 @@ export function runConfigCommand(argv, { stdout = process.stdout, stderr = proce
projects[id] = "valid";
}
const refs = [
...Object.values(organization.tracker?.sync?.credentials ?? {}),
...Object.values(organization.roles).flatMap((r) => Object.values(r.credentials)),
...Object.entries(organization.tracker?.sync?.credentials ?? {}).map(([service, ref]) => [`tracker.sync.credentials.${service}`, ref]),
...Object.entries(organization.roles).flatMap(([instance, r]) => Object.entries(r.credentials).map(([service, ref]) => [`roles.${instance}.credentials.${service}`, ref])),
];
const checked = checkRefs(refs, forbiddenRoots(config, organization));
warnings.push(...checked.warnings);
@@ -159,11 +150,12 @@ export function runConfigCommand(argv, { stdout = process.stdout, stderr = proce
let loaded = null;
if (project !== null) {
const entry = Object.hasOwn(organization.projects, project) ? organization.projects[project] : null;
if (!entry) fail(2, `business ${organization.id} declares no project ${JSON.stringify(project)}`);
// The flag is quoted only when it is a plain name, never a path (#1873).
if (!entry) fail(2, `business ${organization.id} declares no project ${/^[a-z0-9][a-z0-9._-]{0,63}$/.test(project) ? JSON.stringify(project) : "by that name"}`);
if (entry.kind !== "library") loaded = loadProject(entry.root);
}
const resolved = resolveInstance({ system: systemVars(config), business: organization, project: loaded, instance });
const checked = checkRefs(Object.values(resolved.credentials), forbiddenRoots(config, organization));
const checked = checkRefs(Object.entries(resolved.credentials).map(([service, ref]) => [`${instance} ${service}`, ref]), forbiddenRoots(config, organization));
for (const w of checked.warnings) say(`warning: ${w}`);
if (checked.problems.length > 0) {
for (const p of checked.problems) say(p);
+13 -6
View File
@@ -44,7 +44,7 @@ export function parseCredentialRef(ref, service, where, { store = null } = {}) {
}
out.file = ref.file;
if (store && inside(join(store.configDir, "secrets", "agents"), ref.file) && ref.file !== storePath(store, service)) {
refuse(`${where}.file credential-not-yours: ${ref.file} is in the agents' store but not this agent's path`);
refuse(`${where}.file credential-not-yours: the file is in the agents' store but not this agent's path`);
}
} else {
if (typeof ref.env !== "string" || !ENV_NAME.test(ref.env)) refuse(`${where}.env must match ${ENV_NAME}`);
@@ -68,11 +68,16 @@ function inside(root, path) {
// Check a parsed reference against the filesystem and the calendar.
// Returns { problems: [...], warnings: [...] }; a caller that finds any
// problem refuses. `forbiddenRoots` are directories a token file must not
// sit in (the repository, dataRoot). `now` is a Date.
export function checkCredentialRef(ref, { forbiddenRoots = [], now = new Date(), env = process.env, uid = process.getuid() } = {}) {
// sit in (the repository, dataRoot): each a path, or { path, label } so a
// problem can name it. `where` is the reference's field (for example
// roles.coder.credentials.gitea). `now` is a Date.
// A problem or warning names the reference by its field, its service and its
// kind, and an env reference by its checked variable name. It never quotes the
// token file's path or a root's path (#1873).
export function checkCredentialRef(ref, { forbiddenRoots = [], now = new Date(), env = process.env, uid = process.getuid(), where = null } = {}) {
const problems = [];
const warnings = [];
const label = `${ref.service} ${ref.file ? `file ${ref.file}` : `env ${ref.env}`}`;
const label = `${where ?? ref.service} ${ref.store ? "store file" : ref.file ? "file" : `env ${ref.env}`}`;
let notIssued = false;
if (ref.file) {
let stat = null;
@@ -96,14 +101,16 @@ export function checkCredentialRef(ref, { forbiddenRoots = [], now = new Date(),
} catch {
problems.push(`${label}: path can't be resolved`);
}
for (const root of forbiddenRoots) {
for (const entry of forbiddenRoots) {
const root = typeof entry === "string" ? entry : entry.path;
const name = typeof entry === "string" ? "a forbidden root" : entry.label;
let realRoot = root;
try {
realRoot = realpathSync(root);
} catch {
// A root that doesn't exist yet can't contain the file.
}
if (inside(realRoot, real)) problems.push(`${label}: inside ${root}; token files live outside the repository and dataRoot`);
if (inside(realRoot, real)) problems.push(`${label}: inside ${name}; token files live outside the repository and dataRoot`);
}
}
}
+48 -5
View File
@@ -21,6 +21,11 @@ const keys = (x, allowed, required = []) => { if (!isPlainObject(x) || Object.ke
const id = x => { if (typeof x !== 'string' || !ID_PATTERN.test(x) || ['human','tester','user'].includes(x)) fail('invalid-request'); };
const requestKeys = ['op','level','project','name','role','credentials','personality','route','reason'];
export function authorizeHire(proposal, caller) {
if(caller?.agent===true) {
if(typeof caller.authorityDecision!=='string' || typeof caller.resolver!=='string')fail('human-required');
if(caller.authorityDecision!==proposal.id || !caller.rights?.includes('decision.resolve.authority') || proposal.flagged.length || proposal.credentialLines.length || proposal.level==='root' || caller.resolver===proposal.name)fail('hire-owner-required');
return;
}
if (!caller || caller.agent !== false) fail('human-required');
if (!caller.rights?.includes(VERB_RIGHTS['hire.resolve.console'])) fail('right-required');
if ((proposal.flagged.length || proposal.credentialLines.length) && caller.owner !== true) fail('hire-owner-required');
@@ -35,7 +40,15 @@ const publicOf = p => ({ id:p.id, revision:p.revision, op:p.request.op, level:p.
...(p.entry ? {role:structuredClone(p.entry.role)} : {}),
// reason and all free text stay in the private proposal as well.
flagged:p.flagged, credentialLines:p.credentialLines, expiresAt:p.expiresAt, digest:hash(p) });
export function hireDecisions({ writer, rolesDir, configDir, dataRoot, assertClean, system = {}, levels = () => null, catalogueFor = async () => null, registryFor = () => buildRegistry() }) {
// org-defscan (#1873): `scanDefinition(name, entry)` is the bus's definition check on the approved
// entry; it throws (definition-holds-credential, naming the definition and key) when the live store
// holds text in it. Without one, the entry still goes through assertClean whole.
export function hireDecisions({ writer, rolesDir, configDir, dataRoot, assertClean, scanDefinition, system = {}, levels = () => null, catalogueFor = async () => null, registryFor = () => buildRegistry() }) {
const scanEntry = (name, entry) => {
if (typeof scanDefinition === 'function') return scanDefinition(name, structuredClone(entry));
if (typeof assertClean !== 'function') fail('definition-secret-check-required');
assertClean(entry);
};
const home = join(dataRoot,'authority-proposals');
let receiptCache;
const rootReceipts=()=>{
@@ -141,7 +154,8 @@ export function hireDecisions({ writer, rolesDir, configDir, dataRoot, assertCle
}
if(input.op==='change' && input.level==='root' && Object.entries(root.document.agents).some(([name,e])=>name!==input.name && e.definition===current.definition))fail('definition-shared');
const targetDefinition=input.op==='change' && current?.definition?.startsWith('org:') ? current.definition.slice(4) : `hire-${decision}`;
const p={id:decision,business,revision,rootRevision:root?.revision??null,request:structuredClone(input),entry,definition:targetDefinition,flagged,credentialLines:lines,expiresAt};
const prior={instance:current??null,definition:current?.definition?.startsWith('org:')?(input.level==='root'?root.document.definitions:doc.definitions)?.[current.definition.slice(4)]??null:null,launched:doc.launch?.instances?.includes(input.name)??false};
const p={id:decision,business,prior,revision,rootRevision:root?.revision??null,request:structuredClone(input),entry,definition:targetDefinition,flagged,credentialLines:lines,expiresAt};
mkdirSync(home,{recursive:true,mode:0o700});
const st=lstatSync(home); if(!st.isDirectory() || st.isSymbolicLink() || st.uid!==process.getuid() || st.mode&0o077)fail('organization-refused');
const fd=openSync(location(decision),'wx',0o600);
@@ -151,6 +165,9 @@ export function hireDecisions({ writer, rolesDir, configDir, dataRoot, assertCle
async function adjusted(business, decision, adjust) {
const p=read(decision);
if(p.business!==business)fail('decision-stale');
// org-defscan (#1873): the stored entry is the definition text an approval writes. A credential
// issued after the proposal refuses it here, before preview, check or apply goes further.
if(p.entry)scanEntry(p.definition,p.entry);
if(adjust===undefined)return p;
keys(adjust,['role','credentials','route']);
if(!p.entry)fail('invalid-request');
@@ -216,6 +233,8 @@ export function hireDecisions({ writer, rolesDir, configDir, dataRoot, assertCle
const vars=r.route ? {harness:r.route.harness,model:r.route.model,...(r.route.thinking?{thinking:r.route.thinking}:{})} : {};
const currentDoc=r.level==='root'?(await readRootAgentsFile(configDir)).document:writer.read(business).document;
const approvedEntry=p.entry?{...p.entry,rev:(currentDoc.definitions?.[p.definition]?.rev??0)+1}:null;
// org-defscan (#1873): check() ran before the awaits above; the entry the writers get is checked again.
if(approvedEntry)scanEntry(p.definition,approvedEntry);
const excluded=r.level==='root' && r.op==='change' ? currentDoc.agents[r.name]?.excludeOrgs : undefined;
const entry=r.op==='fire'?null:{definition:`org:${p.definition}`,vars,hireDecision:p.id,...(excluded===undefined?{}:{excludeOrgs:excluded})};
if(r.level==='root') {
@@ -225,8 +244,32 @@ export function hireDecisions({ writer, rolesDir, configDir, dataRoot, assertCle
return publish();
}
if(r.level==='project') return writer.setProjectAgent(business,{revision:p.revision,project:r.project,name:r.name,entry},caller.actor,
{approvedDefinition:p.entry?{name:p.definition,entry:approvedEntry}:null,decision:p.id,assertClean,beforeWrite:caller.beforeWrite});
return writer.applyHireDecision(business,p,{actor:caller.actor,assertClean});
{approvedDefinition:p.entry?{name:p.definition,entry:approvedEntry}:null,decision:p.id,assertClean,beforeWrite:caller.beforeWrite,beforePublish:caller.beforePublish,authorityDecision:caller.authorityDecision});
return writer.applyHireDecision(business,p,{actor:caller.actor,assertClean,beforeWrite:caller.beforeWrite,beforePublish:caller.beforePublish,authorityDecision:caller.authorityDecision});
}
return {prepare,templates:()=>({templates:templateHashes(rolesDir).map(({template,sha256})=>({name:template,sha256}))}),preview:async(business,decision,caller)=>{const p=await adjusted(business,decision,caller.adjust);if(Date.parse(p.expiresAt)<=(caller.now??Date.now)())fail('decision-expired');authorizeHire(publicOf(p),caller);return publicOf(p);},rootRefusal,rootActive:name=>rootRefusal(name)===null,check:async(...args)=>{await check(...args);},apply,read: decision => publicOf(read(decision))};
async function reverse(business,decision,caller) {
const p=read(decision);if(p.business!==business || !p.prior || p.request.level==='root')fail('decision-stale');
const r=p.request,old=writer.read(business),current=r.level==='org'?old.document.roles[r.name]:old.document.projects[r.project]?.agents?.[r.name];
// A later approved change must not be overwritten by an earlier inverse.
if(r.op==='fire') {
if(r.level==='project'?current!==undefined:old.document.launch.instances.includes(r.name))fail('decision-stale');
if(r.level==='org') {
const strip=x=>{const {credentials,tracker,...rest}=x??{};return rest;};
if(canonicalJson(strip(current))!==canonicalJson(strip(p.prior.instance)))fail('decision-stale');
}
if(p.prior.definition && canonicalJson(old.document.definitions?.[p.prior.instance.definition.slice(4)])!==canonicalJson(p.prior.definition))fail('decision-stale');
} else if(current?.hireDecision!==decision)fail('decision-stale');
const publicationGuard=()=>{(caller.beforePublish??caller.beforeWrite)();if(p.prior.definition)scanEntry(p.prior.instance.definition.slice(4),p.prior.definition);if(writer.read(business).revision!==old.revision)fail('decision-stale');};
const guard=()=>{caller.beforeWrite();publicationGuard();};
guard();
if(r.op==='hire') {
if(r.level==='org')return caller.fire({...publicOf(p),revision:old.revision});
await caller.stop(publicOf(p),guard);guard();
return writer.setProjectAgent(business,{revision:old.revision,project:r.project,name:r.name,entry:null},caller.actor,{beforeWrite:guard,beforePublish:publicationGuard});
}
const stopped=await caller.stop(publicOf(p),guard);guard();
const result=writer.reverseHireDecision(business,p,{...caller,beforeWrite:guard,beforePublish:publicationGuard});
return {...result,...(r.op==='change' && stopped?.running?{relaunch:publicOf(p)}:{})};
}
return {prepare,reverse,templates:()=>({templates:templateHashes(rolesDir).map(({template,sha256})=>({name:template,sha256}))}),preview:async(business,decision,caller)=>{const p=await adjusted(business,decision,caller.adjust);if(Date.parse(p.expiresAt)<=(caller.now??Date.now)())fail('decision-expired');authorizeHire(publicOf(p),caller);return publicOf(p);},rootRefusal,rootActive:name=>rootRefusal(name)===null,check:async(...args)=>{await check(...args);},apply,read: decision => publicOf(read(decision))};
}
@@ -355,13 +355,14 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null, r
// webby-0094 N1: an instance already delisted (a hand edit, or a repeat after step B) still has
// its tokens revoked, with no write. A revoke is safe to repeat.
if (!old.launch.instances.includes(name)) {
context?.beforeWrite?.();
const revoked = revoke ? revoke(business, name, "fire", services) : null;
return { ...read(business, actor), transaction: null, delisted: false, ...(revoked ? { credentials: revoked } : {}) };
}
const next = structuredClone(old);
next.launch.instances = old.launch.instances.filter(entry => entry !== name);
let revoked = null;
const beforeWrite = revoke ? () => { revoked = revoke(business, name, "fire", services); } : null;
const beforeWrite = () => { context?.beforeWrite?.(); if(revoke)revoked = revoke(business, name, "fire", services); context?.beforeWrite?.(); };
const result = commit(business, old, next, { revision, actor, context, beforeWrite });
return { ...result, delisted: true, ...(revoked ? { credentials: revoked } : {}) };
}
@@ -422,7 +423,7 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null, r
// writer checks again under its lock, before the revoke (row 239), and that check is what keeps
// a stale fire or delete from revoking.
if (writer.read(business).revision !== revision) throw new OrganizationError("revision-conflict", "organization revision changed; read again");
const receipt = writer.write(business, next, { revision, actor, context, beforeWrite });
const receipt = writer.write(business, next, { revision, actor, context, beforeWrite, beforePublish:context?.beforePublish });
return { ...read(business, actor), transaction: receipt.transaction, restart: false };
}
+12 -5
View File
@@ -132,15 +132,18 @@ export function checkRoutes({ proposed, old = null, levels, catalogue = null })
}
const currentEntry = proposed.launch?.models?.[vars[model]];
const previousEntry = old?.launch?.models?.[before[model]];
const changed = [harness, model, thinking].some(field => vars[field] !== before[field]) || canonicalJson(currentEntry ?? null) !== canonicalJson(previousEntry ?? null);
if (changed && previousEntry?.module && !currentEntry?.module && vars[model] === before[model]) throw new OrganizationError('model-provider-unavailable');
if (changed && currentEntry?.module) {
// D3 (P0-4): the catalogue answers which harness a model runs on, not its thinking level, so
// a change that keeps the harness, the model key and its entry needs no catalogue; the
// harness checks below still run on the new thinking level.
const routeChanged = [harness, model].some(field => vars[field] !== before[field]) || canonicalJson(currentEntry ?? null) !== canonicalJson(previousEntry ?? null);
if (routeChanged && previousEntry?.module && !currentEntry?.module && vars[model] === before[model]) throw new OrganizationError('model-provider-unavailable');
if (routeChanged && currentEntry?.module) {
if (catalogue?.status !== 'ready') throw new OrganizationError('model-provider-unavailable');
const offered = catalogue.models?.find(m => m.key === vars[model]);
if (!offered || !offered.available || !proposed.plugins?.['model-provider']?.includes(currentEntry.module) || canonicalJson(currentEntry) !== canonicalJson({ provider: offered.provider, model: offered.model, module: offered.module, upstream: offered.upstream })) throw new OrganizationError('model-provider-unavailable');
if (!offered.harnesses.includes(vars[harness])) throw new OrganizationError('model-harness-incompatible');
}
if (changed && currentEntry && !currentEntry.module && proposed.plugins?.['model-provider']?.length) {
if (routeChanged && currentEntry && !currentEntry.module && proposed.plugins?.['model-provider']?.length) {
if (catalogue?.status !== 'ready') throw new OrganizationError('model-provider-unavailable');
const mapping = catalogue.resolveLegacy ? catalogue.resolveLegacy(currentEntry) : catalogue.legacy?.[vars[model]];
const offered = mapping && catalogue.models?.find(m => m.module === mapping.module && m.upstream === mapping.upstream && m.model === currentEntry.model);
@@ -369,6 +372,10 @@ export function launchSettings({ dir, rolesDir, system, sessions = () => [], now
for (const [choice, prefix] of [[route, ''], [fallback, 'fallback.']]) {
if (!choice) continue;
const unchanged = ROUTE_FIELDS.every(field => priorVars[prefix + field] === choice[field]);
// D3 (P0-4): a choice that keeps the harness and model key needs no catalogue, as in
// checkRoutes; only its thinking level may differ. A key the catalogue offers as a
// different model still refuses model-key-collision below on any change.
const sameRoute = ['harness', 'model'].every(field => priorVars[prefix + field] === choice[field]);
const offered = catalogue?.models?.find(m => m.key === choice.model);
const existing = next.launch.models[choice.model];
const entry = offered ? { provider: offered.provider, model: offered.model, module: offered.module, upstream: offered.upstream } : null;
@@ -377,7 +384,7 @@ export function launchSettings({ dir, rolesDir, system, sessions = () => [], now
if (!existing && !offered) throw new OrganizationError('unknown-model', `${instance} variable ${prefix}model isn't a launch.models key`);
if (existing && offered && canonicalJson(existing) !== canonicalJson(entry) && !unchanged) throw new OrganizationError('model-key-collision');
if (!existing || existing.module) {
if (unchanged) continue;
if (sameRoute) continue;
if (catalogue?.status !== 'ready') throw new OrganizationError('model-catalogue-unavailable');
if (!offered || !offered.available || !Array.isArray(next.plugins?.['model-provider']) || !next.plugins['model-provider'].includes(offered.module)) throw new OrganizationError('model-provider-unavailable');
if (!offered.harnesses.includes(choice.harness)) throw new OrganizationError('model-harness-incompatible');
+59 -35
View File
@@ -6,34 +6,56 @@ import { processState } from '../../seat/src/process-state.mjs';
import { ID_PATTERN } from './vocabulary.mjs';
import { parseJsonFileText } from './util.mjs';
// `path` is kept only when it is a document field ($.projects.alpha.root): a
// host path never rides on the error, where a caller could print or serialise
// it (#1873).
export class OrgError extends Error {
constructor(code, message, path) { super(message); this.name = 'OrgError'; this.code = code; if (path) this.path = path; }
constructor(code, message, path) { super(message); this.name = 'OrgError'; this.code = code; if (typeof path === 'string' && path.startsWith('$')) this.path = path; }
}
// A Node system error names the host path in its message, and a spawn's syscall names the
// program. quietMessage is the call's first word, the file's label and the error code, or null
// for an error that is not a system error; quiet puts it in the message. The code and syscall
// fields stay (#1873, from PR #1916's org-service quiet).
const quieted = new WeakSet();
export function quietMessage(e, what) {
if (!e || typeof e !== 'object') return null;
if (quieted.has(e)) return e.message;
return typeof e.syscall === 'string' && typeof e.code === 'string' ? `cannot ${e.syscall.split(' ')[0]} ${what} (${e.code})` : null;
}
export function quiet(e, what) {
const message = quietMessage(e, what);
if (message !== null && !quieted.has(e)) { e.message = message; quieted.add(e); }
return e;
}
// A filesystem call's error keeps its code and loses its path: rethrow(what) for a promise,
// quiet(e, what) in a catch.
const rethrow = what => e => { throw quiet(e, what); };
const CONFIG = 'the config directory';
const fail = (code, message, path) => { throw new OrgError(code, message, path); };
function idCheck(id) { if (typeof id !== 'string' || !ID_PATTERN.test(id)) fail('org-invalid', 'invalid organization id'); }
export function orgRoot(dataRoot, id) { idCheck(id); if (!isAbsolute(dataRoot) || normalize(dataRoot) !== dataRoot) fail('org-invalid', 'data root must be a normalized absolute path'); return join(dataRoot, 'org-runtime', id); }
async function safeDir(path, create = false) {
async function safeDir(path, create = false, what = 'a directory') {
if (!isAbsolute(path) || resolve(path) !== path) fail('org-mismatch', 'directory must be a normalized absolute path', path);
let current = parse(path).root;
for (const part of path.slice(current.length).split('/').filter(Boolean)) {
current = join(current, part);
try { const st = await fs.lstat(current); if (!st.isDirectory() || st.isSymbolicLink()) fail('org-mismatch', 'directory path contains a symlink or non-directory', current); }
catch (e) { if (e.code !== 'ENOENT' || !create) throw e; await fs.mkdir(current, { mode: 0o700 }).catch(err => { if (err.code !== 'EEXIST') throw err; }); const st = await fs.lstat(current); if (!st.isDirectory() || st.isSymbolicLink()) fail('org-mismatch', 'unsafe directory', current); }
catch (e) { if (e.code !== 'ENOENT' || !create) throw quiet(e, what); await fs.mkdir(current, { mode: 0o700 }).catch(err => { if (err.code !== 'EEXIST') throw quiet(err, what); }); const st = await fs.lstat(current).catch(rethrow(what)); if (!st.isDirectory() || st.isSymbolicLink()) fail('org-mismatch', 'unsafe directory', current); }
}
}
async function readPrivate(file) {
const fd = await fs.open(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
try { const st = await fd.stat(); if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o777) !== 0o600) fail('org-mismatch', 'unsafe metadata file', file); return parseJsonFileText(await fd.readFile('utf8'), file, 'metadata file'); } finally { await fd.close(); }
}
async function privateDir(path) { const st = await fs.lstat(path); if (!st.isDirectory() || st.isSymbolicLink() || st.uid !== process.getuid() || (st.mode & 0o077)) fail('org-mismatch', 'unsafe private directory', path); }
async function privateDir(path, what = 'a directory') { const st = await fs.lstat(path).catch(rethrow(what)); if (!st.isDirectory() || st.isSymbolicLink() || st.uid !== process.getuid() || (st.mode & 0o077)) fail('org-mismatch', 'unsafe private directory', path); }
function validateRegistry(doc, file) {
if (!doc || doc.version !== 1 || typeof doc.instance !== 'string' || !doc.orgs || typeof doc.orgs !== 'object' || Array.isArray(doc.orgs)) fail('org-mismatch', 'invalid registry', file);
for (const [id, entry] of Object.entries(doc.orgs)) { idCheck(id); if (!entry || entry.id !== id || typeof entry.root !== 'string' || !['reserved', 'active', 'deleting', 'archived'].includes(entry.state) || !Number.isSafeInteger(entry.revision) || entry.revision < 1 || typeof entry.created !== 'string') fail('org-mismatch', 'invalid registry entry', file); }
return doc;
}
export async function readRegistry({ configDir }) {
await safeDir(configDir); await privateDir(configDir); const file = join(configDir, 'org-runtime.json'); let doc;
try { doc = await readPrivate(file); } catch (e) { if (e.code === 'ENOENT') return null; if (e instanceof OrgError) throw e; fail('org-mismatch', `invalid registry: ${e.message}`, file); }
await safeDir(configDir, false, CONFIG); await privateDir(configDir, CONFIG); const file = join(configDir, 'org-runtime.json'); let doc;
try { doc = await readPrivate(file); } catch (e) { if (e.code === 'ENOENT') return null; if (e instanceof OrgError) throw e; fail('org-mismatch', `invalid registry: ${e instanceof SyntaxError ? e.message : quiet(e, 'registry file').message}`); }
return validateRegistry(doc, file);
}
export async function listOrgs(ctx) { const doc = await readRegistry(ctx); return doc ? Object.values(doc.orgs) : []; }
@@ -49,8 +71,8 @@ export async function readRegistryLocked(registry, ctx) {
return validateRegistry(registry, join(held.ctx.configDir, 'org-runtime.json'));
}
export async function withRegistry(ctx, fn, { write = true } = {}) {
await safeDir(ctx.configDir); await privateDir(ctx.configDir); const lock = join(ctx.configDir, 'org-runtime.lock'); let fd;
for (let n = 0; n < 200; n++) { try { fd = await fs.open(lock, 'wx', 0o600); break; } catch (e) { if (e.code !== 'EEXIST') throw e; await new Promise(r => setTimeout(r, 10)); } }
await safeDir(ctx.configDir, false, CONFIG); await privateDir(ctx.configDir, CONFIG); const lock = join(ctx.configDir, 'org-runtime.lock'); let fd;
for (let n = 0; n < 200; n++) { try { fd = await fs.open(lock, 'wx', 0o600); break; } catch (e) { if (e.code !== 'EEXIST') throw quiet(e, 'the registry lock'); await new Promise(r => setTimeout(r, 10)); } }
if (!fd) fail('org-locked', 'registry lock is held', lock);
try {
const persisted = await readRegistry(ctx);
@@ -62,10 +84,10 @@ export async function withRegistry(ctx, fn, { write = true } = {}) {
finally { heldRegistries.delete(registry); }
if (write) {
const file = join(ctx.configDir, 'org-runtime.json'); const temp = join(ctx.configDir, `.org-runtime-${randomBytes(12).toString('hex')}`);
try { await fs.writeFile(temp, JSON.stringify(registry, null, 2) + '\n', { mode: 0o600, flag: 'wx' }); await fs.rename(temp, file); } finally { await fs.rm(temp, { force: true }); }
try { await fs.writeFile(temp, JSON.stringify(registry, null, 2) + '\n', { mode: 0o600, flag: 'wx' }); await fs.rename(temp, file); } catch (e) { throw quiet(e, 'the registry file'); } finally { await fs.rm(temp, { force: true }).catch(rethrow('the registry file')); }
}
return result;
} finally { await fd.close(); await fs.unlink(lock); }
} finally { await fd.close(); await fs.unlink(lock).catch(rethrow('the registry lock')); }
}
// The broker's existing organization writer is synchronous. It shares the
// registry lock with the async service and refuses contention rather than
@@ -74,16 +96,16 @@ export function withRegistryWriter(configDir, fn) {
const file = join(configDir, 'org-runtime.json');
const lock = join(configDir, 'org-runtime.lock'); let lockFd;
try { lockFd = openSync(lock, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600); }
catch (e) { if (e.code === 'EEXIST') fail('org-locked', 'registry lock is held', lock); throw e; }
const release = () => { closeSync(lockFd); unlinkSync(lock); };
catch (e) { if (e.code === 'EEXIST') fail('org-locked', 'registry lock is held', lock); throw quiet(e, 'the registry lock'); }
const release = () => { closeSync(lockFd); try { unlinkSync(lock); } catch (e) { throw quiet(e, 'the registry lock'); } };
let pending = false;
try {
let fd, registry = null;
try { fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); }
catch (e) { if (e.code !== 'ENOENT') throw e; }
catch (e) { if (e.code !== 'ENOENT') throw quiet(e, 'the registry file'); }
if (fd !== undefined) {
try {
const dir = lstatSync(configDir);
let dir; try { dir = lstatSync(configDir); } catch (e) { throw quiet(e, CONFIG); }
if (!dir.isDirectory() || dir.isSymbolicLink() || dir.uid !== process.getuid() || (dir.mode & 0o077)) fail('org-mismatch', 'unsafe private directory', configDir);
const st = fstatSync(fd);
if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o777) !== 0o600) fail('org-mismatch', 'unsafe metadata file', file);
@@ -93,7 +115,8 @@ export function withRegistryWriter(configDir, fn) {
const publish = () => {
const temp = join(configDir, `.org-runtime-${randomBytes(12).toString('hex')}`);
try { writeFileSync(temp, JSON.stringify(registry, null, 2) + '\n', { mode: 0o600, flag: 'wx' }); renameSync(temp, file); }
finally { try { unlinkSync(temp); } catch (e) { if (e.code !== 'ENOENT') throw e; } }
catch (e) { throw quiet(e, 'the registry file'); }
finally { try { unlinkSync(temp); } catch (e) { if (e.code !== 'ENOENT') throw quiet(e, 'the registry file'); } }
};
const result = fn(registry, publish);
if (result !== null && typeof result === 'object' && typeof result.then === 'function') {
@@ -108,10 +131,10 @@ export async function reserveOrg(ctx, id) {
return withRegistry(ctx, async registry => {
if (Object.hasOwn(registry.orgs, id)) fail('org-exists', 'organization already reserved', root);
for (const entry of Object.values(registry.orgs)) if (entry.root === root || root.startsWith(entry.root + '/') || entry.root.startsWith(root + '/')) fail('org-overlap', 'organization roots overlap', root);
await safeDir(ctx.dataRoot); await privateDir(ctx.dataRoot);
await safeDir(dirname(root), true); await privateDir(dirname(root));
try { await fs.mkdir(root, { mode: 0o700 }); } catch (e) { if (e.code === 'EEXIST') fail('org-exists', 'organization root already exists', root); throw e; }
await fs.writeFile(join(root, '.mosaic-org.json'), JSON.stringify({ instance: ctx.instance, id }) + '\n', { mode: 0o600, flag: 'wx' });
await safeDir(ctx.dataRoot, false, 'the data root'); await privateDir(ctx.dataRoot, 'the data root');
await safeDir(dirname(root), true, 'org-runtime under the data root'); await privateDir(dirname(root), 'org-runtime under the data root');
try { await fs.mkdir(root, { mode: 0o700 }); } catch (e) { if (e.code === 'EEXIST') fail('org-exists', 'organization root already exists', root); throw quiet(e, 'the organization root'); }
await fs.writeFile(join(root, '.mosaic-org.json'), JSON.stringify({ instance: ctx.instance, id }) + '\n', { mode: 0o600, flag: 'wx' }).catch(rethrow('the organization marker'));
const entry = { id, root, state: 'reserved', created: new Date().toISOString(), revision: 1 }; registry.orgs[id] = entry; return { ...entry };
});
}
@@ -132,14 +155,14 @@ export async function activateOrg(ctx, id) {
// no document yet; preserve the registry's existing activation contract.
const dir = join(ctx.configDir, 'businesses'), file = join(dir, `${id}.json`);
try {
await safeDir(dir);
await safeDir(dir, false, 'businesses in the config directory');
const fd = await fs.open(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
try {
const st = await fd.stat();
if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o022)) fail('org-mismatch', 'unsafe organization document', file);
registry.orgs[id].digest = createHash('sha256').update(await fd.readFile()).digest('hex');
} finally { await fd.close(); }
} catch (e) { if (e.code !== 'ENOENT') throw e; }
} catch (e) { if (e.code !== 'ENOENT') throw quiet(e, 'the organization document'); }
registry.orgs[id].state = 'active';
return { ...registry.orgs[id] };
});
@@ -148,12 +171,12 @@ export async function activateOrg(ctx, id) {
// Serialize bus launch with organization lifecycle operations.
// A leftover lock is never guessed stale; the operator must inspect it.
export async function withOrgLifecycle(ctx, id, fn) {
idCheck(id); await safeDir(ctx.configDir); await privateDir(ctx.configDir);
idCheck(id); await safeDir(ctx.configDir, false, CONFIG); await privateDir(ctx.configDir, CONFIG);
const path = join(ctx.configDir, `org-lifecycle-${id}.lock`); let fd;
try { fd = await fs.open(path, 'wx', 0o600); }
catch (e) { if (e.code === 'EEXIST') fail('org-locked', 'organization lifecycle lock is held; inspect the lock and resume'); throw e; }
catch (e) { if (e.code === 'EEXIST') fail('org-locked', 'organization lifecycle lock is held; inspect the lock and resume'); throw quiet(e, 'the lifecycle lock'); }
try { await fd.writeFile(JSON.stringify({ pid: process.pid, at: new Date().toISOString() }) + '\n'); return await fn(); }
finally { await fd.close(); await fs.unlink(path); }
finally { await fd.close(); await fs.unlink(path).catch(rethrow('the lifecycle lock')); }
}
// Lifecycle locks carry {pid, at}; registry locks are empty and cannot be
@@ -173,7 +196,7 @@ function inspectRecoveryFence(path) {
} finally { closeSync(fd); }
}
export async function orgLock(ctx, id, { clear = false } = {}) {
idCheck(id); await safeDir(ctx.configDir); await privateDir(ctx.configDir);
idCheck(id); await safeDir(ctx.configDir, false, CONFIG); await privateDir(ctx.configDir, CONFIG);
const path = join(ctx.configDir, `org-lifecycle-${id}.lock`);
const guard = `${path}.clear`; let guardFd, self;
if (clear) {
@@ -182,16 +205,16 @@ export async function orgLock(ctx, id, { clear = false } = {}) {
for (;;) {
try { guardFd = openSync(guard, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600); break; }
catch (e) {
if (e.code !== 'EEXIST') throw e;
if (e.code !== 'EEXIST') throw quiet(e, 'the recovery fence');
const holder = inspectRecoveryFence(guard);
if (!holder) continue;
if (holder.unreadable) fail('org-locked', `unreadable recovery fence ${guard}; remove it by hand`, guard);
if (holder.unreadable) fail('org-locked', `unreadable recovery fence org-lifecycle-${id}.lock.clear in the config directory; remove it by hand`);
if (holder.alive !== false) fail('org-locked', `lock recovery is already in progress by pid ${holder.pid}`, guard);
let current; try { current = lstatSync(guard); } catch (err) { if (err.code === 'ENOENT') continue; throw err; }
let current; try { current = lstatSync(guard); } catch (err) { if (err.code === 'ENOENT') continue; throw quiet(err, 'the recovery fence'); }
if (current.dev !== holder.dev || current.ino !== holder.ino) fail('org-locked', 'recovery fence changed during recovery', guard);
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
try { renameSync(guard, `${guard}.stale-${stamp}-${randomBytes(4).toString('hex')}`); }
catch (err) { if (err.code === 'ENOENT') continue; throw err; }
catch (err) { if (err.code === 'ENOENT') continue; throw quiet(err, 'the recovery fence'); }
}
}
}
@@ -201,7 +224,7 @@ export async function orgLock(ctx, id, { clear = false } = {}) {
const reportFence = result => { if (fence) { const { dev, ino, ...visible } = fence; result.fence = visible; } return result; };
let fd;
try { fd = openSync(path, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); }
catch (e) { if (e.code === 'ENOENT') return reportFence(clear ? { held: false, cleared: false } : { held: false }); throw e; }
catch (e) { if (e.code === 'ENOENT') return reportFence(clear ? { held: false, cleared: false } : { held: false }); throw quiet(e, 'the lifecycle lock'); }
try {
const st = fstatSync(fd);
if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o777) !== 0o600 || st.nlink !== 1) fail('org-mismatch', 'unsafe lifecycle lock', path);
@@ -210,10 +233,11 @@ export async function orgLock(ctx, id, { clear = false } = {}) {
let status; try { status = processState(holder.pid); } catch { fail('org-mismatch', 'lifecycle lock holder is uncertain', path); }
const result = { held: true, pid: holder.pid, start: holder.at, alive: status.alive };
if (clear) {
if (status.alive) fail('org-locked', `lifecycle lock held by pid ${holder.pid} since ${holder.at} (alive)`, path);
const current = lstatSync(path);
if (status.alive) fail('org-locked', `lifecycle lock held by pid ${holder.pid} since ${new Date(holder.at).toISOString()} (alive)`, path);
let current; try { current = lstatSync(path); } catch (e) { throw quiet(e, 'the lifecycle lock'); }
if (current.dev !== st.dev || current.ino !== st.ino) fail('org-locked', 'lifecycle lock changed during recovery', path);
unlinkSync(path); result.cleared = true;
try { unlinkSync(path); } catch (e) { throw quiet(e, 'the lifecycle lock'); }
result.cleared = true;
}
return reportFence(result);
} finally { closeSync(fd); }
@@ -221,9 +245,9 @@ export async function orgLock(ctx, id, { clear = false } = {}) {
if (guardFd !== undefined) {
const owned = fstatSync(guardFd);
try {
const current = lstatSync(guard);
let current; try { current = lstatSync(guard); } catch (e) { throw quiet(e, 'the recovery fence'); }
if (owned.dev !== current.dev || owned.ino !== current.ino) fail('org-locked', 'recovery fence changed during recovery', guard);
unlinkSync(guard);
try { unlinkSync(guard); } catch (e) { throw quiet(e, 'the recovery fence'); }
} finally { closeSync(guardFd); }
}
}
+5
View File
@@ -32,6 +32,11 @@ export const ROLE_RIGHTS = record({
// M5 must separately exclude credential.mint from decide and assignees from
// member task writes; these names alone cannot express action/field restrictions.
export const VERB_RIGHTS = record({
'autonomy.get.console': null,
'autonomy.set.console': null,
'autonomy.review.console': null,
'decision.confirm.console': null,
'decision.reverse.console': null,
'project.list': 'org.view',
names: 'org.view',
'thread.list': 'thread.read',
+1 -6
View File
@@ -6,7 +6,7 @@ import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { validateBusinessDocument, httpsUrl } from './organization-config.mjs';
import { ID_PATTERN } from './vocabulary.mjs';
import { OrgError, orgRoot, readRegistry, reserveOrg, resolveOrg, withRegistry, withOrgLifecycle } from './org-registry.mjs';
import { OrgError, orgRoot, quiet, readRegistry, reserveOrg, resolveOrg, withRegistry, withOrgLifecycle } from './org-registry.mjs';
import { loadInstalled } from '../../plugins/src/loader.mjs';
import { BINDING_NAME } from '../../discord/src/binding.mjs';
import { parseJsonFileText } from './util.mjs';
@@ -21,11 +21,6 @@ function authorize(ctx) { if (!ctx || typeof ctx.instance !== 'string' || !/^[a-
function checkId(id) { if (typeof id !== 'string' || !ID_PATTERN.test(id)) fail('org-invalid', 'invalid id'); }
// A filesystem error names the operation, the file by label and the error code, never the
// host path Node puts in its message (PR #1916). code, syscall and the other fields stay.
const quieted = new WeakSet();
function quiet(e, what) {
if (e && typeof e === 'object' && typeof e.syscall === 'string' && typeof e.code === 'string' && !quieted.has(e)) { e.message = `cannot ${e.syscall} ${what} (${e.code})`; quieted.add(e); }
return e;
}
async function privateRead(file, what = 'organization file') { let fd; try { fd = await fs.open(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); } catch (e) { throw quiet(e, what); } try { const st = await fd.stat(); if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o077)) fail('org-mismatch', 'unsafe file', file); return await fd.readFile('utf8'); } catch (e) { throw quiet(e, what); } finally { await fd.close(); } }
async function atomic(file, content, exclusive = false) {
const tmp = join(dirname(file), `.org-write-${randomBytes(12).toString('hex')}`);
@@ -1,3 +1,4 @@
import { checkAutonomy } from './autonomy.mjs';
// The organization configuration file, still the v1 business file at
// <configDir>/businesses/<id>.json (REQ-ROLE-3, note section 2, addendum A
// sections 7 and 8; rename design r2 stage B: names only, same bytes). Human edits go through the trusted
@@ -27,7 +28,7 @@ const BOT_NAME = /^bot-[a-z0-9][a-z0-9._-]{0,60}$/;
// How long a thread's session stays up with no turn before the launcher stops
// it, when launch.threadIdleSeconds is absent (REQ-THREAD-2, row 121).
export const THREAD_IDLE_SECONDS = 900;
const TOP_KEYS = ["businessVersion", "id", "human", "arbiters", "projects", "vars", "tracker", "roles", "launch", "plugins", "hosts", "retired", "replyLimits", "usage", "appearance", "definitions"];
const TOP_KEYS = ["businessVersion", "id", "human", "arbiters", "projects", "vars", "tracker", "roles", "launch", "plugins", "hosts", "retired", "replyLimits", "usage", "appearance", "definitions", "autonomy"];
// The config directory: next to the system config, $MOSAIC_CONFIG or
// ~/.config/mosaic-dev/config.json, the same rule mosaic-config.mjs uses.
@@ -357,12 +358,20 @@ export function validateOrganizationDocument(document, file, { rolesDir, dataRoo
if (!hasModule("system-type", id) && installedNames !== null && !installedNames.has(id)) refuse(`${file} plugins.system-type names unknown module ${id}`);
}
const usage = document.usage === undefined ? undefined : checkUsageSettings(document.usage);
const autonomy = checkAutonomy(document.autonomy);
if(autonomy?.authorityResolver) {
const entry=roles[autonomy.authorityResolver],right='decision.resolve.authority';
if(!entry || !definitions[entry.definition].authority.withinRole.includes(right) ||
[vars,entry.vars].some(layer=>layer?.['limits.authority']!==undefined && !layer['limits.authority'].includes(right))) {
const e=new Error('invalid-autonomy');e.code='invalid-autonomy';throw e;
}
}
const appearance = checkAppearance(document.appearance, `${file} appearance`);
const retired = document.retired === undefined ? undefined : checkRetired(document.retired, roles, file);
return deepFreeze({
businessVersion: 1, id: document.id, file, human, arbiters, projects, vars, tracker, roles, launch, plugins, definitions,
...(retired ? { retired } : {}), ...(usage === undefined ? {} : { usage }), ...(appearance === undefined ? {} : { appearance }),
...(autonomy === undefined ? {} : { autonomy }), ...(retired ? { retired } : {}), ...(usage === undefined ? {} : { usage }), ...(appearance === undefined ? {} : { appearance }),
});
}
+60 -7
View File
@@ -107,6 +107,12 @@ function usageFence(old, document, actor) {
fail("organization-usage-human-only");
}
function autonomyFence(old, document, actor) {
if(canonicalJson(old.autonomy)===canonicalJson(document.autonomy))return;
const agents=Object.entries(old.roles??{}).flatMap(([n,e])=>[n,`${e.definition}.${n}`]);
if(actor!==old.human || actor==='tester' || agents.includes(actor))fail('human-required');
}
function systemTypesFence(old, document, actor) {
if (canonicalJson(old.plugins?.['system-type'] ?? []) === canonicalJson(document?.plugins?.['system-type'] ?? [])) return;
const agents = Object.entries(old.roles ?? {}).flatMap(([name, role]) => [name, `${role?.definition}.${name}`]);
@@ -276,7 +282,7 @@ export function organizationWriter({ dir, rolesDir, dataRoot = null, barrier = (
// the old document, for a writer that builds what it publishes from what it read under the lock.
// #1873 A2: `pathsOnly` logs each change's path and presence without its values, for a verb whose
// values can hold a var a person typed (a secret pasted in the wrong place must not reach evidence).
function commit(id, document, { revision, actor, detail, context, beforeWrite = null, guard, pathsOnly = false }) {
function commit(id, document, { revision, actor, detail, context, beforeWrite = null, beforePublish = null, guard, pathsOnly = false }) {
requireId(actor, "authenticated actor");
requireId(id, "organization id");
let revoked = false;
@@ -298,6 +304,7 @@ export function organizationWriter({ dir, rolesDir, dataRoot = null, barrier = (
modelQuotaFence(old, document, actor);
usageFence(old, document, actor);
systemTypesFence(old, document, actor);
autonomyFence(old, document, actor);
validateBusinessDocument(document, file, { rolesDir, dataRoot });
const bytes = JSON.stringify(document, null, 2) + "\n";
// Check and log exactly what lands on disk, for every setter: a value JSON
@@ -308,6 +315,7 @@ export function organizationWriter({ dir, rolesDir, dataRoot = null, barrier = (
modelQuotaFence(old, written, actor);
usageFence(old, written, actor);
systemTypesFence(old, written, actor);
autonomyFence(old, written, actor);
validateBusinessDocument(written, file, { rolesDir, dataRoot });
if (beforeWrite) {
beforeWrite();
@@ -335,6 +343,13 @@ export function organizationWriter({ dir, rolesDir, dataRoot = null, barrier = (
rmSync(candidate);
fail("revision-conflict", "organization revision changed before publication");
}
// Pure admission check after candidate IO, immediately before publication.
// A refused prepared candidate is never rolled forward by recovery.
try { beforePublish?.(); } catch (error) {
log(id, { ...event, state: 'aborted' });
rmSync(candidate);
throw error;
}
renameSync(candidate, file);
syncDir(parent);
if (entry) { entry.digest = after; entry.revision++; publishRegistry(); }
@@ -456,7 +471,7 @@ export function organizationWriter({ dir, rolesDir, dataRoot = null, barrier = (
const root = rootForProjectCheck(dir, rolesDir);
const guard = (old, document) => {
const agents = Object.entries(old.roles ?? {}).flatMap(([n, role]) => [n, `${role?.definition}.${n}`]);
if ((!approval && actor !== old.human) || actor === "tester" || agents.includes(actor)) fail("human-required");
if ((!approval && actor !== old.human) || actor === "tester" || (approval?.authorityDecision!==approval?.decision && agents.includes(actor))) fail("human-required");
if (entry === null && old.projects?.[project]?.agents?.[name] === undefined) fail("agent-not-found", `projects.${project}.agents.${name} does not exist`);
const checked = structuredClone(document);
if (approval?.approvedDefinition) {
@@ -476,16 +491,16 @@ export function organizationWriter({ dir, rolesDir, dataRoot = null, barrier = (
if (entry === null) delete agents[name]; else agents[name] = entry;
const { agents: _drop, ...rest } = target;
return { ...old, ...(approval?.approvedDefinition ? {definitions:{...(old.definitions??{}),[approval.approvedDefinition.name]:approval.approvedDefinition.entry}} : {}), projects: { ...old.projects, [project]: Object.keys(agents).length ? { ...rest, agents } : rest } };
}, { revision, actor, guard, beforeWrite:approval?.beforeWrite, pathsOnly: true, detail: { verb: "project.agents.set", project, agent: name, form: entryForm(entry), ...(approval?.decision ? {decision:approval.decision}: {}) } });
}, { revision, actor, guard, beforeWrite:approval?.beforeWrite, beforePublish:approval?.beforePublish, pathsOnly: true, detail: { verb: "project.agents.set", project, agent: name, form: entryForm(entry), ...(approval?.decision ? {decision:approval.decision}: {}) } });
}
// A4 trusted decision executor: one locked publication of the role, route and definition.
// The broker has consumed the matching digest before entering this host-only writer.
function applyHireDecision(id, proposal, { actor, assertClean, beforeWrite = null }) {
function applyHireDecision(id, proposal, { actor, assertClean, beforeWrite = null, beforePublish = null, authorityDecision = null }) {
const { request: r, definition, entry, revision } = proposal;
if (!proposal.id || r.level !== 'org') fail('invalid-request');
const build = old => {
if (actor === 'tester' || Object.entries(old.roles).some(([n,e]) => actor === n || actor === `${e.definition}.${n}`)) fail('human-required');
if (actor === 'tester' || (authorityDecision!==proposal.id && Object.entries(old.roles).some(([n,e]) => actor === n || actor === `${e.definition}.${n}`))) fail('human-required');
if ((old.launch?.keep ?? []).includes(r.name)) fail('instance-kept');
if ((old.retired ?? []).includes(r.name)) fail('organization-instance-retired');
if (r.name === old.launch?.by || Object.values(old.arbiters).includes(r.name)) fail('organization-instance-referenced');
@@ -511,12 +526,50 @@ export function organizationWriter({ dir, rolesDir, dataRoot = null, barrier = (
}
return next;
};
return commit(id, build, {revision,actor,beforeWrite,pathsOnly:true,guard:(old,next)=>{
return commit(id, build, {revision,actor,beforeWrite,beforePublish,pathsOnly:true,guard:(old,next)=>{
if(canonicalJson(next)!==canonicalJson(build(old)))fail('organization-definitions-only');
projectsProtected(old,next);
},context:{decision:proposal.id,digest:proposal.digest??null},detail:{verb:'hire.apply',decision:proposal.id,op:r.op,agent:r.name}});
}
// AU inverse, from an immutable private proposal. It touches only the named instance and
// definition; writer validation, fresh revision and owner fence still apply. Bindings never restore.
function reverseHireDecision(id,p,{actor,beforeWrite,beforePublish}) {
const {document:old,revision}=read(id),r=p.request,prior=p.prior;
if(actor!==old.human || !prior || r.level==='root')fail('human-required');
if(r.op==='hire')fail('invalid-request'); // Hire reverses through the stop/revoke/fire executor.
const build=current=>{
const live=r.level==='org'?current.roles[r.name]:current.projects[r.project]?.agents?.[r.name];
if(r.op==='fire') {
if(r.level==='project'?live!==undefined:current.launch.instances.includes(r.name))fail('decision-stale');
if(r.level==='org') {
const strip=x=>{const {credentials,tracker,...rest}=x??{};return rest;};
if(canonicalJson(strip(live))!==canonicalJson(strip(prior.instance)))fail('decision-stale');
}
if(prior.definition && canonicalJson(current.definitions?.[prior.instance.definition.slice(4)])!==canonicalJson(prior.definition))fail('decision-stale');
} else if(live?.hireDecision!==p.id)fail('decision-stale');
const next=structuredClone(current);
if(r.level==='project') {
const entries=next.projects[r.project].agents??{};
if(prior.instance)entries[r.name]=structuredClone(prior.instance);else delete entries[r.name];
delete entries[r.name]?.credentials;delete entries[r.name]?.tracker;
next.projects[r.project].agents=entries;
} else {
if(!prior.instance)fail('decision-stale');
next.roles[r.name]=structuredClone(prior.instance);
delete next.roles[r.name].credentials;delete next.roles[r.name].tracker;
next.launch.instances=next.launch.instances.filter(n=>n!==r.name);
if(prior.launched)next.launch.instances.push(r.name);
}
if(prior.definition) {
const name=prior.instance.definition.slice(4);
next.definitions={...(next.definitions??{}),[name]:{...prior.definition,rev:(current.definitions?.[name]?.rev??0)+1}};
}
return next;
};
return commit(id,build,{revision,actor,beforeWrite,beforePublish,pathsOnly:true,guard:(current,next)=>{if(actor!==current.human || canonicalJson(build(current))!==canonicalJson(next))fail('human-required');},detail:{verb:'hire.reverse',decision:p.id,agent:r.name}});
}
// Runs `fn({ revision, document })` under the writer lock, after recovery, and nothing else may
// write this business meanwhile. `fn` must be synchronous: the lock is a directory held for its
// duration, and an awaited call would leave a half-checked world to whoever finds the lock gone.
@@ -584,5 +637,5 @@ export function organizationWriter({ dir, rolesDir, dataRoot = null, barrier = (
});
}
return { logFile, read, readRaw, write, addProject, setProjectRemote, setProjectAgent, setDefinition, applyHireDecision, hold, holdAsync, findAddition, record, recover: id => withLock(id, () => recover(id)) };
return { logFile, read, readRaw, write, addProject, setProjectRemote, setProjectAgent, setDefinition, applyHireDecision, reverseHireDecision, hold, holdAsync, findAddition, record, recover: id => withLock(id, () => recover(id)) };
}
+5 -8
View File
@@ -1,15 +1,12 @@
// Caller-only descriptions, shared by launch and live bus reads. Inputs are validated policy,
// never credential references, vars, call arguments or file contents.
import { decisionAutonomy } from './autonomy.mjs';
export { decisionAutonomy } from './autonomy.mjs';
import { GATED_ONLY } from './vocabulary.mjs';
const DOMAINS = Object.freeze({ 'git.merge.protected': 'technical', 'policy.change': 'delivery' });
// TI extension point for AU: today's fixed levels, without changing any routing.
export function decisionAutonomy(decisionClass) {
return ['cross-role', 'merge', 'policy'].includes(decisionClass) ? 'out of the loop' : 'in the loop';
}
export function ownRails({ instance, authority, network, credentials = [], messaging = null, arbiters = {}, launcher = null }) {
export function ownRails({ instance, authority, network, credentials = [], messaging = null, arbiters = {}, launcher = null, autonomy }) {
const actions = [];
for (const [kind, held] of Object.entries(authority ?? {})) {
for (const action of held) {
@@ -24,8 +21,8 @@ export function ownRails({ instance, authority, network, credentials = [], messa
const resolve = [...(authority?.withinRole ?? []), ...(authority?.crossRole ?? [])]
.filter(action => action.startsWith('decision.resolve.')).map(right => {
const domain = right.slice('decision.resolve.'.length);
const classes = domain === 'technical' ? ['cross-role', 'merge'] : domain === 'delivery' ? ['cross-role', 'policy'] : [];
return { right, classes: classes.map(name => ({ name, level: decisionAutonomy(name) })) };
const classes = domain === 'technical' ? ['cross-role', 'merge'] : domain === 'delivery' ? ['cross-role', 'policy'] : domain === 'authority' ? ['authority'] : [];
return { right, classes: classes.map(name => ({ name, level: decisionAutonomy(name, autonomy) })) };
});
return {
actions, resolve, network: network ?? 'unknown',
+1 -1
View File
@@ -101,7 +101,7 @@ export function resolveInstance({ system, business, project = null, instance, en
limits: { tools, network, authority: { withinRole, crossRole } },
// Row 210 (#1707, design D5): a host key is used only by the bus child, never by the session,
// so it stays out of the session's credentials and the harness's needs.
rails: ownRails({ instance, authority: { withinRole, crossRole }, network, credentials: definition.credentials, messaging: definition.messaging, arbiters: business.arbiters, launcher: business.launch?.by }),
rails: ownRails({ instance, authority: { withinRole, crossRole }, network, credentials: definition.credentials, messaging: definition.messaging, arbiters: business.arbiters, launcher: business.launch?.by, autonomy: business.autonomy }),
credentials: Object.fromEntries(Object.entries(entry.credentials).filter(([kind]) => !isHostKind(kind))),
tracker: entry.tracker,
launch,
@@ -0,0 +1,21 @@
// One refusal line for a failed `mosaic-config.mjs validate`, shared by `mosaic business` /
// `mosaic org config` and the cli's system config load (#1873). Its own module, so the
// config-command module's export list stays what importers pin.
// A failed `mosaic-config.mjs validate` as one refusal line. Render known key paths with fixed
// constraints; never relay the child's stderr, which can quote a host path or a config value.
// The config is named by where it came from, never by host path (PR #1916): `label` when the
// caller knows it (the cli's --instance load, #1873), else from MOSAIC_CONFIG.
export function systemConfigProblem(proc, env, label = null) {
const problem = String(proc.stderr ?? "").trim();
const field = problem.startsWith('mosaic-config: execution "dataRoot"') ? "dataRoot"
: /^mosaic-config: (?:unsupported )?"?((?:configVersion|environment|dataRoot|execution|console|federation|compactionReadBytes|compactionStreams)(?:\.[A-Za-z][A-Za-z0-9]*|\[\d+\])*)"?(?=[: ])/u.exec(problem)?.[1];
const name = "a 1-200 character name starting with a letter or digit and containing only letters, digits, . _ : / -";
const expected = { configVersion: "1", environment: "development or production", dataRoot: "a canonical absolute instance data path",
"execution.backend": "docker", "execution.adapter": "pi or mock", "execution.provider": name, "execution.model": name };
const detail = problem.startsWith("mosaic-config: configuration is not valid JSON (")
? "configuration is not valid JSON; expected readable JSON: "
: field ? `${field}: expected ${expected[field] ?? "a value satisfying the system configuration schema"}: `
: "expected valid system configuration: ";
return `system config problem (mosaic-config exit ${proc.status}): ${detail}${label ?? (env.MOSAIC_CONFIG ? "the system config MOSAIC_CONFIG names" : "the default system config")}${proc.error?.code ? ` (${proc.error.code})` : ""}`;
}
+2
View File
@@ -60,6 +60,7 @@ export const ACTIONS = Object.freeze([
"prd.approve",
"decision.resolve.technical",
"decision.resolve.delivery",
"decision.resolve.authority",
// Row 285 (C2-08b, #1790): a creative agent writes a supporting Markdown file in the human's library.
// Added as a hard wait and routed under PRD 1.16 REQ-WAIT-3 and REQ-LIB-6; role.mjs grants it within-role
// to the creative roles only.
@@ -96,6 +97,7 @@ export const ROUTED = Object.freeze([
"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",
@@ -0,0 +1,65 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {randomUUID} from 'node:crypto';
import {rmSync} from 'node:fs';
import {join} from 'node:path';
import {tmp,rolesCopy,businessDoc,writeJson} from './helpers.mjs';
import {organizationWriter} from '../src/organization.mjs';
import {hireDecisions} from '../src/hire-decisions.mjs';
import {instanceSettings} from '../src/instance-settings.mjs';
const role={tools:['read'],network:'none',authority:{withinRole:[],crossRole:[]},credentials:[],instructions:'Prior role'};
function setup(t, scanDefinition, barrier) {
const root=tmp();t.after(()=>rmSync(root,{recursive:true,force:true}));const rolesDir=rolesCopy(root),doc=businessDoc(root);
doc.plugins={harness:['pi']};
doc.definitions={scout:{rev:3,role:{tools:role.tools,network:role.network,authority:role.authority,credentials:[]},instructions:role.instructions}};
doc.roles.scout={definition:'org:scout',vars:{harness:'pi',model:'claude-opus-5-5'}};doc.launch.instances.push('scout');
writeJson(join(root,'businesses/acme.json'),doc);
const writer=organizationWriter({dir:root,rolesDir,barrier}),service=hireDecisions({writer,rolesDir,configDir:root,dataRoot:join(root,'data'),assertClean:()=>{},scanDefinition});
let stopped=0,revoked=0;const instances=instanceSettings({dir:root,rolesDir,writer,revoke:()=>{revoked++;}});
const prepare=async request=>{const p=await service.prepare('acme',Object.fromEntries(Object.entries({op:'change',level:'org',name:'scout',role:{inline:{...role,instructions:'Changed role'}},route:{harness:'pi',model:'claude-opus-5-5'},reason:'Test',...request}).filter(([,v])=>v!==undefined)),{id:randomUUID(),expiresAt:'2099-01-01T00:00:00Z'});return p;};
const caller=p=>({agent:false,rights:['org.hire'],owner:true,instanceOwner:true,actor:'jason',digest:p.digest,subject:{provider:'test',iss:'test',sub:'owner'}});
const reverse=p=>service.reverse('acme',p.id,{actor:'jason',beforeWrite(){},stop:async()=>{stopped++;return {running:true};},fire:proposal=>instances.fire('acme',{name:proposal.name,revision:proposal.revision},{actor:'jason',context:{decision:proposal.id}})});
return {writer,service,prepare,caller,reverse,stopped:()=>stopped,revoked:()=>revoked};
}
test('AU role-change inverse restores prior definition contents and requests relaunch of a running agent',async t=>{
const f=setup(t),p=await f.prepare({});await f.service.apply('acme',p.id,f.caller(p));
const r=await f.reverse(p),doc=f.writer.read('acme').document;
assert.equal(doc.definitions.scout.instructions,'Prior role');assert.equal(doc.definitions.scout.rev,5);assert.equal(r.relaunch.name,'scout');assert.equal(f.stopped(),1);
});
test('AU fire inverse rehires prior definition without restoring revoked bindings',async t=>{
const f=setup(t),p=await f.prepare({op:'fire',role:undefined,route:undefined});await f.service.apply('acme',p.id,f.caller(p));
assert.ok(!f.writer.read('acme').document.launch.instances.includes('scout'));
await f.reverse(p);const doc=f.writer.read('acme').document;
assert.ok(doc.launch.instances.includes('scout'));assert.equal(doc.definitions.scout.instructions,'Prior role');assert.equal(doc.roles.scout.credentials,undefined);
});
test('AU hire inverse uses the normal fire/revoke executor',async t=>{
const f=setup(t),p=await f.prepare({op:'hire',name:'new-scout'});await f.service.apply('acme',p.id,f.caller(p));
await f.reverse(p);assert.ok(!f.writer.read('acme').document.launch.instances.includes('new-scout'));assert.equal(f.revoked(),1);
});
test('AU inverse never overwrites a subsequent role change or relaunched fired agent',async t=>{
const f=setup(t),p=await f.prepare({});await f.service.apply('acme',p.id,f.caller(p));
const later=await f.prepare({role:{inline:{...role,instructions:'Later'}}});await f.service.apply('acme',later.id,f.caller(later));
await assert.rejects(f.reverse(p),{code:'decision-stale'});assert.equal(f.stopped(),0);
const fire=await f.prepare({op:'fire',role:undefined,route:undefined});await f.service.apply('acme',fire.id,f.caller(fire));
const old=f.writer.read('acme');f.writer.write('acme',{...old.document,launch:{...old.document.launch,instances:[...old.document.launch.instances,'scout']}},{revision:old.revision,actor:'jason'});
await assert.rejects(f.reverse(fire),{code:'decision-stale'});
});
test('AU inverse scans prior definition against current held credentials before stopping or writing',async t=>{
let refuse=false;
const f=setup(t,()=>{if(refuse)throw Object.assign(new Error('definition-holds-credential'),{code:'definition-holds-credential'});});
const p=await f.prepare({});await f.service.apply('acme',p.id,f.caller(p));
const before=f.writer.read('acme').revision;refuse=true;
await assert.rejects(f.reverse(p),{code:'definition-holds-credential'});
assert.equal(f.stopped(),0);assert.equal(f.writer.read('acme').revision,before);
});
test('AU inverse crossing its window during candidate IO never publishes or recovers forward',async t=>{
let reversing=false,expired=false;
const f=setup(t,undefined,point=>{if(reversing && point==='prepared')expired=true;});
const p=await f.prepare({});await f.service.apply('acme',p.id,f.caller(p));
const prior=f.writer.read('acme');reversing=true;
await assert.rejects(f.service.reverse('acme',p.id,{actor:'jason',beforeWrite(){if(expired)throw Object.assign(new Error('reverse-window-closed'),{code:'reverse-window-closed'});},stop:async()=>({running:true})}),{code:'reverse-window-closed'});
assert.equal(f.writer.read('acme').revision,prior.revision);
f.writer.recover('acme');assert.equal(f.writer.read('acme').revision,prior.revision);
});
@@ -0,0 +1,62 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { decisionAutonomy } from '../src/rails.mjs';
import { VERB_RIGHTS } from '../src/org-rights.mjs';
test('AU absent key preserves fixed levels; present per-class levels are read', () => {
for (const name of ['merge', 'policy', 'cross-role']) assert.equal(decisionAutonomy(name), 'out of the loop');
assert.equal(decisionAutonomy('authority'), 'in the loop');
assert.equal(decisionAutonomy('merge', { merge: 'near the loop' }), 'near the loop');
});
test('AU unknown class or level refuses instead of inventing a default', () => {
assert.throws(() => decisionAutonomy('unknown'), { code: 'invalid-autonomy' });
assert.throws(() => decisionAutonomy('merge', { merge: 'automatic' }), { code: 'invalid-autonomy' });
});
test('AU owner-only Console verbs have explicit null rights', () => {
for (const verb of ['autonomy.get.console','autonomy.set.console','decision.confirm.console','decision.reverse.console']) assert.equal(VERB_RIGHTS[verb], null, verb);
});
import { tmp, rolesCopy, businessDoc, writeJson } from './helpers.mjs';
import { rmSync } from 'node:fs';
import { join } from 'node:path';
import { validateBusinessDocument } from '../src/organization-config.mjs';
import { organizationWriter } from '../src/organization.mjs';
test('AU setting validates in the organization file; every writer refuses non-owner edits',t=>{
const root=tmp();t.after(()=>rmSync(root,{recursive:true,force:true}));
const rolesDir=rolesCopy(root),doc=businessDoc(root);doc.autonomy={merge:'in the loop'};
assert.equal(validateBusinessDocument(doc,join(root,'acme.json'),{rolesDir}).autonomy.merge,'in the loop');
for(const autonomy of [{unknown:'in the loop'},{merge:'fast'},{deploy:'out of the loop'}])assert.throws(()=>validateBusinessDocument({...doc,autonomy},join(root,'acme.json'),{rolesDir}),{code:'invalid-autonomy'});
writeJson(join(root,'businesses/acme.json'),doc);
const writer=organizationWriter({dir:root,rolesDir}),old=writer.read('acme');
for(const actor of ['coder','tester','member'])assert.throws(()=>writer.write('acme',{...doc,autonomy:{merge:'out of the loop'}},{revision:old.revision,actor}),{code:'human-required'});
});
import {authorityBudgetCovered} from '../src/autonomy.mjs';
test('AU authority budgets cover all selectable models and only enforced account harnesses',()=>{
const cap={limit:0,since:'2026-01-01T00:00:00.000Z'};
const b={plugins:{harness:['pi'],'limit-check':['token-limit']},launch:{models:{one:{provider:'one',model:'m'},two:{provider:'two',model:'n'}},limits:{tokens:{providers:{one:cap}}}}};
assert.equal(authorityBudgetCovered(b),false);
b.launch.limits.tokens.models={two:{n:cap}};assert.equal(authorityBudgetCovered(b),true);
b.plugins['limit-check']=[];assert.equal(authorityBudgetCovered(b),false);
b.launch.account={name:'shared'};b.launch.limits.tokens={keys:{shared:cap}};b.plugins['limit-check']=['token-limit'];
// What today's two harness modules declare (packages/plugins/harness/*/index.mjs countsAccount).
const declared={countsAccount:h=>({'claude-code':true,pi:false})[h]??null};
assert.equal(authorityBudgetCovered(b,declared),false);
b.plugins.harness=['claude-code'];assert.equal(authorityBudgetCovered(b,declared),true);
b.plugins.harness.push('pi');assert.equal(authorityBudgetCovered(b,declared),false);
b.plugins.harness=['claude-code'];b.plugins['limit-check']=['daily-quota'];b.launch.limits.tokens={daily:{shared:{...cap,reset:'00:00',timeZone:'UTC'}}};assert.equal(authorityBudgetCovered(b,declared),true);
});
test('AU an account quota covers authority only when every enabled harness declares it spends the launch account (#1873)',()=>{
const cap={limit:0,since:'2026-01-01T00:00:00.000Z'};
const b={plugins:{harness:['alpha','beta'],'limit-check':['token-limit']},launch:{account:{name:'shared'},models:{one:{provider:'one',model:'m'}},limits:{tokens:{keys:{shared:cap}}}}};
const declares=map=>({countsAccount:h=>map[h]??null});
// The declared fact decides, whatever the harness is called.
assert.equal(authorityBudgetCovered(b,declares({alpha:true,beta:true})),true);
assert.equal(authorityBudgetCovered(b,declares({alpha:true,beta:false})),false);
assert.equal(authorityBudgetCovered(b,declares({alpha:true})),false,'an undeclared harness is not covered');
assert.equal(authorityBudgetCovered(b,declares({alpha:true,beta:'yes'})),false,'only true counts');
b.plugins.harness=['claude-code'];
assert.equal(authorityBudgetCovered(b,declares({'claude-code':false})),false,'the name alone covers nothing');
assert.equal(authorityBudgetCovered(b),false,'no lookup: fail closed');
// The provider/model branch needs no harness fact.
b.launch.limits.tokens.providers={one:cap};assert.equal(authorityBudgetCovered(b),true);
});
+10 -5
View File
@@ -124,8 +124,8 @@ test("validate: credential reference problems exit 2 and name each one", () => {
const r = run(s, "validate", "acme");
assert.equal(r.code, 2);
assert.equal(r.out, "");
assert.match(r.err, /cto-gitea\.token: mode 644/);
assert.match(r.err, /coder\.token: inside .*data/);
assert.match(r.err, /roles\.cto\.credentials\.gitea file: mode 644/);
assert.match(r.err, /roles\.coder\.credentials\.vikunja file: inside the data root/);
assert.match(r.err, /expired on 2026-01-01/);
assert.match(r.err, /3 credential reference problem/);
});
@@ -140,8 +140,8 @@ test("validate: a not-issued store agent dated today passes, and an issued one w
});
const r = run(s, "validate", "acme");
assert.equal(r.code, 0, r.err);
assert.match(r.err, /gitea file .*secrets\/agents\/acme\/coder\/gitea: not issued/);
assert.match(r.err, /vikunja file .*secrets\/agents\/acme\/coder\/vikunja: not issued/);
assert.match(r.err, /roles\.coder\.credentials\.gitea store file: not issued/);
assert.match(r.err, /roles\.coder\.credentials\.vikunja store file: not issued/);
assert.doesNotMatch(r.err, /expire|rotation/);
const issued = setup((doc) => {
@@ -150,7 +150,7 @@ test("validate: a not-issued store agent dated today passes, and an issued one w
tokenFile(join(issued.root, "config", "secrets", "agents", "acme", "coder"), "vikunja");
const e = run(issued, "validate", "acme");
assert.equal(e.code, 2);
assert.match(e.err, /vikunja file .*coder\/vikunja: expired on 2026-01-01/);
assert.match(e.err, /roles\.coder\.credentials\.vikunja store file: expired on 2026-01-01/);
});
test("validate: a token file inside the repository is refused", () => {
@@ -211,5 +211,10 @@ test("resolve: refusals", () => {
assert.equal(run(s, "resolve", "acme", "ghost").code, 2);
assert.equal(run(s, "resolve", "acme", "constructor").code, 2);
assert.match(run(s, "resolve", "acme", "pm", "--project", "ghost").err, /declares no project "ghost"/);
// #1873: a --project value that is not a plain name is never echoed.
const odd = run(s, "resolve", "acme", "pm", "--project", "/srv/sk-test-NOT-A-SECRET-1234/x");
assert.equal(odd.code, 2);
assert.match(odd.err, /declares no project by that name/);
assert.doesNotMatch(odd.err, /sk-test|NOT-A-SECRET|\/srv\//);
assert.equal(run(s, "resolve", "acme", "pm", "--project", "stack").code, 4);
});
@@ -177,7 +177,7 @@ test("T19 add of a credentialed definition writes store references dated today,
});
const validated = f.validate();
assert.equal(validated.status, 0, validated.stderr);
assert.match(validated.stderr, /reviewer-2\/vikunja: not issued/);
assert.match(validated.stderr, /roles\.reviewer-2\.credentials\.vikunja store file: not issued/);
// The Console's create form keeps listing it as not accepted.
assert.equal(f.settings.read("acme").definitions.reviewer.accepted, false);
});
@@ -267,3 +267,35 @@ test('a role can add a legacy model when the model-provider module is off', asyn
const saved=settings.set('acme',{revision:view.revision,models,instances:{coder:'legacy-new'}},'chief-of-staff.chief');
assert.equal(saved.instances.coder.modelKey,'legacy-new');
});
test('D3 P0-4: a thinking-only change on a provider model saves with no catalogue, and a model or harness change still refuses', async () => {
const f=fixture();const {settings}=await routeSettings(f);const doc=JSON.parse(readFileSync(f.file,'utf8'));
doc.plugins['model-provider']=['fixture','cliproxy'];doc.launch.models['fixture-deterministic']={provider:'proxy',model:'fixture-deterministic',module:'fixture',upstream:'fixture'};
doc.roles.coder.vars={...doc.roles.coder.vars,harness:'pi',model:'fixture-deterministic',thinking:'off'};writeJson(f.file,doc);
const pick=(thinking,extra={})=>({coder:{harness:'pi',model:'fixture-deterministic',thinking,fallback:null,...extra}});
for(const catalogue of [null,{status:'unavailable',models:[],notices:[]}]){
const view=settings.routes('acme'),next=view.instances.coder.thinking==='low'?'high':'low';
settings.setRoutes('acme',{revision:view.revision,routes:pick(next)},'jason',catalogue);
assert.equal(JSON.parse(readFileSync(f.file,'utf8')).roles.coder.vars.thinking,next);
}
// A level the harness doesn't apply still refuses on a thinking-only change.
assert.throws(()=>settings.setRoutes('acme',{revision:settings.routes('acme').revision,routes:pick('medium')},'jason',null),code('thinking-unsupported'));
// A harness or model change truly needs the catalogue.
const rev=settings.routes('acme').revision,before=readFileSync(f.file,'utf8');
assert.throws(()=>settings.setRoutes('acme',{revision:rev,routes:pick('low',{harness:'claude-code'})},'jason',{status:'unavailable',models:[],notices:[]}),code('model-catalogue-unavailable'));
assert.throws(()=>settings.set('acme',{revision:rev,instances:{reviewer:'fixture-deterministic'}},'jason',{status:'unavailable',models:[],notices:[]}),code('model-provider-unavailable'));
assert.equal(readFileSync(f.file,'utf8'),before);
});
test('D3 review: a fallback thinking-only change on a provider model saves with no catalogue, and a fallback harness change still refuses', async () => {
const f=fixture();const {settings}=await routeSettings(f);const doc=JSON.parse(readFileSync(f.file,'utf8'));
doc.plugins['model-provider']=['fixture','cliproxy'];doc.launch.models['fixture-deterministic']={provider:'proxy',model:'fixture-deterministic',module:'fixture',upstream:'fixture'};
doc.roles.coder.vars={...doc.roles.coder.vars,harness:'pi',model:'fixture-deterministic',thinking:'off','fallback.harness':'pi','fallback.model':'fixture-deterministic','fallback.thinking':'off'};writeJson(f.file,doc);
const unavailable={status:'unavailable',models:[],notices:[]};
const pick=fallback=>({coder:{harness:'pi',model:'fixture-deterministic',thinking:'off',fallback:{harness:'pi',model:'fixture-deterministic',...fallback}}});
settings.setRoutes('acme',{revision:settings.routes('acme').revision,routes:pick({thinking:'low'})},'jason',unavailable);
assert.equal(JSON.parse(readFileSync(f.file,'utf8')).roles.coder.vars['fallback.thinking'],'low');
const before=readFileSync(f.file,'utf8');
assert.throws(()=>settings.setRoutes('acme',{revision:settings.routes('acme').revision,routes:pick({harness:'claude-code',thinking:'low'})},'jason',unavailable),code('model-catalogue-unavailable'));
assert.equal(readFileSync(f.file,'utf8'),before);
});
@@ -25,6 +25,11 @@ const BUNDLES = {
approver: ['board.read', 'decide', 'org.view', 'task.write', 'thread.read', 'thread.write'],
};
const VERBS = {
'autonomy.get.console': null,
'autonomy.set.console': null,
'autonomy.review.console': null,
'decision.confirm.console': null,
'decision.reverse.console': null,
'project.list': 'org.view',
names: 'org.view',
'thread.list': 'thread.read',
@@ -0,0 +1,113 @@
// #1873 (redact6): an organization refusal names the field, the file or the root 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 { chmodSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { checkCredentialRef, parseCredentialRef, storePath } from '../src/credentials.mjs';
import { OrgError, orgLock, quietMessage, readRegistry, withRegistryWriter } from '../src/org-registry.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, { mode: 0o700 });
return { root, dir };
}
function clean(text, root) {
assert.ok(!text.includes(root), text);
assert.doesNotMatch(text, /sk-test|NOT-A-SECRET/);
}
test('a credential problem names the field and the root by label, never the token file or the root', t => {
const { root, dir } = scratch(t);
const file = join(dir, 'gitea');
writeFileSync(file, 'token', { mode: 0o644 });
const ref = { service: 'gitea', file, rotateBy: '2999-01-01' };
const { problems } = checkCredentialRef(ref, { forbiddenRoots: [{ path: dir, label: 'the data root' }, root], where: 'roles.coder.credentials.gitea' });
assert.deepEqual(problems, [
'roles.coder.credentials.gitea file: mode 644 gives group or other access; use 600',
'roles.coder.credentials.gitea file: inside the data root; token files live outside the repository and dataRoot',
'roles.coder.credentials.gitea file: inside a forbidden root; token files live outside the repository and dataRoot',
]);
for (const p of problems) clean(p, root);
const missing = checkCredentialRef({ service: 'gitea', file: join(dir, 'absent'), rotateBy: '2999-01-01' });
assert.deepEqual(missing.problems, ['gitea file: not found']);
const store = checkCredentialRef({ service: 'gitea', store: true, file: join(dir, 'absent'), rotateBy: '2999-01-01' }, { where: 'roles.coder.credentials.gitea' });
assert.deepEqual(store.warnings, ['roles.coder.credentials.gitea store file: not issued']);
});
test('credential-not-yours names the field, not the file', t => {
const { root, dir } = scratch(t);
const store = { configDir: dir, business: 'acme', agent: 'coder' };
const other = storePath({ ...store, agent: 'pm' }, 'gitea');
assert.throws(() => parseCredentialRef({ file: other, rotateBy: '2999-01-01' }, 'gitea', 'roles.coder.credentials.gitea', { store }), error => {
assert.match(error.message, /^roles\.coder\.credentials\.gitea\.file credential-not-yours: the file is in the agents' store/);
clean(error.message, root);
return true;
});
});
test('an OrgError keeps a document field but drops a host path', t => {
const { dir } = scratch(t);
assert.equal(new OrgError('org-mismatch', 'unsafe file', join(dir, 'org.json')).path, undefined);
assert.equal(new OrgError('org-invalid', 'bad root', '$.projects.alpha.root').path, '$.projects.alpha.root');
const error = Object.assign(new Error(`ENOENT: no such file or directory, open '${join(dir, 'x')}'`), { code: 'ENOENT', syscall: 'open' });
assert.equal(quietMessage(error, 'the registry file'), 'cannot open the registry file (ENOENT)');
assert.equal(quietMessage(Object.assign(new Error('x'), { code: 'ENOENT', syscall: `spawn ${join(dir, 'git')}` }), 'git'), 'cannot spawn git (ENOENT)');
});
test('an unreadable registry names the registry file and the error code, never its path', async t => {
const { root, dir } = scratch(t);
writeFileSync(join(dir, 'real.json'), '{}', { mode: 0o600 });
symlinkSync(join(dir, 'real.json'), join(dir, 'org-runtime.json'));
await assert.rejects(readRegistry({ configDir: dir }), error => {
assert.ok(error instanceof OrgError, error.stack);
assert.equal(error.code, 'org-mismatch');
assert.equal(error.message, 'invalid registry: cannot open registry file (ELOOP)');
assert.equal(error.path, undefined);
clean(error.message, root);
clean(JSON.stringify(error), root);
return true;
});
});
test('an unreadable recovery fence names the fence in the config directory, never its path', async t => {
const { root, dir } = scratch(t);
writeFileSync(join(dir, 'org-lifecycle-acme.lock.clear'), '{}', { mode: 0o600 });
chmodSync(join(dir, 'org-lifecycle-acme.lock.clear'), 0o644);
await assert.rejects(orgLock({ configDir: dir }, 'acme', { clear: true }), error => {
assert.ok(error instanceof OrgError, error.stack);
assert.equal(error.code, 'org-locked');
assert.equal(error.message, 'unreadable recovery fence org-lifecycle-acme.lock.clear in the config directory; remove it by hand');
assert.equal(error.path, undefined);
clean(error.message, root);
return true;
});
});
// Review round (#1873): the registry's own filesystem calls name the directory or file by label.
test('a missing config directory names it by label and code, never its path, and keeps the system code', async t => {
const { root, dir } = scratch(t);
await assert.rejects(readRegistry({ configDir: join(dir, 'absent') }), error => {
assert.equal(error.code, 'ENOENT');
assert.equal(error.message, 'cannot lstat the config directory (ENOENT)');
clean(error.message, root);
return true;
});
});
test('the registry writer names the registry file by label when it cannot open it, never its path', t => {
const { root, dir } = scratch(t);
writeFileSync(join(dir, 'real'), '{}', { mode: 0o600 });
symlinkSync(join(dir, 'real'), join(dir, 'org-runtime.json'));
assert.throws(() => withRegistryWriter(dir, () => {}), error => {
assert.equal(error.code, 'ELOOP');
assert.equal(error.message, 'cannot open the registry file (ELOOP)');
clean(error.message, root);
return true;
});
});
+3 -1
View File
@@ -251,7 +251,9 @@ export returns:
session credential (`no-session-credential`) and `MOSAIC_CLAUDE_BIN`.
- `version({env})` is what the manifest records as `harnessVersion`.
- `countsAccount()` says whether its launches spend `launch.account` and get
the row 126 account checks.
the row 126 account checks. The same answer decides whether an account
quota covers authority hires (#1873); the bus reads it once at start, so
return a constant.
- `mounts`, `builtinWeb`, `builtins`, `typedName`, `reliesOn`,
`gateTimeout`, `bundle`, `env`, `turnEnd`, `outputPolicy` and `transcript` give its
sandbox binds, web, tool names, S0 lines, bundle files, adapter variables,
@@ -63,7 +63,8 @@ export function preflight({ env, proxy = null }) {
if (!binary(env)) {
return {
code: "harness-binary-missing",
message: env.MOSAIC_CLAUDE_BIN ? `MOSAIC_CLAUDE_BIN ${env.MOSAIC_CLAUDE_BIN} isn't an executable file at an absolute path` : "no claude on the bus host's PATH",
// Names the variable, never its value, which is a host path (#1873).
message: env.MOSAIC_CLAUDE_BIN ? "MOSAIC_CLAUDE_BIN isn't an executable file at an absolute path" : "no claude on the bus host's PATH",
};
}
return null;
Loaded 100 of 137 files, more files were not shown because too many files have changed in this diff. Show more