Compare commits

...
60 Commits
Author SHA1 Message Date
jason.woltje 666f4d6864 fix(cli): build review fixes for the parity verbs (#1873, P1-3)
- autonomy set: an empty --authority-resolver clears the resolver as the
  panel does; --reverse-window-hours takes 1 to 168, as the page and bus do.
- The default instance gets a one-line refusal (exit 3) naming the named
  instance and --dev-local-owner, not a stack trace.
- tasks create|edit|archive: a value starting with -- is a usage error;
  --business is checked before the agent guard, and the guard also reads
  process.env.
- Drop the unused consoleRefusal export.
- Docs: --business is required, resolver clearing, window range, and the
  named-instance requirement.
2026-11-11 20:47:35 -06:00
jason.woltje 226e28d1e6 fix(cli): autonomy set takes the bus's 64-hex revision (#1873, P1-3)
The panel's revision is the bus's sha256 of the organization, so a
whole-number --revision could never match a real Console. The fake in
cli-parity now answers hex revisions, and a whole number is a usage error.
2026-11-11 20:47:35 -06:00
jason.woltje 550bd084d0 feat(cli): hire, autonomy and task-board verbs for WebUI parity (#1873, P1-3)
mosaic org role-templates|hire|change|fire and mosaic org autonomy
[set|review] send the hire form's and the autonomy panel's own requests
to the running Console's org route, so the Console runs the same owner,
send, origin, body and revision checks and the same bus verbs. They
refuse inside an agent run after their usage checks.

mosaic tasks create|edit|archive pass the board's parseTaskWrite and go
to the bus as the person (tasks.create|edit|archive).

Docs: cli README, docs/TOOLS.md, webui README Console actions table.
2026-11-11 20:47:34 -06:00
jason.woltje 8827905ba0 fix(org): restore names bus recover for a stale lock; receipt taken at mount; restore-then-dashboard test (#1873)
Build review P3-1, P3-3 and P3-4.
2026-11-11 20:14:46 -06:00
jason.woltje a559957085 feat(org): restore starts the bus; pages follow restart and quick setup (#1873)
P1-4: org restore starts the bus as mosaic bus start ID --dev-local-owner
--detach would, and its receipt names the bus. Where that start is refused
(deployed or stamped instance, agent run) restore refuses before changing
anything; --no-bus keeps the old restore. The Console Restore action
restores and starts the bus with one receipt, and offers Restart bus when
the start fails after a good restore.

P1-5: Quick Setup refreshes the sidebar organization list, and the
organization dashboard redraws on the config event Restart bus already
dispatches, so the bus line and System types follow without a reload.
2026-11-11 20:03:35 -06:00
jason.woltje 6d0e9f89be fix(bus): sweep ungranted copy slots at start, clean a failed stage (#1873)
Security review P2: a hard crash after the stage left the staged token
on disk. sweepCopySlots runs at start, before Credentials loads, and
removes each copy service slot whose binding holds no grant, plus any
staged temp file. It never touches a role's slots, a slot a loaded
reference names, or an organization whose document cannot be read. A
stage write that fails removes its temp file, and its slot when the
rename has already happened.

Build review P3s: push is held to the binding's approved level, not the
token's. Tests now cover the boot-copy pre-checks, the format check
running before the stage, and stageCopy's reference refusal.
2026-11-11 17:13:04 -06:00
jason.woltje d5aa963893 feat(bus): copy credential issue CB2, Gitea verified live (#1873)
An owner-issued Gitea token for an agent copy is staged inactive in the
self-only store, proved live against the copy's Gitea (token scopes by
semantic comparison with all and admin refused, the copy's dedicated
per-org/copy/service account, every reachable repository, expiry), then
committed under the writer's generation, revision, decision, copyId,
incarnation and definition-pin recheck. The grant record holds only an
opaque grant id and rotate date. Vikunja refuses
credential-scope-unverified. Copies still cannot launch (CB3).
2026-11-11 17:13:04 -06:00
jason.woltje 75f6d5533a fix(proxy): review fixes for the Console proxy setup page (#1873)
Security review:
- Release verification cache keys on ctime as well as mtime, in
  nanoseconds. A same-size rewrite with mtime set back now re-hashes and
  reports proxy-install-invalid.

Build review:
- The setup page hides the account panels on any failed accounts read, not
  only proxy-down, and brings them back once the proxy answers.
- The organizations table shows a dash, not a made-up 0, when a bus does
  not answer or sends no model list (orgCatalogue in proxy-admin.mjs).
- A fence clear the bus did not answer keeps the confirmed restart for a
  retry.
- A fence with no readable time names `mosaic proxy fence clear
  --proxy-restarted` on the page, in the refusal table and in the README.
- CLI `init --generate-key` goes through the setup controller and writes
  the same init receipt as the page.
- Status carries the last ten receipts, so the page shows them after a
  reload. An initialized proxy keeps the init form folded.
- README: proxy-admin-busy is per process; the cross-process locks that
  serialize the writes are named.
2026-11-11 16:32:31 -06:00
jason.woltje f0e11cb342 feat(proxy): Console proxy setup page and CLI parity (#1873)
One Proxy setup panel on the Model proxy settings page takes an owner from
nothing to a running proxy: status with one next-step button per state, the
pinned fetch with hash checks, progress and a receipt, init with a
bus-generated key (a pasted key only over loopback or HTTPS, with a
confirmation), unit render/install/start/stop/restart with health, a fence
clear that the bus re-proves against a confirmed restart, and a stale init
lock clear that names its blockers. Own proxy per organization shows as not
built.

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

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

Page fix found in the end-to-end run: a stopped proxy hides the account
panels until the service answers. (The catalogue switch label fix found in
the same run landed upstream as D3 P0-4.)
2026-11-11 16:32:31 -06:00
jason.woltje 35c125fc1f fix(cli): agent-guard re-check: delete revision error first, real-org archive and delete proof (#1873)
org delete checks --if-revision before --confirm again, so a bad revision
without --confirm says "invalid revision" as it did on BASE.

The archive and delete "bus untouched" check moves from the status test,
whose fixture has no owner file, to the Restart bus test, which has a real
organization and a live bus. An agent run's archive and delete exit 3 and
leave the bus pid and the organization unchanged; the same archive from a
human shell stops the host, and restore makes the organization active again.
2026-11-11 16:15:51 -06:00
jason.woltje 294149c20e fix(cli): agent-guard review round: org archive and delete refuse, usage errors first (#1873)
Build review finding 2: org archive and org delete stop the organization's
bus, so they now refuse inside an agent run like bus stop, after their usage
checks and before any config is read. Finding 3 (also security P3): the
--pm/--no-launch and Console LAN flag checks now come before the refusal,
so a usage error exits 4 as the docs say.
2026-11-11 16:15:51 -06:00
jason.woltje 6f18f3d015 fix(cli): bus and Console lifecycle verbs refuse inside an agent run (#1873)
D2 security re-check P1 (agent-guard). mosaic bus start|stop|restart and
mosaic console start (with or without --dev-local-owner), stop and restart
refuse inside an agent run with refuseInsideAgent's refusal (exit 3), as
bus recover does. The check reads the given environment and this process's
own, so a sanitized io.env does not hide a marker; bus recover and console
restart now check both too. consoleStart refuses for every Console. Status
verbs stay open. No new marker list and no new human gate.

The Console's Restart bus runs org-bus.mjs in the Console process; its new
host goes through the guarded CLI entry with the Console's own environment.
Restart Console relaunches under the supervisor without the CLI.
2026-11-11 16:15:51 -06:00
jason.woltje a5f227e9fc fix(console): Stop bus review round: trail and stopped flag (#1873)
A stop that may already have signalled the host (a timeout or an
unexpected error) is trailed as failed, not refused; only exit 3 comes
before the signal. The response's stopped flag follows what busStop
reports, so a host that exited on its own between the read and the stop
is not reported as stopped by this press.
2026-11-11 16:10:48 -06:00
jason.woltje ce066123b0 feat(console): Stop bus stops one organization's bus cleanly (#1873)
POST /api/orgs/ID/stop/bus runs busStop the way mosaic bus stop does: a
graceful SIGTERM close that leaves no host.lock or writer.lock, so an
offline schema migration can open the store straight after. It follows
restartBus for authority, same-origin checks, trail and receipt. A bus
already stopped answers done with state stopped and changes nothing; a
host identity the Console cannot prove refuses with every file unchanged.

The organization header gains a Stop bus button beside Restart bus, one
press, sharing its receipt and busy state. The restart icon now lives
inside its button, so a wide receipt no longer pulls it off the button.
2026-11-11 15:59:32 -06:00
jason.woltje 5dbd7e72d3 fix(org): copy binding review round, CB1 (#1873)
Security review P2s:
- A binding proposal keeps only its structured request fields. The
  free-text reason is never written to the private proposal or covered
  by its digest.
- A root copy's receipts are checked at prepare, at check, and again
  under the organization lock before the write and before publication.
  A copy removed, replaced or unreadable since boot refuses with
  instance-fired, copy-stale or root-policy-unreadable.

Build review P2:
- A root copy's admitted projects come from the project unions,
  recomputed from the current document and the boot's root agents. A
  project that excludes the copy, or whose union refuses, is never in
  reach; a request naming it refuses with credential-scope-exceeded.

Build review P3s: tests for proposal-time shadowing, the loader's
copyBindings validation, a pending record that covers every service,
a stricter path check on the refusal table, and refusal codes on every
bare rejects in the binding tests.
2026-11-11 14:27:08 -06:00
jason.woltje d23673cd3e feat(org): copy credential binding CB0 and CB1 (#1873)
CB0: registry incarnation minted at reserve (legacy entries get one at
the first binding proposal, under the registry lock); copyId, source
and definition pin, binding generation; a pure resolver where
effective = role intersect approved and only a bound, matching,
unshadowed record resolves.

CB1: copyBindings in the organization file (ids, scope names, project
ids and integers only). applyCopyBindingDecision is its only writer,
behind a module-private capability every other commit is fenced
against. A binding is an owner-only subtype of the hire decision:
only the chief raises it, no autonomy level, resolver agent, member
right, tester or reader resolves it. Digest, expiry, single
consumption, revision, generation, incarnation and definition pin are
rechecked before any effect; a bind writes pending, an unbind the
unbound tombstone, receipts are paths only. Nothing writes bound, so
nothing launches on a binding yet (CB2/CB3). The org service and the
Console update path keep copyBindings server-held. Eight new refusal
codes with next steps.
2026-11-11 14:27:08 -06:00
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
jason.woltje 4a122699a0 fix: preserve tester session identity through cleanup (#1873) 2026-11-11 08:22:31 -06:00
jason.woltje f382699d47 fix: bound tester supervisor cleanup and verify fast sessions (#1873) 2026-11-11 05:13:47 -06:00
jason.woltje 5e93356cfa fix(authority): close chief grant and root fire review gaps (#1873) 2026-11-11 04:19:33 -06:00
jason.woltje 89205a6163 feat(authority): resolve hire, fire and role changes through pinned human decisions (#1873) 2026-11-11 04:19:33 -06:00
jason.woltje 6342940634 test(cli): clarify cleanup timing limits and namespace failures (#1873) 2026-11-11 03:49:22 -06:00
jason.woltje cfd7e612d3 test(cli): bound supervisor cleanup with a fixed process inventory (#1873) 2026-11-11 03:20:27 -06:00
jason.woltje afe131444c test(cli): retain default service template coverage (#1873) 2026-11-11 02:34:50 -06:00
jason.woltje 0b52249cc3 test(cli): measure legacy and query inner timers at the supervisor (#1873) 2026-11-11 02:11:48 -06:00
jason.woltje b7c9eee603 fix(organization): definition writes go through the bus with the held-secret check (#1873)
Review P1: the CLI wrote definitions with a no-op secret check. setDefinition
now refuses definition-secret-check-required when a write that adds or changes
an entry has no assertClean. New bus verbs definition.import|invent|edit|remove
run the writes for the business's human at the cli with the credential
store's assertClean; the tester, agents and readers refuse. The CLI's
definition writes call the bus and map its codes to messages that never
quote the definition.
2026-11-11 01:15:12 -06:00
jason.woltje 1d07df0d61 feat(organization): org definitions, template hash and diff reads, definition verbs (#1873)
Roles round 2, slice T1 (roles-and-rails design sections 4, 5 and 13).

- The org file's definitions key: each entry {rev, from?, role, instructions,
  personality?} passes checkComposition; a bad entry refuses the whole file,
  naming the definition and key, never a value.
- org:<name> instance references resolve to the file's definition; a missing
  one refuses definition-missing, naming the instance. Template names are
  unchanged.
- Template identity is the name plus a sha256 over the template JSON and its
  contract; templateHashes reads the current hash for each template.
- The writer's setDefinition is the only path that changes definitions: it
  checks the composition, bumps rev, refuses agent actors, and refuses
  definition-in-use for a definition an instance names (A4 adds the change
  decision). write() refuses organization-definitions-protected.
- Definition verbs import, edit, invent and remove need org.hire
  (definition.*.console in VERB_RIGHTS) and refuse every agent caller.
- The diff read lists the primitives that changed and a line diff of the
  instructions. It applies nothing.
- CLI: mosaic org definitions templates|list|show|diff|import|invent|edit|remove.
2026-11-11 01:15:12 -06:00
jason.woltje be698e602b test(cli): json-redaction pins follow the label refusals
The review round changed the config-command relay and the org document
refusal to name the file by label. cli/tests/json-redaction.test.mjs still
required the host path in those two CLI refusals. They now require the
label and the path's absence. The direct scripts/mosaic-config.mjs checks
and the loadSystem check (PL1, deferred) still require the path.
2026-11-11 01:04:15 -06:00
jason.woltje d085b16136 fix(organization,cli): review round: raw fs and git errors, console entry, config, document and --vars refusals name labels
Sol review (org-redact5, changes 6):
- org-service: Node filesystem errors become "cannot <syscall> <label> (<CODE>)";
  code, syscall, deleteStep and deleteReceipt stay. git failures name the verb
  and exit code, never git's stderr.
- org-console: consoleStop names "another checkout", not the entry path. Exit 3.
- config-command: the system config is named by where it came from. Exit 3.
- cli org: documents and raw fs errors are named by kind and code. Exit 2.
- instances: --vars file named by flag and kind. Exit 4.
- hosts: a refused command is named by the basename refusedCommand matched.
- role.mjs (T1): the change is back to the readJsonFile label only.
Sentinel tests for each; pins in last-values, rename-b-commands, org-recovery.
2026-11-11 01:04:15 -06:00
jason.woltje 4c8bf9ca4e fix(organization,cli): refusals name the file by label, never by host path
PR #1916 rule: a refusal names the field or file, never a config value
or a host path. readJsonFile's three refusals and parseJsonFileText now
name the file by its `what` label only. Callers whose label alone is
ambiguous add a name that already passed a strict pattern (business id,
role name, org id, turn id, project key) or a fixed path under the data
root. Exit codes, refusal codes and check order are unchanged.

organization: util, access, project, config-command, library-seats,
org-service deleteOrg receipt, agent-levels regex; T1 files role.mjs and
organization-config.mjs change only the readJsonFile label.
cli: access lock, accounts owner, host state/lock, notifier journal,
notify-config, run-evidence, dev-fixture, git-socket, org-bus, org-console.
discord: turn record label names the turn id.

Sentinel tests (sk-test-NOT-A-SECRET-1234 as a path component) cover each
changed refusal and fail on BASE; old path pins updated.
2026-11-11 01:04:15 -06:00
jason.woltje e588fb32cb test(cli): band the P10 inner timer at the supervisor, not around cleanup (#1873)
The row299 root-side pair (mos-1409) spans supervisor start-up and
clean_session, which scans every /proc entry on the host. With about
1,850 processes that adds 0.6 to 1.6 s, so the unchanged inner probe read
2.5 to 3.9 s and failed the [1.25, 2.75) band, while the 1 s inner-timer
mutant read 2.06 to 2.38 s and passed. Widening the bound alone would
have kept that mutant alive.

A second observer pair, also confined to the private test copy, brackets
only the inner timer: begin just before the supervisor starts its sleep,
end at the start of finish(). It writes only for the SO_PEERCRED peer and
at most one pair. The unchanged band now applies to that interval
(2.01 s on base, 1.00 s and 3.00 s for the 1 s and 3 s mutants, so both
fail). The root pair remains required and must contain the timer pair;
probe-inner-timeout already places it before the outer bound.
2026-11-11 00:30:10 -06:00
jason.woltje e3577e9d31 fix(case2): give the case2 fixture host the request-host ops (#1873)
test-case2.sh failed 8 of 21 steps on base from S3b on. Bisect names
f23a998ee (#797, "close project archive admission races") as the first
bad commit: it made every launch call request.admission and updated each
in-process launcher fixture except scripts/case2/launch.mjs. The fixture
host refused the op with invalid-host-request, so no launch started; the
S6/S7 "unauthenticated" and S8/S10 reds followed from the missing launch
capability, not from a separate session change.

The fixture now spreads requestHostOps from packages/cli/tests, the shared
map every other fixture uses, so it tracks the production protocol.
2026-11-11 00:30:10 -06:00
256 changed files with 16594 additions and 1782 deletions

No files matched your search

+12 -5
View File
@@ -42,9 +42,15 @@ green at every step. Not production software — a proven foundation.
rewritten, only pruned via `prune` with a receipt.
6. **Fail closed**: missing or invalid config/policy refuses the operation.
Never improvise around a refusal; diagnose it.
7. **Policy**: missions govern tasks (least-privilege intersection — a task
narrows, never widens). Role authority is declared in `roles/` and changes
only via reviewed commits.
7. **Policy**: missions govern tasks; a task narrows, never widens. The
rails, the core vocabulary of tools and actions, and the shipped role
templates in `roles/` change only by reviewed commit. An organization's
own roles are copies or compositions of known parts. One takes effect
only through a decision, resolved where the organization's owner-set
autonomy level sends it, by default a human with `org.hire`. The
decision lists every grant above the chief's own as a flagged line,
which only the owner approves. Only the chief raises hires, and a hired
agent cannot hire.
8. **Git**: commit only after applicable suites are green. Work on the
owner-authorized `refactor` branch; never force-push. Push remains an explicit
act. Do not merge into `next` or `main` without separate authorization.
@@ -104,7 +110,8 @@ Finish all independent work first, then ask one focused question only when:
Repository gates still apply. In particular, a successful implementation or a
broad request to “finish” does not by itself authorize push, merge, deployment,
release, production changes, policy/role expansion, or access to secrets. Perform
release, production changes, policy/role expansion, or access to secrets. An approved
hire decision is that authority for the one agent it names. Perform
such an action only when the established plan explicitly includes it. If blocked,
report the exact boundary, what is complete, the recommended resolution, and the
specific action that will resume; do not use “waiting for confirmation” as a
@@ -206,7 +213,7 @@ Full reference — usage, fields, exit codes, safety notes:
- `LAYERS.md` — implemented vs deferred layers
- `docs/SESSIONS.md` — session registry
- `adapters/README.md` — the harness adapter contract
- `roles/` — role contracts (conductor, future agent/coder/reviewer)
- `roles/` — shipped role templates and the conductor policy
## Recovery rule
+32 -2
View File
@@ -309,10 +309,19 @@ scripts/mosaic inbox | tasks | agents [--business <id>] [--json]
scripts/mosaic inbox --read <id>... | --read-all [--business <id>]
scripts/mosaic decide <decision> <option> [--note <text>] [--yes]
scripts/mosaic trail <task|decision> [--json]
scripts/mosaic bus start <business> [--pm | --no-launch] [--dev-local-owner] | stop | status [--json]
scripts/mosaic bus start <business> [--pm | --no-launch] [--dev-local-owner] [--detach] | stop | restart | recover | status [--json]
scripts/mosaic console start [--dev-local-owner [--dev-insecure-bind ADDR --dev-allow-host NAME[,NAME...]]] [--detach] [--port auto|N]
scripts/mosaic console stop | status | restart
scripts/mosaic org reload <org>
scripts/mosaic org role-templates <org> [--json]
scripts/mosaic org hire|change <org> --name <name> --template <template> --harness <harness> --model <model> [--thinking <level>] [--level org|project|root] [--project <project>] --reason <text> [--json]
scripts/mosaic org fire <org> --name <name> [--level org|project|root] [--project <project>] --reason <text> [--json]
scripts/mosaic org autonomy <org> [--json]
scripts/mosaic org autonomy <org> set --revision <rev> [--cross-role|--merge|--policy|--authority in|near|out] [--notices on|off] [--reverse-window-hours 1-168] [--authority-resolver <name>|""] [--json]
scripts/mosaic org autonomy <org> review cross-role|merge|policy|authority [--json]
scripts/mosaic tasks create --title <text> --requirement <REQ-X-N> [--project <name>] [--state <state>] [--assignees <role,...>] [--priority 0-5] [--due <iso>|none] --business <id> [--json]
scripts/mosaic tasks edit task:<n> --expect <digest> [--title ...] [--state ...] [--assignees ...] [--priority ...] [--due ...] --business <id> [--json]
scripts/mosaic tasks archive task:<n> --expect <digest> --business <id> [--json]
scripts/mosaic talk <role> <text> [--wait SECONDS] [--business <id>]
scripts/mosaic project create <name> [--repo <repo>] | list [--json]
scripts/mosaic threads [--project <project>] [--json] [--tester]
@@ -333,7 +342,11 @@ For local development on a named instance only, `mosaic bus start BUSINESS --dev
`mosaic console start --dev-local-owner --dev-insecure-bind ADDR --dev-allow-host NAME[,NAME...]` opens that dev owner Console to other machines, for UI testing from another device on a trusted LAN. It has no sign-in: anyone who can reach ADDR:PORT acts as the owner. Start, and `mosaic console status` while that Console runs, print `WARNING: dev owner Console listens on ADDR:PORT with no sign-in; anyone who can reach it acts as the owner.` on stderr. ADDR is `0.0.0.0` or an IPv4 address, and a specific address must also be one of the names. NAME is a host name or IPv4 address without a port; give several as one comma-separated value. The Console answers only `NAME:<bound port>` and the loopback names, and takes a write only when Origin is `http://` plus that same Host. A browser on such an http origin sends no Fetch Metadata, so for an allowed NAME a write with that exact Origin and no `Sec-Fetch-Site` counts as same-origin; a `Sec-Fetch-Site` the browser did send is still checked. Both flags are refused without `--dev-local-owner`, and `--dev-insecure-bind` is refused without a `--dev-allow-host`. SSO, preview and tester Consoles stay on loopback. The Console state file records `bind` and `allowHosts`. `mosaic console status` prints the state as JSON (`bind` is `127.0.0.1` for a loopback Console) and never moves a stale state file.
`mosaic console restart` and `mosaic org reload ORG` are the Console's Settings controls (F3) from a terminal (U1, #1873). Each sends one request to the running Console named in the instance's console state, as the page sends it (the Console's own Host and Origin, `Sec-Fetch-Site: same-origin`, a JSON `{}` body), so the Console applies the same owner, `--send` and origin checks and writes the same action trail, `<dataRoot>/console/actions.jsonl`. Both refuse inside an agent run (exit 3) before reading anything, and refuse with exit 3 when no Console runs. `console restart` works only for a Console started in the foreground by `mosaic console start` (otherwise the Console answers `not-supervised`); it waits up to 30 seconds for the relaunched Console to answer and prints `{"outcome":"restarted","from":OLD,"boot":NEW}`. `org reload ORG` restarts a running bus or starts a stopped one and prints the Console's done receipt. A request that left but got no answer is reported as outcome unknown (exit 1) and is never retried; check `mosaic console status`, `mosaic bus status ORG` and the trail. `mosaic bus restart ORG` remains the trail-less host verb.
`mosaic console restart` and `mosaic org reload ORG` are the Console's Settings controls (F3) from a terminal (U1, #1873). Each sends one request to the running Console named in the instance's console state, as the page sends it (the Console's own Host and Origin, `Sec-Fetch-Site: same-origin`, a JSON `{}` body), so the Console applies the same owner, `--send` and origin checks and writes the same action trail, `<dataRoot>/console/actions.jsonl`. Both refuse inside an agent run (exit 3) before reading anything, and refuse with exit 3 when no Console runs. `console restart` works only for a Console started in the foreground by `mosaic console start`, or by `mosaic console start --dev-local-owner --detach`, which leaves a detached supervisor (`packages/cli/src/console-supervisor.mjs`, its own session) running the Console as the foreground start does; its receipt adds the supervisor's `supervisor` pid, and its later warnings go to `console/supervisor-*.log` under the data root. `mosaic console stop` stops that Console, and its supervisor then exits. `mosaic console start` refuses inside an agent run (exit 3), with or without `--dev-local-owner` or `--detach`, checking the starting process's own environment as well as the one given for the Console (agent-guard, #1873). A detached start whose supervisor does not reply within 60 seconds signals the supervisor's process group, then kills it, and clears `console/start.lock` only when the lock names that supervisor (pid and start time) and it is gone. Any other Console answers `not-supervised`. `console restart` waits up to 30 seconds for the relaunched Console to answer and prints `{"outcome":"restarted","from":OLD,"boot":NEW}`. `org reload ORG` restarts a running bus or starts a stopped one and prints the Console's done receipt. A request that left but got no answer is reported as outcome unknown (exit 1) and is never retried; check `mosaic console status`, `mosaic bus status ORG` and the trail. `mosaic bus restart ORG` remains the trail-less host verb.
The hire form and the autonomy panel from a terminal (E2, #1873, P1-3). `mosaic org role-templates|hire|fire|change ORG` and `mosaic org autonomy ORG [set|review]` send the page's own request to the running Console's org route (`/api/orgs/ORG/decisions/hire/templates|start`, `/api/orgs/ORG/autonomy[/review]`), as `org reload` does, so the Console applies the same owner, `--send`, origin, body and live-session checks and the same bus verbs run (`hire.templates.console`, `hire.start.console`, `autonomy.get.console`, `autonomy.set.console`, `autonomy.review.console`). `hire` and `change` read the templates first and send the chosen template's name and sha256, as the form does. `autonomy set` needs the 64-hex `--revision` from `mosaic org autonomy ORG` and sends every setting the panel sends, with only the named ones changed; a stale revision is refused `revision-conflict`; an empty `--authority-resolver ""` clears the resolver, as emptying the panel's field does. These verbs need a named instance (`MOSAIC_CONFIG` set to `~/.config/mosaic-NAME/config.json`) whose Console runs with `--dev-local-owner`; the default instance is refused with exit 3. A Console refusal exits 3 with the Console's code and message; `outcome-unknown` exits 1; an unknown template exits 2. Every one of these verbs, reads included, refuses inside an agent run. `mosaic tasks create|edit|archive` are the task board's three writes: the arguments pass the board's own check (`workspace-tasks.mjs parseTaskWrite`) and go to the bus as the person (`tasks.create|edit|archive`, human transport), so the bus runs its human, field, digest and archive checks. A stale `--expect` is refused `task-conflict`; read the digest with `mosaic tasks --json`.
The bus and Console lifecycle verbs refuse inside an agent run (agent-guard, #1873): `mosaic bus start`, `bus stop`, `bus restart` and `bus recover`, and `mosaic console start`, `console stop` and `console restart`, and `mosaic org archive` and `org delete`, which stop the organization's bus, and `mosaic org restore`, which starts it (`org restore --no-bus` starts nothing and is not refused), and `mosaic org role-templates`, `hire`, `fire`, `change` and `autonomy` (E2). Each exits 3 with `refused: this runs only from a human shell, outside any agent run (found MARKER in the environment)` before it reads anything. It checks the environment it is given and its own process environment, so a sanitized environment does not hide a marker. A usage error (a missing or extra argument, `--pm` with `--no-launch`, or a bad `--dev-insecure-bind`/`--dev-allow-host`) still comes first (exit 4). `mosaic bus status` and `mosaic console status` stay open to agents. The Console's Restart bus and Restart Console controls keep working: the Console starts its bus host with its own environment, which carries no marker, and Restart Console relaunches the Console under its supervisor without going through the CLI. The markers are the ones `packages/cli/src/transport.mjs` lists. The check is cooperative: a process that drops the markers from its environment is not stopped by it.
The instance processing switch is separate from per-business launches off/on.
`mosaic halt` durably stops managed processing across every business in the
@@ -1345,6 +1358,23 @@ input; error on stderr, never a clean verdict).
problem. Scripts print diagnostics on stderr; model responses (and only
model responses) on stdout.
## Model proxy setup (`scripts/mosaic proxy`, #1873)
Human-shell verbs that match the Console's Proxy setup panel; each refuses
`human-required` in an agent shell or with `--tester`, and prints no key or
absolute path. `proxy fetch` (existing output unchanged) now prints the
page's progress steps on stderr and writes the same fetch receipt to
`<dataRoot>/state/proxy-admin.jsonl`. `proxy status [--json]` shows release, init, unit, fence, init
lock, own proxy and the next step. `proxy init --generate-key [--port N]
[--rotate-grace-minutes N] [--first-output-seconds N] --business ID` has the
bus generate the management key (no prompt, no TTY). `proxy unit
render|status|install|start|stop|restart` manages the proxy user unit.
`proxy fence clear --restart --business ID` restarts the unit, requires a
confirmed new process listening on the port, then clears the fence with that
evidence (`proxy-restart-unconfirmed` otherwise). `proxy init-lock
status|clear` clears a stale init lock only when every bus is stopped and no
proxy state exists. Details: `packages/model-proxy/README.md`.
## Agent limits (`scripts/mosaic limits`)
View: `scripts/mosaic limits --business ID --json`. Set: `scripts/mosaic limits
+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);
}
+530 -119
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';
@@ -9,7 +11,7 @@ import { initializeControl, processingStatus } from '../../processing-control/sr
import { NAME as HARNESS_NAME } from '../../plugins/src/kinds.mjs';
import { loadRequirements } from '../../tasks/src/requirements.mjs';
import { MERGE, LandingRefusal, readLanding } from './landing.mjs';
import { pluginName, ID_PATTERN, THREAD_DEFINITIONS, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers, grantScopeOf, grantInScope } from '../../organization/src/vocabulary.mjs';
import { pluginName, ID_PATTERN, THREAD_DEFINITIONS, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, giteaName, parseGiteaUrl, covers, grantScopeOf, grantInScope } from '../../organization/src/vocabulary.mjs';
// Kept closed while S1 lands; its vocabulary is the integration authority.
export const ACTIONS = Object.freeze([
@@ -43,6 +45,7 @@ export const ACTIONS = Object.freeze([
// Row 194 (#1692): routed under PRD 1.7 REQ-COS-3, below.
'role.hire',
'role.fire',
'role.change',
'credential.mint',
'spend',
'deploy',
@@ -53,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',
]);
@@ -82,9 +86,11 @@ 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',
'role.change',
// PRD 1.16 REQ-WAIT-3 and REQ-LIB-6 (row 285, #1790): a creative agent's library write.
'library.write',
]);
@@ -172,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', '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.
@@ -193,6 +199,15 @@ const userLevel = (verb) => (VIEWER_VERBS.includes(verb) ? 'viewer' : 'collabora
export const BOARD_VERBS = Object.freeze(['inbox', 'agents', 'thread.claims', 'tasks', 'trail', 'decisions', 'credentials', 'replies', 'messages', 'state', 'usage']);
// The person's task-store writes, distinct from the agent's singular task.* actions.
export const PERSON_TASK_VERBS = Object.freeze(['tasks.create', 'tasks.edit', 'tasks.archive', 'tasks.unarchive', 'tasks.delete']);
// #1873 T1: the human's definition writes, each with its [allowed, required] argument keys. Kept here,
// not imported, as the sandbox client loads this file; runtime.mjs maps each verb to the verb module
// (organization/src/definition-verbs.mjs names the same four, and a test holds them equal).
export const DEFINITION_BUS_KEYS = Object.freeze({
'definition.import': [['revision', 'template', 'as'], ['revision', 'template']],
'definition.invent': [['revision', 'name', 'definition'], ['revision', 'name', 'definition']],
'definition.edit': [['revision', 'rev', 'name', 'definition'], ['revision', 'rev', 'name', 'definition']],
'definition.remove': [['revision', 'rev', 'name'], ['revision', 'rev', 'name']],
});
// Row 175: the organization verbs a tester may call. Its writes run every check the human's do
// and log actor `tester`; row 176 adds routes reads; limits.set, organization.set and organization.revision refuse it.
export const TESTER_ORGANIZATION_VERBS = Object.freeze(['limits', 'plugins', 'instances', 'plugins.set', 'instances.set', 'routes.set', 'routes']);
@@ -217,14 +232,6 @@ const LIFECYCLE_CODE = /^[a-z-]{1,64}$/;
// Row 194 S6 (D2.2): the stop outcomes a fire's lifecycle.done reports, each written as its own event.
const FIRE_STOPS = ['killed', 'alive'];
const BOT_NAME = /^bot-[a-z0-9][a-z0-9._-]{0,60}$/;
// Row 194 (#1692, D2.1, D2.2, PRD 1.7 REQ-COS-3): the chief's hire and fire arguments, allowed then
// required. Each carries the reason the log records.
const HIRE_FIRE_ARGS = Object.freeze({
'role.hire': [['name', 'definition', 'vars', 'reason'], ['name', 'definition', 'reason']],
'role.fire': [['name', 'reason'], ['name', 'reason']],
});
// N3: hire's vars hold only the model key and limits.* keys. The writer checks the values.
const HIRE_VAR = (key) => key === 'model' || key.startsWith('limits.');
// What instance.request and the host ops answer about a request.
const lifecycleView = (r) => ({
request: r.request, action: r.action, instance: r.instance, by: r.by, at: r.at, state: r.state,
@@ -522,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');
@@ -593,9 +601,12 @@ export class Broker {
#builtin;
#builtinBinding;
#organization;
#hires;
#autonomyHost;
#pluginService;
#credentialVerbs;
#proxyInit;
#proxyFenceClear;
#proxyPage;
#project;
#library;
@@ -631,6 +642,7 @@ export class Broker {
// Row 236 (#436b): giteaGrant and giteaKeys from gitea-grant.mjs, or null, and the remote halves of
// section 5's restart rows and of revokes a restart cut short, for reconcileGitea.
#giteaGrant;
#copyGrant;
#giteaKeys;
#giteaUndo = [];
// Row 281 (#1786, d6 section 3b): each tracked Gitea promise and the business/agent it acts for, or
@@ -656,7 +668,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, project = null, library = null, usage = null, limits = null, harnessOn = null, credentialVerbs = null, proxyInit = null, proxyPage = null, hostGrant = null, hostKeys = null, processing = null, testerOn = null, grantScope = null, roleFile = null, giteaGrant = null, giteaKeys = null, access = null, accessStamp = null, compactions = null, requestLifecycle = null, requirementsHost = null, workspaceRead = null, taskTracker = null }) {
constructor({ store, businesses, pluginService = null, secretCheck = () => {}, credentialStatus = () => [], organization = null, hires = null, autonomy = null, project = null, library = null, usage = null, limits = null, harnessOn = null, credentialVerbs = null, proxyInit = null, proxyFenceClear = null, proxyPage = null, hostGrant = null, hostKeys = null, processing = null, testerOn = null, grantScope = null, roleFile = null, giteaGrant = null, copyGrant = null, 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');
@@ -679,14 +691,18 @@ export class Broker {
this.#secretCheck = secretCheck;
this.#credentialStatus = credentialStatus;
this.#organization = organization;
this.#hires = hires;
this.#autonomyHost = autonomy;
this.#pluginService = pluginService;
// Row 199: the runtime's credential lifecycle (credential-verbs.mjs); it holds the store, not the broker.
this.#credentialVerbs = credentialVerbs;
this.#proxyInit = proxyInit;
this.#proxyFenceClear = proxyFenceClear;
this.#proxyPage = proxyPage;
this.#hostGrant = hostGrant;
this.#hostKeys = hostKeys;
this.#giteaGrant = giteaGrant;
this.#copyGrant = copyGrant;
this.#giteaKeys = giteaKeys;
this.#access = access;
this.#accessStamp = accessStamp;
@@ -1552,6 +1568,11 @@ export class Broker {
// agent-copies.mjs copyRefusal; inlined because this module also loads in the sandbox client).
#copyRefused(b, instance) {
const copy = b.copies && Object.hasOwn(b.copies, instance) ? b.copies[instance] : null;
if (b.roles[instance]?.unbound?.length) throw new BusError('credential-unbound', `${instance} has no binding for ${b.roles[instance].unbound.join(', ')}`);
if(copy?.level==='root') {
const refusal=this.#hires?.rootRefusal ? this.#hires.rootRefusal(copy.name) : this.#hires?.rootActive && !this.#hires.rootActive(copy.name) ? 'instance-fired' : null;
if(refusal)fail(refusal);
}
if (!copy || copy.state === 'instance') return;
if (copy.state === 'unbound') throw new BusError('credential-unbound', `${instance} has no binding in ${b.id} for ${copy.missing.join(', ')}`);
throw new BusError(copy.project === null ? 'copy-refused' : 'project-refused', `${instance} can't launch in ${b.id}: ${copy.reason}`);
@@ -1839,6 +1860,7 @@ export class Broker {
if (s.tester) fail('tester-refused');
if (s.reader) fail('read-only');
if (s.human) fail('agent-required');
this.#copyRefused(this.#business(s.business),s.role);
const h = this.#own(s);
if (h?.op !== 'claim' || (s.thread ? h.generation !== this.#runGeneration(s) : h.holder_run !== s.run))
fail(this.#launcherReleased.has(s.business + ':' + s.run) ? 'launcher-released'
@@ -2274,6 +2296,14 @@ export class Broker {
run: null,
});
}
// Trusted account transport supplies freshly resolved membership, never request arguments.
// This capability reaches only the A4 verbs; it cannot pass existing owner Console guards.
bindHireConsole({business, actor, subject, rights, owner = false, instanceOwner = false}) {
this.#business(business);
identifier(actor);
if (!object(subject) || !['provider','iss','sub'].every(k=>typeof subject[k]==='string' && subject[k]) || !Array.isArray(rights)) fail('human-required');
return this.#cap({business,human:actor,role:null,run:null,via:'console',console:Object.freeze({...subject}),hireMembership:Object.freeze({actor,rights:[...rights],agent:false,owner,instanceOwner,subject:{...subject}})});
}
bindReader({ business }) {
this.#business(business);
return this.#cap({ business, reader: true, role: null, run: null, human: false });
@@ -2431,6 +2461,7 @@ export class Broker {
options.some(o => !object(o) || !id(o.key) || !text(o.text, 1024)) ||
new Set(options.map(o => o.key)).size !== options.length) return null;
const digest = sha256(JSON.stringify([{ ...d, options }, raise]));
if (['role.hire','role.fire','role.change'].includes(d.action)) return raise.hire ? {kind:'hire',options,...raise.hire} : null;
if (d.action !== 'credential.mint') return 'grant' in raise ? null : { kind: 'decision', options, digest };
if (!object(raise.grant) || typeof raise.grant.method !== 'string' || options.length !== 2) return null;
const approve = options.find((o) => o.key === raise.approvalChoice);
@@ -3321,24 +3352,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';
@@ -3346,9 +3379,11 @@ export class Broker {
const result = await write('instance.fire');
step = 'C';
this.#store.transaction(() => {
this.#recordAuthority(sc, 'role.fire', authority, { reason: args.reason, transaction: result.transaction ?? null });
if(sc.human)this.#event(sc,'action.allowed',{action:'role.fire',target:args.name,reason:args.reason,transaction:result.transaction??null},args.name);
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';
@@ -3361,6 +3396,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.
@@ -4778,10 +4814,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
@@ -5002,6 +5060,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(?,?,?,?,?,?,?,?,?,?,?)',
@@ -5017,7 +5077,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
@@ -5101,7 +5163,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.
@@ -5126,14 +5190,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}`);
@@ -5144,7 +5219,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,
);
@@ -5178,6 +5253,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;
});
@@ -5690,6 +5768,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);
});
@@ -5896,6 +5975,19 @@ export class Broker {
return Promise.resolve().then(() => this.#proxyPage(request.verb, args, { business: s.business, human: s.human }))
.catch(error => { throw this.#refused(s, new BusError(PROXY_PAGE_CODES.includes(error.code) ? error.code : 'proxy-down'), { verb: request.verb }); });
}
// #1873: the fence clear takes restart evidence, which the runtime re-proves on the host
// before the lock clears. Owner only, before every other gate, like the proxy page verbs.
if (request.verb === 'proxy.fence.clear') {
this.#human(s);
if (!this.#proxyFenceClear) fail('not-configured');
const args = request.args ?? {};
keys(args, ['evidence'], ['evidence']);
return Promise.resolve().then(() => this.#proxyFenceClear(args)).then(result => {
if (!object(result) || typeof result.cleared !== 'boolean') fail('proxy-down');
this.#store.transaction(() => this.#event(s, 'action.allowed', { call: 'proxy.fence.clear', cleared: result.cleared, pid: result.evidence?.after?.pid ?? null }, null));
return result;
}).catch(error => { throw this.#refused(s, new BusError(/^proxy-[a-z-]+$/.test(error.code ?? '') ? error.code : 'proxy-down'), { verb: request.verb }); });
}
// FED R3 (design 3.5, 3.7 trigger 1): a user reaches the collaborator list only, and each
// request reads the access file again, so a removed or expired grant refuses the next one.
let grants = null;
@@ -5916,19 +6008,24 @@ export class Broker {
if (request.verb === 'proxy.init') {
this.#human(s);
if (!this.#proxyInit) fail('not-configured');
keys(args, ['key', 'port', 'rotateGraceMinutes', 'firstOutputSeconds'], ['key']);
if (!validManagementKey(args.key)) fail('proxy-key-invalid');
keys(args, ['key', 'generate', 'port', 'rotateGraceMinutes', 'firstOutputSeconds']);
// #1873: exactly one of a key or generate:true. A generated key is made here, stored by
// init where a typed key goes, and never returned, logged or put in an event.
if (Object.hasOwn(args, 'generate') ? args.generate !== true || Object.hasOwn(args, 'key') : !Object.hasOwn(args, 'key')) fail('invalid-request');
if (!Object.hasOwn(args, 'generate') && !validManagementKey(args.key)) fail('proxy-key-invalid');
if (Object.hasOwn(args, 'port') && (!Number.isInteger(args.port) || args.port < 1024 || args.port > 65535 || args.port === 8317)) fail('proxy-config-invalid');
try { proxyTiming(args); } catch (error) { fail(error.code); }
// Async initialization is outside the store transaction. Refusals retain codes only.
return Promise.resolve().then(() => this.#proxyInit(args)).then(result => {
const { generate, ...given } = args;
const initArgs = generate ? { ...given, key: randomBytes(48).toString('base64url') } : given;
return Promise.resolve().then(() => this.#proxyInit(initArgs)).then(result => {
if (!object(result) || Object.keys(result).some(k => !['binary', 'port', 'rotateGraceMinutes', 'firstOutputSeconds'].includes(k))
|| !Number.isInteger(result.port) || result.port < 1024 || result.port > 65535 || result.port === 8317
|| typeof result.binary !== 'string' || !result.binary.startsWith('/')) fail('proxy-init-failed');
try { proxyTiming(result); } catch (error) { fail(error.code); }
this.#store.transaction(() => this.#event(s, 'action.allowed', { call: 'proxy.init', port: result.port }, null));
this.#store.transaction(() => this.#event(s, 'action.allowed', { call: 'proxy.init', port: result.port, ...(generate ? { generated: true } : {}) }, null));
return result;
}).catch(error => { throw this.#refused(s, new BusError(/^proxy-[a-z-]+$/.test(error.code ?? '') ? error.code : 'proxy-init-failed'), request); });
}).catch(error => { throw this.#refused(s, new BusError(/^proxy-[a-z-]+$/.test(error.code ?? '') ? error.code : 'proxy-init-failed'), { verb: request.verb }); });
}
this.#secretCheck(args);
if (request.verb === 'plugin.tool') {
@@ -6075,14 +6172,37 @@ export class Broker {
return this.#withLast(cap, s, request);
}
if (CREDENTIAL_VERBS.includes(request.verb)) return this.#requestCredential(s, request.verb, structuredClone(args));
if (request.verb === 'instance.hire' || request.verb === 'instance.fire')
{
const result = this.#chiefLifecycle(s, request.verb, structuredClone(args));
return result?.then ? result.catch(error => { throw this.#refused(s, error, request); }) : result;
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');
if(!this.#hires?.templates)fail('not-configured');
return this.#hires.templates();
}
if (request.verb === 'instance.hire' || request.verb === 'instance.fire') fail('decision-required');
if (request.verb === 'hire.raise' || request.verb === 'hire.start.console' ||
(request.verb === 'decision.raise' && ['role.hire','role.fire','role.change'].includes(args.action))) {
const input=request.verb==='decision.raise' ? (()=>{ keys(args,['action','hire'],['action','hire']); if(args.action!==`role.${args.hire?.op}`)fail('invalid-request'); return args.hire; })() : args;
return this.#raiseHire(s,input,request.verb==='hire.start.console').catch(e=>{throw this.#refused(s,new BusError(e.code??'invalid-request'),request);});
}
if(request.verb==='decision.preview.console' && ['role.hire','role.fire','role.change'].includes(this.#decision(s,args.id).action))return this.#hireConsole(s,'hire.preview.console',args).catch(e=>{throw this.#refused(s,new BusError(e.code??'outcome-unknown'),request);});
if (['hire.preview.console','hire.resolve.console'].includes(request.verb))
return this.#hireConsole(s,request.verb,args).catch(e=>{throw this.#refused(s,new BusError(e.code??'outcome-unknown'),request);});
if (request.verb === 'grants.scope' || request.verb === 'grants.scope.set')
return this.#grantScopeVerb(s, request.verb, structuredClone(args));
if (["limits", "limits.set", "organization.set", "organization.revision", "plugins", "plugins.set", "instances", "instances.set", "routes", "routes.set", "veto.set", "library.limits.set"].includes(request.verb)) {
if (["limits", "limits.set", "organization.set", "organization.revision", "plugins", "plugins.set", "instances", "instances.set", "routes", "routes.set", "veto.set", "library.limits.set"].includes(request.verb) || Object.hasOwn(DEFINITION_BUS_KEYS, request.verb)) {
// Row 123 (#1617, REQ-COS-1, REQ-LIMIT-1): organization.set is limits.set for a role whose
// file lists organization.change. It takes the same keys, runs the same write path and
// logs the same receipt; only the actor differs: the role definition and instance.
@@ -6137,6 +6257,13 @@ export class Broker {
else if (request.verb === "library.limits.set") {
keys(args, ["revision", "replyLimits"], ["revision", "replyLimits"]);
this.#human(s);
}
// #1873 T1: the definition writes need org.hire. Until row 193, as #mayHuman, the business's
// one human (the owner) passes; the tester is refused above and an agent here.
else if (Object.hasOwn(DEFINITION_BUS_KEYS, request.verb)) {
keys(args, ...DEFINITION_BUS_KEYS[request.verb]);
this.#human(s);
if (s.human !== this.#business(s.business).human) fail("human-required");
} else {
keys(args, ORGANIZATION_KEYS, ["revision"]);
// Row 194 (D2.4, B7): the chief can't change a kept instance's model, as that replaces it at
@@ -6244,6 +6371,11 @@ export class Broker {
if (a.token !== undefined) string(a.token, 16384);
if (a.date !== undefined) string(a.date, 10);
if (a.reason !== undefined) string(a.reason, 500);
// #1873 CB2: an issue for a copy this bus booted with goes to the copy verifier (copy-grant.mjs).
// The boot's copies view leaves out a name the file's own roles hold; a copy that needs no
// credential joins the roles but is still a copy. Any other unknown name answers credential-unknown.
const booted = this.#business(s.business);
if (verb === 'credentials.issue' && this.#copyGrant && booted.copies && Object.hasOwn(booted.copies, a.agent)) return this.#issueCopyGrant(s, a);
if (!Object.hasOwn(this.#business(s.business).roles, a.agent)) fail('credential-unknown');
// A running agent keeps its tokens until its runs end: delete waits for every claim to end.
if (verb === 'credentials.delete' && (this.#holder(s.business, a.agent)?.op === 'claim' || this.#threadHolders(s.business, a.agent).length))
@@ -6253,6 +6385,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');
@@ -6272,47 +6406,308 @@ export class Broker {
}
return result;
}
// Row 194 (#1692, D2.1, D2.2): the chief's hire and fire. PRD 1.7 REQ-COS-3 routes both within the
// chief's role with a reason, as role.end ends a run (S2e). The writer commits outside the store
// transaction, so the grant is checked before it and the use logged only after it, as
// organization.set's grant is.
#chiefLifecycle(s, verb, a) {
const hire = verb === 'instance.hire', action = hire ? 'role.hire' : 'role.fire';
this.#agent(s);
const [allowed, required] = HIRE_FIRE_ARGS[action];
keys(a, allowed, required);
this.#hireFireArgs(action, a);
const args = a;
const b = this.#business(s.business);
if (b.launch?.by !== s.role) fail('role-not-chief');
if (!this.#organization) fail('not-configured');
// The grant: the action is routed and on the chief's withinRole. Without both it needs a decision
// (decision-required), and these verbs take none.
const authority = { target: args.name };
this.#checkAuthority(s, action, authority);
// D2.4: a kept name is neither hired nor fired.
if ((b.launch?.keep ?? []).includes(args.name)) fail('instance-kept');
const actor = b.roles[s.role].definition + '.' + s.role;
const context = { instance: s.role, run: s.run, reason: args.reason };
// The writer gets its own keys. The reason goes in the context, which its receipt logs.
const { reason: _, ...written } = args;
const write = (op) => {
try {
const result = this.#organization(s.business, op, written, actor, context);
return result?.then ? result.catch(error => this.#organizationFail(error, verb)) : result;
} catch (e) {
this.#organizationFail(e, verb);
}
};
if (hire) {
this.#hireable(s, args.definition);
const result = write('instance.hire');
const finish = result => {
this.#store.transaction(() => this.#consumeAuthority(s, action, authority, { reason: args.reason, transaction: result.transaction }));
// #1873 CB2: owner only, as every binding decision (design Q1): not the tester, a reader or an
// org.hire member. The recheck runs after the remote work and again under the writer's locks.
#copyOwner(s) {
if (s.tester || s.reader || !s.human) fail(s.tester ? 'tester-refused' : 'human-required');
if (s.hireMembership ? s.hireMembership.owner !== true : s.human !== this.#business(s.business).human) fail('human-required');
}
#issueCopyGrant(s, a) {
this.#copyOwner(s);
const passed = (e) => e instanceof BusError && (e.code.startsWith('credential-') || ['copy-unavailable', 'human-required',
'definition-holds-credential', 'definition-scan-unreadable'].includes(e.code));
return this.#copyGrant(s.business, a, { actor: s.human, owner: true, recheck: () => this.#copyOwner(s) })
.then((result) => {
this.#secretCheck(result);
if (JSON.stringify(result).includes(a.token)) fail('credential-leak');
return result;
};
return result?.then ? result.then(finish) : finish(result);
}, (e) => {
if (passed(e)) throw e;
if (['revision-conflict', 'decision-stale'].includes(e?.code) || e?.code?.startsWith('organization-')) fail(e.code);
if (e?.name === 'BusinessError') fail('invalid-request');
throw new BusError('outcome-unknown');
});
}
async #autonomyConsole(s,verb,a) {
if(verb==='autonomy.get.console') {
keys(a,[]);
if(!this.#autonomyHost?.get)fail('not-configured');
const current=await this.#autonomyHost.get(s.business);
return {...current,levels:Object.fromEntries(['cross-role','merge','policy','authority'].map(k=>[k,decisionAutonomy(k,current.autonomy)]))};
}
if(verb==='autonomy.review.console') {
keys(a,['decisionClass'],['decisionClass']);decisionAutonomy(a.decisionClass);
const latest=this.#store.get("SELECT seq FROM events WHERE business=? AND kind='action.allowed' AND json_extract(body,'$.operation')='autonomy.set' ORDER BY seq DESC LIMIT 1",s.business);
this.#store.transaction(()=>this.#event(s,'action.allowed',{operation:'autonomy.review',decisionClass:a.decisionClass,settingSeq:latest?.seq??null,subject:s.console},null));
return {reviewed:true};
}
if(verb==='autonomy.set.console') {
keys(a,['revision','autonomy'],['revision','autonomy']);
const AUTONOMY_CLASSES=['cross-role','merge','policy','authority'];
checkAutonomy(a.autonomy);
if(!this.#autonomyHost?.get || !this.#autonomyHost?.set)fail('not-configured');
const old=await this.#autonomyHost.get(s.business),b=this.#business(s.business);
if(a.revision!==old.revision)fail('revision-conflict');
if(AUTONOMY_CLASSES.some(cls=>decisionAutonomy(cls,a.autonomy)==='near the loop') && a.autonomy.notices!==true)fail('autonomy-precondition');
for(const cls of AUTONOMY_CLASSES) {
const before=decisionAutonomy(cls,old.autonomy),after=decisionAutonomy(cls,a.autonomy);
// Pulling back is unconditional. Only a move away from the loop has prerequisites.
// Lead ruling: policy has no effect executor. Its existing out and in levels
// remain usable; near refuses in checkAutonomy until an inverse exists.
if(cls==='policy')continue;
if(cls==='authority' && after==='out of the loop' && before!==after)fail('authority-limits-unavailable');
if(['in the loop','near the loop','out of the loop'].indexOf(after)<=['in the loop','near the loop','out of the loop'].indexOf(before))continue;
if(a.autonomy.notices!==true || !this.#autonomyHost.reversible?.(cls,b))fail('autonomy-precondition');
const resolvers=cls==='authority'?[a.autonomy.authorityResolver]:cls==='merge'?[b.arbiters.technical]:cls==='policy'?[b.arbiters.delivery]:Object.values(b.arbiters);
for(const name of resolvers) {
const role=b.roles[name],right=cls==='authority'?'decision.resolve.authority':`decision.resolve.${cls==='merge'?'technical':cls==='policy'?'delivery':name===b.arbiters.technical?'technical':'delivery'}`;
if(!role?.authority?.withinRole.includes(right) || (cls==='authority' && name===b.launch?.by))fail('autonomy-precondition');
}
if(['merge','policy'].includes(cls) && !Object.values(b.roles).some(r=>r.authority?.withinRole.includes('review.verdict')))fail('autonomy-precondition');
if(cls==='authority' && !this.#budgetCovered(b))fail('autonomy-precondition');
if(after==='out of the loop') {
if(before!=='near the loop')fail('autonomy-precondition');
const review=this.#store.get("SELECT seq,body FROM events WHERE business=? AND kind='action.allowed' AND json_extract(body,'$.operation')='autonomy.review' AND json_extract(body,'$.decisionClass')=? ORDER BY seq DESC LIMIT 1",s.business,cls);
const latest=this.#store.get("SELECT seq FROM events WHERE business=? AND kind='action.allowed' AND json_extract(body,'$.operation')='autonomy.set' ORDER BY seq DESC LIMIT 1",s.business);
if(!review || JSON.parse(review.body).settingSeq!==latest?.seq)fail('autonomy-precondition');
const notice=this.#store.get("SELECT seq FROM events WHERE business=? AND kind='action.allowed' AND json_extract(body,'$.operation')='autonomy.notice' AND json_extract(body,'$.decisionClass')=? AND seq>? ORDER BY seq DESC LIMIT 1",s.business,cls,latest?.seq??0);
const reversal=this.#store.get("SELECT max(e.seq) seq FROM events e WHERE e.business=? AND e.kind='action.allowed' AND json_extract(e.body,'$.operation') LIKE 'autonomy.reverse%' AND EXISTS(SELECT 1 FROM events n WHERE n.business=e.business AND n.kind='action.allowed' AND json_extract(n.body,'$.operation')='autonomy.notice' AND json_extract(n.body,'$.decisionClass')=? AND json_extract(n.body,'$.decision')=json_extract(e.body,'$.decision'))",s.business,cls);
if(!notice || review.seq<=Math.max(notice.seq,reversal?.seq??0))fail('autonomy-precondition');
}
}
this.#store.transaction(()=>this.#event(s,'action.allowed',{operation:'autonomy.set.requested',autonomy:a.autonomy,subject:s.console},null));
const result=await this.#autonomyHost.set(s.business,a,s.human);
this.#autonomyOwner(s);
this.#store.transaction(()=>this.#event(s,'action.allowed',{operation:'autonomy.set',autonomy:a.autonomy,subject:s.console,transaction:result.transaction},null));
return result;
}
keys(a,['id'],['id']);identifier(a.id);
const d=this.#decision(s,a.id),notice=autonomyEvent(this.#store,s.business,d.id,['autonomy.notice']);
const windowOpen=()=>{
if(!notice || Date.parse(notice.body.reverseUntil)<=Math.max(Date.now(),this.#clock))throw new BusError('reverse-window-closed','The reverse window has closed; raise an ordinary new decision for this change.');
};
windowOpen();
if(autonomyEvent(this.#store,s.business,d.id,['autonomy.reverse.consume']))fail('decision-reversed');
const before=autonomyEvent(this.#store,s.business,d.id,['autonomy.before']),after=autonomyEvent(this.#store,s.business,d.id,['autonomy.after']);
if(!before && !this.#autonomyHost?.reverse)fail('reversal-unavailable');
this.#store.transaction(()=>{
windowOpen();
this.#event(s,'action.allowed',{operation:'autonomy.reverse.consume',decision:d.id,subject:s.console},d.id);
});
let committedResult=null;
try {
if(before && after) return this.#store.transaction(()=>{
windowOpen();
const current=this.#storeCurrent(s.business,before.body.target);
if(current.digest!==after.body.digest)fail('decision-stale');
const result=this.#storeWrite(s,{current,op:'change',verb:'tasks.edit',fields:before.body.fields,by:{role:null,run:null,human:s.human},kind:'human.input',body:{kind:'autonomy.reverse',decision:d.id}});
this.#event(s,'action.allowed',{operation:'autonomy.reverse',decision:d.id,revision:result.revision},d.id);
return {id:d.id,reversed:true,task_ref:result.task_ref};
});
const effects=this.#store.all("SELECT body FROM events WHERE business=? AND kind='action.allowed' AND json_extract(body,'$.decision')=? ORDER BY seq",s.business,d.id).map(e=>JSON.parse(e.body));
let recorded=false;
const result=await this.#autonomyHost.reverse({business:s.business,decision:d.id,decisionClass:autonomyClass(d.action),action:d.action,raised:this.#raised(s.business,d.id),effects,actor:s.human,
beforeWrite:()=>{this.#autonomyOwner(s);windowOpen();},beforePublish:()=>{this.#autonomyOwner(s);windowOpen();},fire:proposal=>this.#fireApproved(s,proposal,()=>{this.#autonomyOwner(s);windowOpen();},true),stop:proposal=>this.#stopCopyApproved(s,proposal),complete:(proposal,result)=>this.#store.transaction(()=>this.#event(s,'action.allowed',{action:'fire.done',decision:d.id,transaction:result.transaction??null},proposal.level==='project'?`${proposal.name}.${proposal.project}`:proposal.name)),relaunch:proposal=>{
this.#autonomyOwner(s);
const instance=proposal.level==='project'?`${proposal.name}.${proposal.project}`:proposal.name;
this.#mayHuman(s,'instance.launch',instance);this.#launchable(s,instance,'not-thread-launch');
return this.#store.transaction(()=>({request:this.#lifecycleRecord(s,'lifecycle.launch',instance,{decision:d.id})}));
},record:result=>this.#store.transaction(()=>{if(!(autonomyClass(d.action)==='authority' && result.committed===true))windowOpen();recorded=true;this.#event(s,'action.allowed',{operation:'autonomy.reverse',decision:d.id,result},d.id);})});
if(autonomyClass(d.action)==='authority' && result.committed===true)committedResult=result;
if(!recorded)this.#store.transaction(()=>{if(!committedResult)windowOpen();this.#event(s,'action.allowed',{operation:'autonomy.reverse',decision:d.id,result},d.id);});
return {id:d.id,reversed:true,...result};
} catch(error) {
if(committedResult)return {id:d.id,reversed:true,...committedResult,trailPending:true};
this.#store.transaction(()=>this.#event(s,'action.allowed',{operation:'autonomy.reverse.failed',decision:d.id,code:error.code??'outcome-unknown'},d.id));
throw error;
}
}
#hireCaller(s) {
if (!s.console || !s.human || s.reader || s.tester) fail('human-required');
if(s.hireMembership) return s.hireMembership;
if(s.human!==this.#business(s.business).human)fail('human-required');
return {actor:s.human,agent:false,rights:['org.hire'],owner:true,instanceOwner:true,subject:s.console};
}
async #raiseHire(s, input, immediate) {
const b=this.#business(s.business);
// Change is a replacement composition and uses the existing role.hire grant.
const chiefAllowed=()=>{
this.#agent(s);
if(s.role!==this.#business(s.business).launch?.by)fail('role-not-chief');
if(!['hire','change','fire'].includes(input?.op))fail('invalid-request');
if(this.#classification(s,input.op==='fire'?'role.fire':'role.hire')!=='within-role')fail('not-authorized');
};
// #1873 CB1: a copy credential binding is owner-only. A person raising one in one step must be
// the organization's owner; a member with org.hire is not.
if(immediate) { const c=this.#hireCaller(s); if(!c.rights.includes('org.hire'))fail('right-required'); if(input?.binding!==undefined && c.owner!==true)fail('human-required'); }
else chiefAllowed();
if(!this.#hires)fail('not-configured');
const did=randomUUID(), at=this.#now(), expiresAt=new Date(Date.parse(at)+7*86400000).toISOString();
const p=await this.#hires.prepare(s.business,structuredClone(input),{id:did,expiresAt});
// Recheck the chief after asynchronous preparation; a released run cannot raise.
if(!immediate)chiefAllowed();
else {
const caller=this.#hireCaller(s);
if(p.binding && caller.owner!==true)fail('human-required');
if(!p.binding && (((p.flagged.length || p.credentialLines.length) && !caller.owner) || (p.level==='root' && !caller.instanceOwner)))fail('hire-owner-required');
}
const action=`role.${p.op}`;
const level=decisionAutonomy('authority',b.autonomy);
let route='human';
// A binding always goes to the owner; no autonomy level moves it (design section 4).
if(!immediate && !p.binding && level!=='in the loop' && p.level!=='root' && !p.flagged.length && !p.credentialLines.length) {
if(level==='out of the loop')fail('authority-limits-unavailable');
if(!this.#budgetCovered(this.#business(s.business)))fail('autonomy-precondition');
route=b.autonomy?.authorityResolver;
if(!b.roles[route]?.authority?.withinRole.includes('decision.resolve.authority'))fail('autonomy-precondition');
if(route===s.role || resolverSubject(b,route,p.name))route='human';
}
this.#store.transaction(()=>{
const raiser=immediate?'human':s.role;
this.#store.run('INSERT INTO decisions(id,at,business,project,raised_by_role,raised_by_run,class,action,route_to,question,options,recommendation,blocking) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)',
did,at,s.business,p.project??null,raiser,immediate?'human-console':s.run,'gated',action,route,p.binding?`${p.binding.after===null?'unbind':'bind'} credentials for copy ${p.binding.instance}?`:`${p.op} ${p.name} at ${p.level} level?`,JSON.stringify([{key:'yes',text:'Approve'},{key:'no',text:'Deny'}]),'no',0);
this.#event(s,'action.allowed',{operation:'decision.raise',decision:did,action,approvalChoice:'yes',target:p.name,hire:p,autonomyLevel:level},did);
});
if(immediate) return this.#hireConsole(s,'hire.resolve.console',{id:did,choice:'yes',digest:p.digest});
return {...this.#decisionView(this.#decision(s,did)),hire:p};
}
// The harness fact comes from the host's loaded modules; no host, no account coverage.
#budgetCovered(b) { return authorityBudgetCovered(b,{countsAccount:this.#autonomyHost?.countsAccount}); }
// Trusted runtime inverse only; never a socket verb.
hireReverse(...args) {
if(!this.#hires?.reverse)fail('reversal-unavailable');
return this.#hires.reverse(...args);
}
async #resolveAuthority(s,a) {
keys(a,['id','choice'],['id','choice']);identifier(a.id);
const d=this.#decision(s,a.id),p=this.#raised(s.business,d.id)?.hire;
const validate=()=>{
this.#agent(s);
if(d.route_to!==s.role || d.raised_by_role===s.role || resolverSubject(this.#business(s.business),s.role,p?.name))fail('not-resolver');
if(p?.binding)fail('human-required');
if(!p || p.level==='root' || p.flagged.length || p.credentialLines.length)fail('hire-owner-required');
if(this.#closed(d.id))fail('decision-closed');
if(this.#expireHire(s,d,p))fail('decision-expired');
if(this.#autonomyLevel(d)==='in the loop')fail('hire-resolution-required');
if(this.#autonomyLevel(d)==='out of the loop')fail('authority-limits-unavailable');
if(!this.#budgetCovered(this.#business(s.business)))fail('autonomy-precondition');
if(!this.#business(s.business).roles[s.role]?.authority?.withinRole.includes('decision.resolve.authority'))fail('not-resolver');
};
validate();
if(!['yes','no'].includes(a.choice))fail('invalid-choice');
if(!this.#hires)fail('not-configured');
const caller={agent:true,authorityDecision:d.id,resolver:s.role,actor:s.role,rights:['decision.resolve.authority'],digest:p.digest,now:()=>Math.max(Date.now(),this.#clock),beforeWrite:validate};
if(a.choice==='yes')await this.#hires.check(s.business,d.id,caller);
validate();
this.#store.transaction(()=>{
validate();
this.#store.run('INSERT INTO decision_events(decision,at,op,by,choice,via) VALUES(?,?,?,?,?,?)',d.id,this.#now(),'resolved',s.run,a.choice,'broker');
if(a.choice==='yes')this.#event(s,'action.allowed',{operation:'hire.consume',decision:d.id,digest:p.digest,hire:p},d.id);
this.#autonomyNotice(s,d,this.#closed(d.id));
});
if(a.choice==='no')return {id:d.id,op:'resolved',choice:a.choice};
// No caller-provided human identity: the trusted writer receives the resolving instance.
const recheck=()=>{
this.#agent(s);
if(this.#autonomyLevel(d)==='out of the loop')fail('authority-limits-unavailable');
if(!this.#budgetCovered(this.#business(s.business)))fail('autonomy-precondition');
if(this.#autonomyLevel(d)==='in the loop' || !this.#business(s.business).roles[s.role]?.authority?.withinRole.includes('decision.resolve.authority') || resolverSubject(this.#business(s.business),s.role,p.name))fail('not-resolver');
};
recheck();
try {
if(p.op==='fire' && p.level!=='org')await this.#stopCopyApproved(s,p);
recheck();
const result=p.op==='fire' && p.level==='org'?await this.#fireApproved(s,p,recheck):await this.#hires.apply(s.business,d.id,{...caller,beforeWrite:recheck,beforePublish:recheck});
this.#store.transaction(()=>this.#event(s,'action.allowed',{operation:'hire.apply',decision:d.id,transaction:result.transaction??null,revision:result.revision??null},d.id));
return {...result,id:d.id,op:'resolved',choice:a.choice};
} catch(error) {
this.#store.transaction(()=>this.#event(s,'action.allowed',{operation:'hire.failed',decision:d.id,code:error.code??'outcome-unknown'},d.id));throw error;
}
}
async #hireConsole(s,verb,a) {
const caller={...this.#hireCaller(s),now:()=>Math.max(Date.now(),this.#clock)};
keys(a,verb==='hire.preview.console'?['id','adjust']:['id','choice','digest','adjust'],verb==='hire.preview.console'?['id']:['id','choice','digest']);
identifier(a.id);
const d=this.#decision(s,a.id), raise=this.#raised(s.business,d.id);
let p=raise?.hire;
if(!['role.hire','role.fire','role.change'].includes(d.action) || !p)fail('decision-not-eligible');
const closed=this.#closed(d.id);
if(closed)fail(closed.by==='expiry'?'decision-expired':'decision-closed');
if(this.#expireHire(s,d,p))fail('decision-expired');
if(!caller.rights.includes('org.hire'))fail('right-required');
if(p.binding && caller.owner!==true)fail('human-required');
if(a.adjust!==undefined) {
if(!this.#hires?.preview)fail('not-configured');
p=await this.#hires.preview(s.business,d.id,{...caller,adjust:a.adjust});
}
if((p.flagged.length || p.credentialLines.length) && !caller.owner || p.level==='root' && !caller.instanceOwner)fail('hire-owner-required');
if(verb==='hire.preview.console')return {id:d.id,business:s.business,question:d.question,kind:'hire',options:JSON.parse(d.options),...p};
if(a.digest!==p.digest)fail('decision-stale');
if(a.choice==='yes') { if(!this.#hires)fail('not-configured'); await this.#hires.check(s.business,d.id,{...caller,digest:p.digest,adjust:a.adjust}); }
if(!['yes','no'].includes(a.choice))fail('invalid-choice');
// A durable close/consume precedes the external file effect. A crash never replays an approval.
this.#store.transaction(()=>{
if(this.#closed(d.id))fail('decision-closed');
this.#store.run('INSERT INTO decision_events(decision,at,op,by,choice,via) VALUES(?,?,?,?,?,?)',d.id,this.#now(),'resolved',caller.actor,a.choice,'console');
this.#event(s,'human.input',{kind:'answer',decision:d.id,op:'resolved',choice:a.choice,via:'console',subject:s.console},d.id);
if(a.choice==='yes')this.#event(s,'action.allowed',{operation:'hire.consume',decision:d.id,digest:p.digest,hire:p},d.id);
});
if(a.choice==='no')return {id:d.id,op:'resolved',choice:a.choice};
if(!this.#hires)fail('not-configured');
try {
if(p.op==='fire' && p.level!=='org')await this.#stopCopyApproved(s,p);
const result=p.op==='fire' && p.level==='org' ? await this.#fireApproved(s,p) : await this.#hires.apply(s.business,d.id,{...caller,digest:p.digest,adjust:a.adjust});
if(p.op==='fire' && p.level!=='org')this.#store.transaction(()=>this.#event(s,'action.allowed',{action:'fire.done',decision:d.id},p.level==='project'?`${p.name}.${p.project}`:p.name));
this.#store.transaction(()=>this.#event(s,'action.allowed',{operation:result.done===false?'hire.started':'hire.apply',decision:d.id,transaction:result.transaction??null},d.id));
return {...result,id:d.id,op:'resolved',choice:a.choice,transaction:result.transaction??null};
} catch(e) {
this.#store.transaction(()=>this.#event(s,'action.allowed',{operation:'hire.failed',decision:d.id,code:e.code??'outcome-unknown'},d.id));
throw e;
}
}
#expireHire(s,d,p,insideTransaction=false) {
if(!p || Date.parse(p.expiresAt)>Math.max(Date.now(),this.#clock) || !Number.isFinite(Date.parse(p.expiresAt)))return false;
const close=()=>{
if(this.#closed(d.id))return;
this.#store.run('INSERT INTO decision_events(decision,at,op,by,via) VALUES(?,?,?,?,?)',d.id,this.#now(),'withdrawn','expiry','broker');
this.#event({business:s.business,role:null,run:null},'action.allowed',{operation:'hire.expired',decision:d.id},d.id);
};
if(insideTransaction)close();else this.#store.transaction(close);
return true;
}
#expireHires(s) {
for(const d of this.#store.all(`SELECT d.* FROM decisions d WHERE business=? AND action IN ('role.hire','role.fire','role.change') AND NOT EXISTS(SELECT 1 FROM decision_events x WHERE x.decision=d.id AND x.op IN ${CLOSED})`,s.business))
this.#expireHire(s,d,this.#raised(s.business,d.id)?.hire,true);
}
async #stopCopyApproved(s,p) {
const instance=p.level==='project'?`${p.name}.${p.project}`:p.name;
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,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 {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');
if (b.launch?.by === args.name) fail('organization-instance-referenced');
@@ -6354,8 +6749,9 @@ export class Broker {
// A non-empty set: step G, B and C run as a job, and the verb answers now with no transaction.
if (stopped.job) {
const { job, set, ...rest } = stopped;
const sc = { business: s.business, role: s.role, run: s.run, thread: s.thread, human: null };
this.#startGrantJob(s.business, args.name, verb, () => this.#fireJob(sc, args, authority, write, job, set));
const sc = { business: s.business, role: s.role, run: s.run, thread: s.thread, human: s.human };
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.
@@ -6363,41 +6759,13 @@ export class Broker {
// Step C: log the fire and record it done. A failure at B or C leaves I firing until a repeat.
const finish = result => {
this.#store.transaction(() => {
this.#consumeAuthority(s, action, authority, { reason: args.reason, transaction: result.transaction ?? null });
this.#event(s,'action.allowed',{action:'role.fire',operation:'hire.apply',decision:p.id,transaction:result.transaction??null},args.name);
this.#event(s, 'action.allowed', { action: 'fire.done', transaction: result.transaction ?? null }, args.name);
});
return { ...result, ...stopped };
};
return result?.then ? result.then(finish) : finish(result);
}
// The shape of hire and fire arguments.
#hireFireArgs(action, args) {
identifier(args.name);
// FED R3 r4 (sage-2557): `user` is the granted user's principal, as checkBusiness refuses it.
if (args.name === 'human' || args.name === 'tester' || args.name === 'user') fail('invalid-request');
string(args.reason, 500);
if (action === 'role.fire') return;
identifier(args.definition);
if ('vars' in args && (!object(args.vars) || !Object.keys(args.vars).every(HIRE_VAR))) fail('invalid-request');
}
// D2.3 at hire, against the role files as they stand: a version 2 definition whose every action is
// on HIRE_ALLOWED (W-N2), named in the chief role file's hireable list and not in launch.noHire.
#hireable(s, definition) {
if (!this.#roleFile) fail('not-configured');
const b = this.#business(s.business);
const load = (name) => {
try {
return this.#roleFile(name);
} catch (e) {
if (e.name === 'BusinessError') fail('definition-not-hireable');
throw e;
}
};
const def = load(definition);
if (def.roleVersion !== 2 || unhireableAction(def) !== null) fail('definition-not-hireable');
if (!(load(b.roles[s.role].definition).hireable ?? []).includes(definition)) fail('definition-not-hireable');
if ((b.launch?.noHire ?? []).includes(definition)) fail('definition-not-hireable');
}
// The bus code for an organization writer's refusal. Anything the writer didn't refuse by name is
// outcome-unknown: the write may have committed.
#organizationFail(e, verb) {
@@ -6413,6 +6781,8 @@ export class Broker {
if ((verb === "instance.hire" || verb === "instance.fire") && e.code === "definition-not-hireable") fail(e.code);
// Row 194 (B7, webby-0094 N3): the limits writer's keep check, against the file, as well.
if (e.code === "instance-kept") fail(e.code);
// #1873 T1: the definition writer's refusals pass by code; a composition refusal stays invalid-request.
if (Object.hasOwn(DEFINITION_BUS_KEYS, verb) && /^definition-[a-z-]+$/.test(e.code ?? "")) fail(e.code);
// Row 194 (brief item 8): a fire's or a delete's revoke failed, and nothing was written.
if ((verb === "instance.fire" || verb === "instances.set") && e.code === "credential-revoke-failed") fail(e.code);
// Row 65: the route checks every write of an instance's route vars runs.
@@ -7014,12 +7384,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, []);
@@ -7166,14 +7563,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');
@@ -7249,6 +7647,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,
@@ -7296,6 +7695,7 @@ export class Broker {
identifier(a.id);
if (a.note !== undefined) string(a.note);
const d = this.#decision(s, a.id);
if (['role.hire','role.fire','role.change'].includes(d.action) && ['decision.resolve','decision.resolve.console'].includes(verb)) fail('hire-resolution-required');
if (this.#closed(d.id)) fail('decision-closed');
// Recheck eligibility, context digest and membership in the current options in this
// transaction, before any resolution, audit, notification or grant execution.
@@ -7313,13 +7713,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(?,?,?,?,?,?,?)',
@@ -7332,6 +7740,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)
@@ -7755,19 +8164,20 @@ export class Broker {
const allowed = verb === 'trail' ? ['subject'] : verb === 'tasks' ? TASK_ARGS : [];
keys(a, allowed, verb === 'trail' ? ['subject'] : []);
if (!s.human && !s.reader) this.#agent(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')this.#expireHires(s);
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",
@@ -7800,6 +8210,7 @@ export class Broker {
case 'decisions': {
if (!s.human && !s.reader) fail('human-required');
keys(a, ['list'], ['list']);
this.#expireHires(s);
if (a.list === 'roles')
return this.#store
.all(
+233
View File
@@ -0,0 +1,233 @@
// #1873 CB2 (design section 5 steps 4 to 6): credentials.issue for an agent copy's bound service.
// Gitea is the only service with a verifier in this slice. A copy token is accepted only after the
// Gitea server it belongs to proves the whole grant live: the token's scopes, the account it belongs
// to (the copy's own dedicated account, never an administrator or another copy's or agent's), every
// repository that account can reach and the token's expiry, against the owner-approved binding. A
// Vikunja binding refuses credential-scope-unverified: that is the shipped behaviour.
//
// Order (no lock is held across the remote work):
// 1. checks that need nothing written: owner, service, date, token form, definition scan, the
// record and the boot snapshot, the bound projects' remotes and the approved scopes;
// 2. stage: the writer deactivates the record (pending, the service's grant fields gone), then the
// token goes to the copy's slot in the self-only store;
// 3. the slot is read back by the identity the stage wrote, and Gitea is asked;
// 4. commit: the writer rechecks registry incarnation, document revision, binding generation,
// copyId, decision and definition pin under its locks; its beforeWrite reads the slot back by
// identity again and runs the caller's session recheck. Only then do the grant metadata and the
// receipt land.
// Any refusal after the stage removes the slot it wrote and leaves the record pending. A lost reply
// or a crash leaves the record pending too; nothing resolves a pending record, and recovery is a
// new verified issue. A token a crash left staged goes at the next start (sweepCopySlots). Nothing here logs, and no result, receipt or refusal holds the token, the
// slot's path, a request or anything derived from the token.
import { lstatSync, readdirSync, unlinkSync } from 'node:fs';
import { join } from 'node:path';
import { BusError } from './broker.mjs';
import { Credentials, assertNotHeld, day, privateDir } from './credentials.mjs';
import { send, versionAtLeast, REPO_PAGE, REPO_PAGES } from './gitea-grant.mjs';
import { scanOrganizations } from './definition-scan.mjs';
import { parseGiteaUrl, GITEA_SCOPE_CATEGORIES, ID_PATTERN, SERVICES } from '../../organization/src/vocabulary.mjs';
import { copyBotLogin, scopesWithin } from '../../organization/src/copy-bindings.mjs';
const deny = (code, reason) => {
throw new BusError(code, reason);
};
const unverified = (reason) => deny('credential-scope-unverified', reason);
const DAY_MS = 86400000;
// A copy credential's rotation date is after today and at most this many days ahead.
export const MAX_ROTATE_DAYS = 366;
const object = (x) => x !== null && typeof x === 'object' && !Array.isArray(x);
const positive = (n) => Number.isSafeInteger(n) && n > 0;
const lower = (s) => (typeof s === 'string' ? s.toLowerCase() : null);
// The scopes a Gitea token reports, as {category: level}. Gitea writes write:X as implying read:X and
// may list both; the higher level wins. "all", any admin category, a category Gitea added that this
// bus doesn't know, or anything that doesn't parse returns null: such a token is broader than any
// binding can approve.
export function tokenLevels(scopes) {
if (!Array.isArray(scopes) || scopes.length === 0) return null;
const out = {};
for (const scope of scopes) {
const m = typeof scope === 'string' ? /^(read|write):([a-z]+)$/.exec(scope) : null;
if (!m || !GITEA_SCOPE_CATEGORIES.includes(m[2])) return null;
if (out[m[2]] !== 'write') out[m[2]] = m[1];
}
return out;
}
const asList = (levels) => Object.entries(levels).map(([c, l]) => `${l}:${c}`).sort();
// The approved Gitea repositories of a copy's binding: one https origin, lower-cased "owner/repo"
// names, from each bound project's remote. A project with no Gitea remote, two remotes that disagree
// or two servers cannot be proved.
export function boundRepositories(doc, projects) {
let origin = null;
const repos = new Set();
for (const project of projects ?? []) {
const entry = doc.projects?.[project];
if (!object(entry)) unverified('remote');
const remotes = [entry.remote, entry.git?.mode === 'remote' ? entry.git.remote : undefined].filter((r) => r !== undefined);
if (remotes.length === 0 || remotes.some((r) => r !== remotes[0])) unverified('remote');
const parsed = parseGiteaUrl(remotes[0], { repo: true });
if (!parsed.url) unverified('remote');
if (origin !== null && parsed.url.origin !== origin) unverified('remote');
origin = parsed.url.origin;
repos.add(`${parsed.owner}/${parsed.repo}`.toLowerCase());
}
if (origin === null) unverified('remote');
return { origin, repos };
}
// Live introspection of one Gitea token against an approved binding. Returns { userId, tokenId,
// login } or throws the refusal. `approved` is the binding's Gitea scopes; `repos` the lower-cased
// names it may reach; `login` the account this copy's grant must belong to; `rotateBy` the date the
// owner gave. Every call is GET with the token; a missing or ambiguous answer is unverified.
export async function verifyGiteaToken({ transport = send, url, ca, timeoutMs, token, approved, repos, login, rotateBy }) {
const call = (path) => transport({ url, method: 'GET', path, auth: { token }, ca, timeoutMs });
const version = await call('/api/v1/version');
if (version.status !== 200 || !object(version.json) || !versionAtLeast(version.json.version)) unverified('version');
const info = await call('/api/v1/token');
if (info.status === 401 || info.status === 403 || info.status === 404) unverified('token');
if (info.status !== 200 || !object(info.json) || !positive(info.json.id) || !object(info.json.user) || !positive(info.json.user.id)
|| typeof info.json.user.login !== 'string') unverified('token');
if (lower(info.json.user.login) !== lower(login)) deny('credential-identity-refused', login);
const levels = tokenLevels(info.json.scopes);
if (!levels || !scopesWithin('gitea', asList(levels), approved)) deny('credential-scope-exceeded');
if (!levels.user) unverified('scopes');
// An expiry Gitea reports must not come before the owner's rotation date ends.
if (info.json.expires_at !== undefined && info.json.expires_at !== null) {
const at = typeof info.json.expires_at === 'string' ? Date.parse(info.json.expires_at) : NaN;
if (Number.isNaN(at)) unverified('token');
if (at < Date.parse(rotateBy + 'T00:00:00Z') + DAY_MS) deny('credential-date');
}
const user = await call('/api/v1/user');
if (user.status !== 200 || !object(user.json) || typeof user.json.is_admin !== 'boolean') unverified('user');
if (user.json.id !== info.json.user.id || lower(user.json.login) !== lower(login) || user.json.is_admin !== false)
deny('credential-identity-refused', login);
// Every repository the account can reach: owned, collaborator and team access. One unrelated
// repository, an admin permission, or push where the binding approved read refuses. Push is the
// account's right, so it is held to what the binding approved, not to the token's own level.
const write = tokenLevels(approved)?.repository === 'write';
const seen = new Map();
let total = null;
for (let page = 1; ; page++) {
if (page > REPO_PAGES) unverified('repositories');
const r = await call(`/api/v1/user/repos?limit=${REPO_PAGE}&page=${page}`);
if (r.status !== 200 || !Array.isArray(r.json) || r.total === null || (total !== null && r.total !== total)) unverified('repositories');
total = r.total;
if (total > REPO_PAGE * REPO_PAGES) unverified('repositories');
for (const repo of r.json) {
if (!object(repo) || typeof repo.full_name !== 'string' || !object(repo.permissions)) unverified('repositories');
const name = repo.full_name.toLowerCase();
if (!repos.has(name)) deny('credential-scope-exceeded');
const p = repo.permissions;
if (p.admin !== false || typeof p.push !== 'boolean' || p.pull !== true || (p.push && !write)) deny('credential-scope-exceeded');
seen.set(name, true);
}
if (seen.size >= total || r.json.length === 0) break;
}
if (seen.size !== total) unverified('repositories');
for (const name of repos) if (!seen.has(name)) unverified('repositories');
// An organization scope reaches organizations' own settings: the account must belong to none.
if (levels.organization) {
const orgs = await call(`/api/v1/user/orgs?limit=${REPO_PAGE}&page=1`);
if (orgs.status !== 200 || !Array.isArray(orgs.json)) unverified('organizations');
if (orgs.json.length) deny('credential-scope-exceeded');
}
return { userId: info.json.user.id, tokenId: info.json.id, login };
}
// The issue for one copy's service. `copies` is the bus's boot snapshot ({ org, copies,
// definitions }); `writer` the organization writer; `credentials` the bus's store. `hooks` are the
// tests' crash points (afterStage, afterRemote, beforeCommit), never set at runtime.
export function copyGrant({ credentials, writer, copies = null, organizations = null, transport = send, ca = undefined, timeoutMs = undefined, now = () => new Date(), hooks = {} }) {
return async (business, args, { actor, owner = false, recheck = () => {} } = {}) => {
const { agent: instance, service, token, date } = args;
if (owner !== true || actor === 'tester') deny('human-required');
if (service === 'vikunja') unverified('service');
if (service !== 'gitea') deny('credential-unknown');
const today = now().toISOString().slice(0, 10);
if (!day(date) || date <= today || Date.parse(date) - Date.parse(today) > MAX_ROTATE_DAYS * DAY_MS) deny('credential-date');
const value = Credentials.checkFormat(service, token);
const { document: doc, revision } = writer.read(business);
scanOrganizations({ organizations: organizations?.() ?? [business], own: { id: business, document: doc }, readRaw: (b) => writer.readRaw(b),
boot: copies?.definitions ? { org: copies.org, definitions: copies.definitions } : null }, (v) => assertNotHeld(v, [value]));
const record = doc.copyBindings?.[instance];
if (!object(record) || record.state === 'unbound' || !object(record.services?.[service])) deny('credential-unbound');
const copy = copies?.org === business && Object.hasOwn(copies.copies ?? {}, instance) ? copies.copies[instance] : null;
if (!copy || copy.state === 'refused' || Object.hasOwn(doc.roles ?? {}, instance)) deny('copy-unavailable');
if (copy.level !== record.source?.level || copy.name !== record.source?.name || (copy.project ?? null) !== (record.source?.project ?? null)
|| copy.definition !== record.definition?.ref) deny('credential-binding-changed');
const approved = record.services[service];
if (!Array.isArray(approved.scopes) || !approved.scopes.some((s) => /^(read|write):user$/.test(s))) unverified('scopes');
const { origin, repos } = boundRepositories(doc, approved.resources?.projects);
const login = copyBotLogin(record.orgIncarnation, record.copyId, service);
if (!login) deny('credential-binding-changed');
const op = { instance, service, copyId: record.copyId, generation: record.generation, decision: record.decision, incarnation: record.orgIncarnation };
const definitions = copies?.org === business ? copies.definitions ?? {} : {};
const staged = writer.stageCopyGrant(business, op, { revision, actor, owner, definitions });
const slot = business + '/' + instance;
let identity = null;
try {
identity = credentials.stageCopy(slot, service, value.token);
await hooks.afterStage?.();
// An inode number can be reused after an unlink, so the read-back must also be the staged token.
const read = credentials.readCopy(slot, service, identity);
if (read.token !== value.token) deny('credential-file');
const grant = await verifyGiteaToken({ transport, url: origin, ca, timeoutMs, token: read.token, approved: approved.scopes, repos, login, rotateBy: date });
await hooks.afterRemote?.();
await recheck();
const grantId = `gitea:${grant.userId}:${grant.tokenId}`;
const after = { ...op, generation: staged.generation };
const committed = writer.commitCopyGrant(business, after, {
grantId, rotateBy: date, revision: staged.revision, actor, owner, definitions,
beforeWrite: () => {
hooks.beforeCommit?.();
const again = credentials.readCopy(slot, service, identity);
if (again.token !== read.token) deny('credential-file');
recheck();
},
});
return { agent: instance, service, verb: 'issue', date, state: committed.state, account: login, generation: committed.generation };
} catch (e) {
if (identity) {
try {
credentials.unlinkCopy(slot, service, identity);
} catch {}
}
throw e;
}
};
}
// Security review P2: at start, before Credentials loads and before any issue can run, each staged
// file of a copy's service whose binding holds no grant for that service goes, and so does any
// interrupted write's temp file. An issue killed after its stage leaves the record pending and the
// token on disk; nothing resolves a pending record and no reference loads a copy slot, so the file
// only keeps a secret nothing will use. `documents` is [business, validated organization document]
// for each loaded business (null when unreadable: nothing of it is touched). Only names the document
// binds as copies are looked at, never a name its own roles hold or a slot a loaded reference names.
// A store dir that isn't private refuses credential-file, as Credentials would. Returns
// [{ instance, service }], never a path.
export function sweepCopySlots(storeRoot, documents, references = {}) {
const removed = [];
if (!storeRoot || !privateDir(storeRoot, { missing: true })) return removed;
for (const [business, doc] of documents) {
if (!object(doc) || !object(doc.copyBindings) || !ID_PATTERN.test(business)) continue;
const dir = join(storeRoot, business);
if (!privateDir(dir, { missing: true })) continue;
for (const [copy, record] of Object.entries(doc.copyBindings)) {
if (!ID_PATTERN.test(copy) || Object.hasOwn(doc.roles ?? {}, copy)) continue;
const copyDir = join(dir, copy);
if (!privateDir(copyDir, { missing: true })) continue;
const instance = business + '/' + copy;
for (const name of readdirSync(copyDir)) {
const service = name.endsWith('.tmp') ? name.slice(0, -4) : name;
if (!SERVICES.includes(service) || references[instance]?.[service] !== undefined) continue;
if (name === service && typeof record?.services?.[service]?.grantId === 'string') continue;
if (lstatSync(join(copyDir, name)).isDirectory()) continue;
unlinkSync(join(copyDir, name));
removed.push({ instance, service });
}
}
}
return removed;
}
+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 } : {};
+120 -25
View File
@@ -6,7 +6,7 @@ import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
import { createHash } from 'node:crypto';
import { isDeepStrictEqual } from 'node:util';
import { BusError } from './broker.mjs';
import { credentialKind, isHostKind, ID_PATTERN } from '../../organization/src/vocabulary.mjs';
import { credentialKind, isHostKind, ID_PATTERN, SERVICES } from '../../organization/src/vocabulary.mjs';
const deny = (code) => {
throw new BusError(code);
};
@@ -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.
export 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,11 +471,26 @@ 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);
this.#writeSlot(instance, service, service === 'model' ? JSON.stringify(value.model) + '\n' : value.key ? value.token : value.token + '\n');
const file = this.#storePath(instance, service);
commit();
this.#refs.set(instance + ':' + service, { ref: { service, store: true, file, [service === 'vikunja' ? 'expires' : 'rotateBy']: date }, date });
this.#tokens.set(instance + ':' + service, { ...value, date });
this.#revoked.delete(instance + ':' + service);
this.#held.delete(instance + ':' + service);
return this.status().find((e) => e.instance === instance && e.service === service);
}
// The store slot write issue and stageCopy share: tmp (O_EXCL, O_NOFOLLOW, 0600), fsync, rename
// over the slot, fsync the dir. Returns the identity of the file it wrote.
#writeSlot(instance, service, text, { undo = false } = {}) {
const file = this.#storePath(instance, service);
const agentDir = dirname(file);
mkdirSync(this.#storeRoot, { recursive: true, mode: 0o700 });
@@ -477,27 +505,101 @@ export class Credentials {
} catch (e) {
if (e.code !== 'ENOENT') deny('credential-file');
}
let fd;
// A write that fails leaves no token behind: the temp file this call made goes, and with `undo`
// (stageCopy) so does the slot it renamed into place.
let fd, identity;
try {
fd = openSync(tmp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
writeSync(fd, service === 'model' ? JSON.stringify(value.model) + '\n' : value.key ? value.token : value.token + '\n');
fsyncSync(fd);
try {
fd = openSync(tmp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
const st = fstatSync(fd);
identity = { dev: st.dev, ino: st.ino };
writeSync(fd, text);
fsyncSync(fd);
} finally {
if (fd !== undefined) closeSync(fd);
}
renameSync(tmp, file);
} catch (e) {
if (identity) {
try {
const st = lstatSync(tmp);
if (st.isFile() && st.dev === identity.dev && st.ino === identity.ino) unlinkSync(tmp);
} catch {}
}
throw e;
}
try {
const dirFd = openSync(agentDir, constants.O_RDONLY);
try {
fsyncSync(dirFd);
} finally {
closeSync(dirFd);
}
} catch (e) {
if (undo) {
try {
this.unlinkCopy(instance, service, identity);
} catch {}
}
throw e;
}
return identity;
}
// #1873 CB2 (design section 5 step 4): a copy's service slot, <store>/<business>/<copy>/<service>.
// The bus stages it while the copy's binding is inactive, and afterwards reads back or removes only
// the file with the identity (dev, ino) it wrote. Memory and references never hold a copy's token
// in this slice; launch admission (CB3) reads the slot. A name a loaded reference uses refuses.
static checkFormat(service, token) {
return Credentials.#format(service, token);
}
stageCopy(instance, service, token) {
if (this.#refs.has(instance + ':' + service) || !SERVICES.includes(service)) deny('credential-reference');
const value = Credentials.#format(service, token);
return this.#writeSlot(instance, service, value.token + '\n', { undo: true });
}
// The staged token, read back with the store's checks plus one link only and the exact identity the
// stage wrote: a symlink, a hard link, a replaced or a stale file refuses credential-file.
readCopy(instance, service, identity) {
const file = this.#storePath(instance, service);
privateDir(this.#storeRoot);
privateDir(dirname(dirname(file)));
privateDir(dirname(file));
const same = (st) => st.dev === identity?.dev && st.ino === identity?.ino;
let original;
try {
original = lstatSync(file);
} catch {
deny('credential-file');
}
if (original.isSymbolicLink() || !original.isFile() || original.nlink !== 1 || !same(original)) deny('credential-file');
let fd, text;
try {
fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW);
const st = fstatSync(fd);
if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o777) !== 0o600 || st.size > 16384 || st.nlink !== 1 || !same(st))
deny('credential-file');
text = readFileSync(fd, 'utf8');
} catch (e) {
if (e instanceof BusError) throw e;
deny('credential-file');
} finally {
if (fd !== undefined) closeSync(fd);
}
renameSync(tmp, file);
const dirFd = openSync(agentDir, constants.O_RDONLY);
return Credentials.#format(service, text);
}
// Removes the slot only while it is still the file the stage wrote; another file is left alone.
unlinkCopy(instance, service, identity) {
const file = this.#storePath(instance, service);
let st;
try {
fsyncSync(dirFd);
} finally {
closeSync(dirFd);
st = lstatSync(file);
} catch (e) {
if (e.code === 'ENOENT') return false;
deny('credential-file');
}
commit();
this.#refs.set(instance + ':' + service, { ref: { service, store: true, file, [service === 'vikunja' ? 'expires' : 'rotateBy']: date }, date });
this.#tokens.set(instance + ':' + service, { ...value, date });
this.#revoked.delete(instance + ':' + service);
this.#held.delete(instance + ':' + service);
return this.status().find((e) => e.instance === instance && e.service === service);
if (!st.isFile() || st.dev !== identity?.dev || st.ino !== identity?.ino) return false;
unlinkSync(file);
return true;
}
// Row 236 (#436b section 5, settle forward): memory takes the store file as it is now, with the
// constructor's checks, and `date` becomes the entry's date. A held entry refuses.
@@ -600,14 +702,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) {
+14 -15
View File
@@ -198,12 +198,12 @@ export class ModelKeys {
// Host-only callbacks are the existing synchronous organization path.
hire(instance, check, publish) {
return this.#exclusive(async () => {
check(); // Fresh validation and captured revision AFTER controller queue.
await check(); // Fresh validation and captured revision AFTER controller queue.
const existed = this.hasRecord(instance);
try {
await this.#issue(instance);
check(); // Recheck captured revision AFTER HTTP, before publication.
return publish();
await check(); // Recheck captured revision AFTER HTTP, before publication.
return await publish();
} catch (error) {
// A newly issued generation has not escaped to queued admission yet.
if (!existed && this.hasRecord(instance)) {
@@ -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 {
@@ -347,7 +347,7 @@ export function modelOrganization({ modelKeys, writer }) {
};
const wrap = invoke => (business, verb, args, actor, context) => {
const isRevoke = verb === 'instance.fire' || (verb === 'instances.set' && args.op === 'delete');
if (!isRevoke && verb !== 'instance.hire') return invoke(business, verb, args, actor, context);
if (!isRevoke) return invoke(business, verb, args, actor, context);
return (async () => {
if (pending.has(business)) fail('organization-busy');
const agent = args.name ?? args.instance, instance = business + '/' + agent;
@@ -355,19 +355,11 @@ export function modelOrganization({ modelKeys, writer }) {
if (verb === 'instances.set') invoke(business, 'instances.check', { instance: agent }, actor, context);
const { revision } = writer.read(business);
if (args.revision !== undefined && args.revision !== revision) fail('revision-conflict');
const checkHire = () => {
if (writer.read(business).revision !== revision) fail('revision-conflict');
const checked = invoke(business, 'instance.hireCheck', args, actor, context);
if (checked.revision !== revision) fail('revision-conflict');
return checked;
};
if (!isRevoke) checkHire(); // No registration or proxy I/O before shared checks.
const prepare = { instance, revision, remoteRevoked: false };
pending.set(business, prepare);
const existed = modelKeys.hasRecord(instance);
try {
if (isRevoke) { await modelKeys.prepareRevoke(instance); prepare.remoteRevoked = existed; }
else return await modelKeys.hire(instance, checkHire, () => invoke(business, verb, args, actor, context));
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);
@@ -375,5 +367,12 @@ export function modelOrganization({ modelKeys, writer }) {
} finally { pending.delete(business); }
})();
};
return { writer: adapter, revoke, wrap };
const hire = async (business, agent, check, publish) => {
if (pending.has(business)) fail('organization-busy');
const prepare = { instance: `${business}/${agent}`, revision: writer.read(business).revision, remoteRevoked: false };
pending.set(business, prepare);
try { return await modelKeys.hire(prepare.instance, check, publish); }
finally { pending.delete(business); }
};
return { writer: adapter, revoke, wrap, hire };
}
+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),
);
}
+6 -3
View File
@@ -98,16 +98,19 @@ export function createProxyLock({ stateDir, identity, waitMs = 5000 }) {
fs.renameSync(temp, generationFile); syncDir(stateDir);
fs.unlinkSync(fenceFile); syncDir(stateDir);
}
async function clear({ restarted, read }) {
// `verify(previous)` (optional) re-proves a restart against the fence it would clear, inside
// the lock, and returns the evidence the receipt records.
async function clear({ restarted, read, verify }) {
if (restarted !== true) fail('proxy-restart-required');
return run(async () => {
const previous = status();
const evidence = verify ? await verify(previous) : undefined;
await read();
const fd = fs.openSync(receiptFile, 'a', 0o600);
try { fs.writeFileSync(fd, JSON.stringify({ op: 'fence.clear', time: new Date().toISOString(), previous }) + '\n'); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
try { fs.writeFileSync(fd, JSON.stringify({ op: 'fence.clear', time: new Date().toISOString(), previous, ...(evidence === undefined ? {} : { evidence }) }) + '\n'); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
syncDir(stateDir);
if (previous) confirm();
return { cleared: Boolean(previous) };
return { cleared: Boolean(previous), ...(evidence === undefined ? {} : { evidence }) };
}, { allowUncertain: true });
}
async function run(operation, { allowUncertain = false } = {}) {
+43
View File
@@ -1,6 +1,7 @@
// Static refusal table: each listed code maps to one rail and next step. These strings
// never interpolate caller arguments, credential references, policy values or file bytes.
const entries = [];
const group = (codes, rail, nextStep) => {
const guidance = Object.freeze({ rail, nextStep });
for (const code of codes.split(' ')) {
@@ -8,6 +9,26 @@ const group = (codes, rail, nextStep) => {
entries.push([code, guidance]);
}
};
group("authority-limits-unavailable", "R3/R12: future authority spend coverage", "Use authority near the loop with an enforced organization budget. Out of the loop waits for enforceable per-agent limits in every approvable definition; current-instance limits do not cover future hires.");
group("policy-reverse-unavailable", "R3/R12: policy inverse unavailable", "Choose policy in the loop or out of the loop. Near the loop waits for a policy effect executor and a tested reverse.");
group("invalid-autonomy autonomy-precondition reversal-unavailable", "R3/R12: validated owner autonomy and reversible effects", "Use only the four decision classes and three levels. Keep work in the loop until the class has a distinct resolver, tested inverse, notices, review and spend prerequisites; an unsupported effect needs an ordinary new decision. Authority near needs enabled limit checks and aggregate coverage of new agents. Moving out needs a near-loop resolution followed by the owner’s trail review. A failed inverse may have stopped an agent; inspect its writer receipt and use the normal launch or decision path.");
group("confirmation-required", "R5/R12: person confirms the arbiter resolution", "The arbiter remains the resolver. Confirm its pending resolution in the owner Console, or return it with a reason for a fresh resolution.");
group("decision-reversed reverse-window-closed", "R2/R12: one-use reverse within the owner's window", "Inspect the reversal trail. After the window, raise an ordinary new decision through the normal approval path; never replay the old approval.");
group("definition-shared hire-resolution-required hire-owner-required decision-expired right-required", "R5/R8/R9: digest-bound hire approval by a permitted person", "Use the hire Console path with org.hire. Flagged, credential and root decisions need their owner; expired decisions need a fresh proposal.");
group("root-fire-shared", "R3/R8: root fire must revoke every copy's grants", "Other organizations may retain running copies and grants. Ask the owner to coordinate revocation across those organizations before removing the root agent.");
// #1873 CB1: copy credential bindings (design sections 4 and 6). Approval is owner-only; nothing here
// names a token, a slot path or a file path.
group("organization-bindings-protected", "R1/R8: copy bindings change only through an owner-approved decision", "Ask the chief to raise a binding decision for the copy; only the organization owner approves it. No other write path changes copyBindings.");
group("credential-binding-changed credential-role-changed credential-definition-unpinned", "R1/R8: binding approval pinned to the current copy, definition and generation", "Read the copy's current binding and definition, then ask the chief to raise a fresh binding decision for the owner; an old approval never carries over.");
group("credential-scope-exceeded", "R1/R8: effective access is the role intersected with the approval", "Narrow the requested scopes and projects to what the copy's role and admitted projects allow, or change the role through its own decision first.");
group("credential-unbound", "R1/R8: launch needs an approved, verified binding", "Ask the chief to raise a binding decision for the missing service; the owner approves it. A pending binding is not usable until verification exists.");
// #1873 CB2: a copy's service credential is accepted only after live verification at the service.
group("credential-scope-unverified", "R1/R8: a copy credential is accepted only when the service proves its whole grant", "Gitea: approve read:user in the copy's binding (its role must declare it) so the account's repositories can be listed, give every bound project a Gitea remote on one server, and retry when the server answers. Vikunja has no verifier yet; its copy bindings stay unusable.");
group("credential-identity-refused", "R1/R8: one dedicated service account per organization, copy and service", "Create the account the refusal names on the copy's Gitea server, not an administrator, with access to the bound projects' repositories only, and issue a token from that account. Never reuse another copy's or agent's account or token.");
group("credential-date credential-format", "R1/R8: well-formed credentials with a future rotation date", "Supply the token on standard input alone, and a rotation date after today in YYYY-MM-DD form, no more than a year ahead for a copy credential.");
group("copy-unavailable copy-refused", "R3/R8: one active, unambiguous copy identity", "Read the organization's copies. Ask the owner to resolve the definition collision or restore the copy's source before raising a binding decision.");
group("copy-stale", "R3: root policy changed since this bus booted", "Ask the owner to restart the organization bus to load the approved root policy, then launch a fresh copy.");
group("root-policy-unreadable", "R3: root policy evidence must be readable", "Ask the owner to inspect root agent receipt metadata and restart the bus after repairing the read failure.");
group("plugin-disabled", "R3/R6/R10: installed, pinned and enabled plugin capabilities", "Ask the instance owner to verify the installed plugin and the organization owner to enable it. A replacement of an already loaded package needs a host restart; then relaunch the agent.");
group("plugin-restart-required", "R3/R10: one imported package pin per host process", "Ask the instance owner to restart the host after replacing an already loaded package, then retry through the supported path.");
group(
@@ -40,6 +61,18 @@ group(
"R1/R3: owner-managed model and provider configuration",
"Ask the owner to inspect the provider configuration through its supported controls; never put keys or callback contents in messages."
);
// #1873: proxy setup from the Console page and `mosaic proxy`. Each code has one owner next step.
group("proxy-install-missing proxy-fetch-url proxy-fetch-failed proxy-fetch-redirect", "R1: pinned model proxy release", "Fetch the pinned release from the Proxy page or with mosaic proxy fetch. A download or redirect failure is retried as it stands; the release URL is fixed by the pin.");
group("proxy-install-invalid proxy-fetch-hash proxy-fetch-archive proxy-fetch-existing", "R1: pinned release hashes must match", "A file does not match the pinned hash. Do not run it. Ask the owner to remove the pinned release directory named in the README and fetch again.");
group("proxy-already-initialized proxy-port-in-use proxy-management-key-invalid", "R1/R4: proxy init is exclusive creation", "Init never rewrites proxy.json. Read the Proxy page status; pick a free port, or keep the existing setup and continue from the status's next step.");
group("proxy-init-busy proxy-init-lock-held proxy-init-lock-absent", "R4: init lock clears only when provably stale", "Wait for a running init to finish. A stale lock clears from the Proxy page or with mosaic proxy init-lock clear only when the bus is stopped and no proxy.json, key file or proxy dir exists; the refusal lists what is still there.");
group("proxy-key-transport", "R1/R6: a pasted key travels only over loopback or HTTPS", "Use the generated key, which never leaves the host, or open the Console over HTTPS or on this machine to paste one.");
group("proxy-restart-required proxy-restart-unconfirmed proxy-lock-uncertain", "R4/R6: fence clears only after a confirmed proxy restart", "Restart the proxy from the Proxy page or with mosaic proxy fence clear --restart, so the new process is confirmed on the proxy port, then clear the fence. A fence file with no readable time cannot be proven against: restart the proxy service yourself and run mosaic proxy fence clear --proxy-restarted.");
group("proxy-admin-busy", "R4: one proxy setup action at a time", "Wait for the running fetch, init or unit action to finish (the Proxy page status shows it), then retry.");
group("proxy-key-mismatch", "R1: a pasted management key is confirmed before init", "Type the same key in both fields, or leave them empty to have the bus generate the key.");
group("proxy-lock-busy proxy-lock-invalid", "R4: one proxy key write at a time", "Wait for the running proxy write to finish and retry. An invalid lock file needs the owner to inspect the proxy state directory.");
group("proxy-not-configured proxy-unit-usage proxy-unit-exists proxy-unit-dropin proxy-unit-copy proxy-unit-path proxy-unit-missing proxy-unit-unhealthy proxy-unit-failed", "R1/R4: proxy user unit is explicit creation", "Initialize the proxy first, then install the unit from the Proxy page or with mosaic proxy unit install. An existing unit or drop-in is never replaced; remove it by hand. An unhealthy unit: read its journal by unit name and restart it.");
group("proxy-client-invalid proxy-client-refused proxy-client-response", "R1: proxy management answers", "Check the proxy unit is running and its port answers on the Proxy page, then retry. Upstream bodies are never shown.");
group(
"block-recipient block-reply-required block-sender message-recipient-refused message-sender-refused tester-reply-required tester-thread-human user-reply-required user-thread-human",
"Role messaging: permitted sender, recipient and reply context",
@@ -85,6 +118,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",
+115 -13
View File
@@ -1,3 +1,6 @@
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';
import { readPluginLock } from '../../plugins/src/pinned.mjs';
import { createRequirementsHost } from '../../tasks/src/builtin.mjs';
@@ -32,12 +35,18 @@ import { createModelCatalogue } from './model-catalogue.mjs';
import { sha256 } from '../../model-proxy/src/common.mjs';
import { initializeProxy } from '../../model-proxy/src/settings.mjs';
import { createProxyLock } from './proxy-lock.mjs';
import { verifyRestart } from '../../model-proxy/src/admin.mjs';
import { ModelKeys, modelOrganization, stopModelRuns, MODEL_STOP_TIMEOUT_MS } from './model-keys.mjs';
import { instanceSettings } from '../../organization/src/instance-settings.mjs';
import { organizationWriter, OrganizationError, setReplyLimits } from '../../organization/src/organization.mjs';
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';
import { loadWithCopies } from '../../organization/src/agent-copies.mjs';
import { copyGrant, sweepCopySlots } from './copy-grant.mjs';
import { loadRole } from '../../organization/src/role.mjs';
import { resolveInstance } from '../../organization/src/resolve.mjs';
import { canonicalJson } from '../../organization/src/util.mjs';
@@ -59,7 +68,7 @@ import { discordProcessingHooks } from '../../discord/src/processing.mjs';
import { createContainerStopper } from '../../processing-control/src/containers.mjs';
// Row 194 (D3): the verbs the instance writer commits, whose answers carry restart.
const INSTANCE_VERBS = new Set(['instances.set', 'veto.set', 'instance.hire', 'instance.fire']);
const INSTANCE_VERBS = new Set(['instances.set', 'veto.set', 'instance.fire']);
// FED R4 (#1801, design 3.7): the revocation timer's period.
export const ACCESS_SWEEP_MS = 30_000;
// Trusted host API. S1 supplies resolved definitions, S6 supplies launch records and ends them.
@@ -92,6 +101,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 = [];
@@ -100,22 +112,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
@@ -309,7 +336,7 @@ export async function startBroker({
|| (Object.hasOwn(librarySync, 'intervalMs') && !(Number.isSafeInteger(librarySync.intervalMs) && librarySync.intervalMs > 0))
|| (Object.hasOwn(librarySync, 'configDir') && typeof librarySync.configDir !== 'string')))
throw new BusError('invalid-host-request');
let modelProxyClient = null, proxyPage = null, proxyUsage = null, usageTimer = null, proxyLock = null;
let modelProxyClient = null, proxyPage = null, proxyUsage = null, usageTimer = null, proxyLock = null, proxyPort = null;
let modelKeys = null, modelTimer = null, modelSweep = null, modelCatalogues = null;
const modelRuns = new Map();
let store, credentials, server, adapter, processing, syncTimer = null, syncPass = null, accessTimer = null, requestTimer = null;
@@ -358,6 +385,7 @@ export async function startBroker({
proxyLock = createProxyLock({ stateDir: join(instanceDataRoot, 'proxy'), identity: `${instanceConfigDir}:${proxy.port}` });
}
modelProxyClient = await loadManagementClient({ configDir: org ? instanceConfigDir : organization.dir, proxy, receipt: proxyReceipt });
proxyPort = proxy.port;
}
// Row 234 (#1740, r6 X4 third bullet): each host grant record gives its agent a store reference to
@@ -376,6 +404,20 @@ export async function startBroker({
// Row 236 (#1742, #436b r5 section 5): each gitea grant record gives its agent a store reference to
// gitea, and `held` names the files memory must not load until reconcile or a revoke.
const { held } = storeRoot ? giteaStart(store, storeRoot, businesses, references) : { held: [] };
// #1873 CB2 (security review P2): a copy slot an issue staged before a crash, whose binding holds
// no grant for it, goes before Credentials loads and before any issue can run.
if (storeRoot) {
const reader = organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir, dataRoot: instanceRoot ?? dataRoot });
const documents = Object.keys(businesses).map((id) => {
try {
return [id, reader.read(id).document];
} catch {
return [id, null];
}
});
for (const entry of sweepCopySlots(storeRoot, documents, references))
process.stderr.write(`copies: ${JSON.stringify({ event: 'copy-slot-swept', ...entry })}\n`);
}
// #1886 (F3 defect l): a store reference with no file yet reads as no key (design D3a), but Credentials
// refuses a missing store root, and only issue() made it. Start makes it the same way issue() does when
// any reference names the store. A root that exists with loose modes or as a symlink still refuses.
@@ -383,6 +425,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,
@@ -651,9 +705,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 });
@@ -743,6 +807,11 @@ export async function startBroker({
// Row 236 (#1742): the Gitea grant path. The admin slot is <configDir>/secrets/grants/<business>/gitea.
giteaGrant: organization ? giteaGrant({ configDir: organization.dir }) : null,
giteaKeys: storeRoot ? giteaKeys({ credentials, storeRoot }) : null,
// #1873 CB2: credentials.issue for an agent copy's bound Gitea service, verified live at the server.
copyGrant: organization && storeRoot
? copyGrant({ credentials, writer: organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir, dataRoot: instanceRoot ?? dataRoot }),
copies, organizations: () => Object.keys(businesses) })
: null,
usage,
limits,
store,
@@ -816,8 +885,6 @@ export async function startBroker({
if(verb==='instances.check')return instances.checkDelete(id,args.instance,actor);
// Row 194 b6 (webby-0094 B1): a fire's checks against the file, before step A.
if(verb==='instance.fireCheck')return instances.checkFire(id,args.name);
// Internal read-only path: no reload, journal, credential or publication.
if(verb==='instance.hireCheck')return instances.checkHire(id,args);
let result;
if (['limits.set', 'instances.set'].includes(verb) && settings.current(id).plugins?.['model-provider']?.length) {
return catalogueFor(id, { fresh: true }).then(catalogue => {
@@ -849,9 +916,11 @@ export async function startBroker({
else if(verb==='grants.scope.set')result=instances.grantScope(id,args,actor);
// C2-06 M11: the human's replyLimits, through the organization writer's fence.
else if(verb==='library.limits.set')result=setReplyLimits(organizationWriter({dir:organization.dir,rolesDir:organization.rolesDir}),id,args,actor);
// #1873 T1: the human's definition writes. The broker passes only the business's human (the
// owner, so ROLE_RIGHTS.owner); the store's assertClean is the held-secret check each write needs.
else if(Object.hasOwn(DEFINITION_BUS_VERBS,verb))result=definitionVerbs({writer:organizationWriter({dir:organization.dir,rolesDir:organization.rolesDir}),rolesDir:organization.rolesDir,assertClean:value=>credentials.assertClean(value)})[DEFINITION_BUS_VERBS[verb]](id,args,{actor,rights:[...ROLE_RIGHTS.owner],agent:false});
// Row 194 (D2.1, D2.2): the chief's hire and fire, with the reason in the change context.
else if(verb==='instance.hire')result=instances.hire(id,args,{actor,context});
else if(verb==='instance.fire')result=instances.fire(id,{name:args.name},{actor,context});
else if(verb==='instance.fire')result=instances.fire(id,{name:args.name,revision:args.revision},{actor,context});
else throw new BusError('invalid-request');
// Row 194 (D3 item 2): one reload, after every verb that committed, then the limit checks.
const live=reload(id);
@@ -862,6 +931,14 @@ export async function startBroker({
return INSTANCE_VERBS.has(verb)?{...result,restart:!live}:result;
}):null,
// Row 194 (D2.3): the role files as they stand, for the hire checks.
autonomy: organization ? autonomyHost({writer:organizationWriter({dir:organization.dir,rolesDir:organization.rolesDir,dataRoot:instanceRoot??dataRoot}),dataRoot,business:id=>businesses[id],countsAccount:levels.countsAccount,hires:{reverse:(...args)=>broker.hireReverse(...args)},reload,processing}) : null,
hires: organization ? (()=>{
const service=hireDecisions({writer:organizationWriter({dir:organization.dir,rolesDir:organization.rolesDir,dataRoot:instanceRoot??dataRoot}),rolesDir:organization.rolesDir,configDir:organization.dir,dataRoot,
system:organization.system,levels,catalogueFor:async(id,doc)=>doc.plugins?.['model-provider']?.length?catalogueFor(id,{fresh:true}):null,assertClean:value=>credentials.assertClean(value),scanDefinition:(name,entry)=>scanDefinitions({[name]:entry},value=>credentials.assertClean(value)),registryFor:doc=>organizationPlugins(instanceRoot??dataRoot,doc.plugins).registry,
// #1873 CB1: a copy binding names a copy this bus booted with; no snapshot, no binding.
copies:id=>copies?.org===id?copies:null});
return authorityHost(service,{modelKeys,reload,proxyOrganization});
})() : null,
roleFile: organization ? (name) => loadRole(organization.rolesDir, name) : null,
secretCheck: (value) => credentials.assertClean(value),
credentialStatus: () => credentials.status(),
@@ -874,11 +951,21 @@ export async function startBroker({
proxyInit: organization ? async args => {
const settings = await initializeProxy({ configDir: organization.dir, dataRoot: instanceRoot ?? dataRoot, ...args });
modelProxyClient = await loadManagementClient({ configDir: organization.dir, proxy: settings, receipt: proxyReceipt });
proxyPort = settings.port;
// #1873: an org bus that initializes the shared proxy also holds its write lock from now on.
if (org && !proxyLock) proxyLock = createProxyLock({ stateDir: join(instanceDataRoot, 'proxy'), identity: `${instanceConfigDir}:${settings.port}` });
configureProxyPage();
return settings;
} : null,
// #1873: clear the uncertain-write fence only on restart evidence this host re-proves: the
// process on the proxy port is new, alive and started after the fence was set.
proxyFenceClear: organization ? async ({ evidence }) => {
if (!proxyLock || !modelProxyClient) throw new BusError('not-configured');
return proxyLock.clear({ restarted: true, read: () => modelProxyClient.getKeys(), verify: previous => verifyRestart(evidence, { fence: previous, port: proxyPort }) });
} : null,
credentialVerbs: organization
? credentialVerbs({ credentials, writer: organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir, dataRoot:instanceRoot ?? dataRoot }) })
? 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.
@@ -1126,6 +1213,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 ?? {})
@@ -1249,7 +1350,7 @@ export async function startBroker({
return refused(['model-provider-unavailable','model-catalogue-unavailable','model-harness-incompatible'].includes(answer?.code) ? answer.code : 'model-provider-unavailable');
},
modelPreflight: async ({ business, role }) => {
const current = settings ? settings.current(business) : businesses[business];
const current = organization ? loadOrg(business) : businesses[business];
if (!Object.hasOwn(current?.roles ?? {}, role)) throw new BusError('unknown-role');
return modelKeys ? modelKeys.preflight(business + '/' + role) : null;
},
@@ -1299,6 +1400,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});
});
+6 -370
View File
@@ -8,7 +8,6 @@ import { DatabaseSync } from 'node:sqlite';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { Store, schemaDigest } from '../src/store.mjs';
import { Broker, ACTIONS } from '../src/broker.mjs';
import { HIRE_ALLOWED } from '../../organization/src/vocabulary.mjs';
import { startBroker } from '../src/runtime.mjs';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../organization/tests/helpers.mjs';
import { busOrganization } from '../src/organization-context.mjs';
@@ -711,380 +710,17 @@ test('row 194 T18 (D2.4): from a chief session veto.set and instances.set refuse
assert.deepEqual(organization.at(-1), ['demo', 'veto.set', { revision: PATCH.revision, op: 'unkeep', name: 'coder' }, 'jason']);
});
// Row 194 (#1692, D2.1-D2.3): the chief's hire and fire. PRD 1.7 REQ-COS-3 routes role.hire and
// role.fire within-role for the chief (sage-1471, mos-1089), so they take the reason form, as
// role.end does: no decision, a reason of 1 to 500 characters that goes into the log. The role
// files are stubs, so a definition's authority is the test's to set.
const LISTED = { launch: { by: 'chief', instances: ['pm', 'coder'], keep: ['pm'], noHire: ['banned'] } };
const roleDoc = (withinRole, extra = {}) => ({ roleVersion: 2, authority: { withinRole, crossRole: [] }, hireable: [], ...extra });
const ROLE_FILES = {
'chief-of-staff': roleDoc(chiefAuthority, { hireable: ['coder', 'researcher', 'wide', 'banned'] }),
coder: roleDoc(['message.send', 'record.write']),
// A version 1 file has empty authority lists, so only the version check refuses it (M60).
researcher: { roleVersion: 1, authority: { withinRole: [], crossRole: [] }, hireable: [] },
wide: roleDoc(['message.send', 'task.create']),
banned: roleDoc(['message.send']),
pm: roleDoc(['message.send']),
};
const roleFile = (files) => (name) => {
if (!Object.hasOwn(files, name)) throw Object.assign(new Error(`no role ${name}`), { name: 'BusinessError' });
return files[name];
};
function hireSetup(t, extra = LISTED, files = ROLE_FILES) {
return setup(t, extra, { roleFile: roleFile(files) });
}
const HIRE = { name: 'helper', definition: 'coder', reason: 'more hands for the queue' };
const FIRE = { name: 'coder', reason: 'the work is done' };
const CONTEXT = (reason) => ({ instance: 'chief', run: 'chief-run', reason });
const withoutReason = ({ reason, ...rest }) => rest;
test('row 194 T8: the chief hires with a reason and no decision, the writer gets the reason as context, and the log names it', (t) => {
const { call, organization, allowed, store } = hireSetup(t);
const result = call('chief', 'instance.hire', HIRE);
assert.equal(result.transaction, 't-1');
assert.deepEqual(organization, [['demo', 'instance.hire', withoutReason(HIRE), 'chief-of-staff.chief', CONTEXT(HIRE.reason)]]);
const [use, ...rest] = allowed('role.hire');
assert.equal(rest.length, 0);
assert.equal(use.subject, 'helper');
assert.deepEqual({ reason: use.body.reason, transaction: use.body.transaction, decision: use.body.decision ?? null }, { reason: HIRE.reason, transaction: 't-1', decision: null });
// Within-role: no decision is raised or needed.
assert.equal(store.get('SELECT count(*) AS n FROM decisions').n, 0);
// vars reach the writer with the name and definition.
call('chief', 'instance.hire', { ...HIRE, name: 'aide', vars: { model: 'm', 'limits.x': 1 } });
assert.deepEqual(organization.at(-1)[2], { name: 'aide', definition: 'coder', vars: { model: 'm', 'limits.x': 1 } });
});
// T11 (D5): a business id names one business on one bus. The chief's hire and fire go to its own
// business's writer, and no argument names another.
test('row 194 T11: on a bus with two businesses, the chief of one hires and fires only in its own', (t) => {
const root = mkdtempSync(join(tmpdir(), 'bus-chief-two-'));
const store = new Store(root);
t.after(() => {
store.close();
rmSync(root, { recursive: true, force: true });
});
const organization = [];
const one = demo(LISTED).demo;
const b = new Broker({
store,
businesses: { demo: one, other: { ...one, id: 'other' } },
roleFile: roleFile(ROLE_FILES),
organization: (...args) => (organization.push(args), { revision: 'b'.repeat(64), transaction: 't-' + organization.length }),
});
const chief = b.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' });
b.request(chief, { verb: 'role.claim' });
for (const verb of ['instance.hire', 'instance.fire'])
for (const extra of [{ business: 'other' }, { target: 'other' }])
assert.throws(() => b.request(chief, { verb, args: { ...(verb === 'instance.hire' ? HIRE : FIRE), ...extra } }), /invalid-request/, `${verb} ${Object.keys(extra)}`);
assert.equal(organization.length, 0);
b.request(chief, { verb: 'instance.hire', args: HIRE });
b.request(chief, { verb: 'instance.fire', args: FIRE });
assert.deepEqual(organization.map((c) => [c[0], c[1]]), [['demo', 'instance.hire'], ['demo', 'instance.fireCheck'], ['demo', 'instance.fire']]);
assert.equal(store.get("SELECT count(*) AS n FROM events WHERE business='other'").n, 0);
});
test('row 194 T8: hire and fire need a reason of 1 to 500 characters and refuse a decision key', (t) => {
const { call, organization } = hireSetup(t);
for (const reason of [undefined, '', 'x'.repeat(501), 7]) {
const r = reason === undefined ? {} : { reason };
assert.throws(() => call('chief', 'instance.hire', { name: 'helper', definition: 'coder', ...r }), /invalid-request/, String(reason));
assert.throws(() => call('chief', 'instance.fire', { name: 'coder', ...r }), /invalid-request/, String(reason));
}
assert.throws(() => call('chief', 'instance.hire', { ...HIRE, decision: 'd-1' }), /invalid-request/);
assert.throws(() => call('chief', 'instance.fire', { ...FIRE, decision: 'd-1' }), /invalid-request/);
for (const name of ['human', 'tester']) assert.throws(() => call('chief', 'instance.hire', { ...HIRE, name }), /invalid-request/, name);
assert.equal(organization.length, 0);
call('chief', 'instance.hire', { ...HIRE, reason: 'x'.repeat(500) });
assert.equal(organization.length, 1);
});
test('row 194 T8 (sage-1471): only the chief hires or fires; every other role refuses role-not-chief, even one granted the actions', (t) => {
const granted = { authority: { withinRole: ['message.send', 'role.hire', 'role.fire'], crossRole: [] } };
const { call, organization, allowed } = hireSetup(t, { ...LISTED, roles: { ...roles, pm: { definition: 'pm', ...granted }, coder: { definition: 'coder', ...granted } } });
for (const who of ['pm', 'coder', 'assistant']) {
assert.throws(() => call(who, 'instance.hire', HIRE), /role-not-chief/, who);
assert.throws(() => call(who, 'instance.fire', FIRE), /role-not-chief/, who);
}
assert.throws(() => call('tester', 'instance.hire', HIRE), /tester-refused/);
assert.throws(() => call('reader', 'instance.hire', HIRE), /read-only/);
assert.throws(() => call('human', 'instance.fire', FIRE), /agent-required/);
assert.equal(organization.length, 0);
assert.equal(allowed('role.hire').length + allowed('role.fire').length, 0);
});
test('row 194 T8 (sage-1471): a chief whose authority lacks role.hire or role.fire refuses decision-required before the writer', (t) => {
for (const missing of ['role.hire', 'role.fire']) {
const authority = { withinRole: chiefAuthority.withinRole.filter((a) => a !== missing), crossRole: [] };
const { call, organization, lastClaim } = hireSetup(t, { ...LISTED, roles: { ...roles, chief: { definition: 'chief-of-staff', authority } } });
const [verb, args] = missing === 'role.hire' ? ['instance.hire', HIRE] : ['instance.fire', FIRE];
assert.throws(() => call('chief', verb, args), /decision-required/, missing);
assert.equal(organization.length, 0, missing);
assert.equal(lastClaim('coder').op, 'claim', missing);
// The other action still goes through.
const [other, otherArgs] = missing === 'role.hire' ? ['instance.fire', FIRE] : ['instance.hire', HIRE];
call('chief', other, otherArgs);
assert.equal(organization.filter(([, verb]) => verb !== 'instance.fireCheck').length, 1, missing);
}
});
test('row 194 T8a: a re-hire of a fired name goes to the writer like a hire, and the writer\'s refusals pass through by name', (t) => {
const { call, organization, writer } = hireSetup(t);
const again = { name: 'coder', definition: 'coder', reason: 'needed again' };
for (const code of ['organization-instance-exists', 'organization-instance-retired', 'definition-not-hireable', 'instance-kept']) {
writer.error = Object.assign(new Error(code), { code });
assert.throws(() => call('chief', 'instance.hire', again), new RegExp(code));
}
writer.error = null;
call('chief', 'instance.hire', again);
assert.deepEqual(organization.at(-1).slice(1, 3), ['instance.hire', withoutReason(again)]);
});
test('row 194 T8b: a definition is hireable only when every action is on HIRE_ALLOWED, it is version 2, the chief lists it and noHire doesn\'t', (t) => {
assert.deepEqual([...HIRE_ALLOWED], ['message.send', 'record.write', 'review.request', 'task.update.assigned', 'task.scope.change', 'git.push.working']);
for (const action of ACTIONS) {
const files = { ...ROLE_FILES, wide: roleDoc(['message.send', action]) };
const { call, organization } = hireSetup(t, LISTED, files);
const run = () => call('chief', 'instance.hire', { ...HIRE, definition: 'wide' });
if (HIRE_ALLOWED.includes(action)) {
run();
assert.equal(organization.length, 1, action);
} else {
assert.throws(run, /definition-not-hireable/, action);
assert.equal(organization.length, 0, action);
}
}
const { call, organization } = hireSetup(t);
const hire = (definition) => call('chief', 'instance.hire', { ...HIRE, definition });
// Version 1 (M60), noHire, not on the chief's list, and no role file.
for (const definition of ['researcher', 'banned', 'pm', 'nothing'])
assert.throws(() => hire(definition), /definition-not-hireable/, definition);
assert.equal(organization.length, 0);
hire('coder');
assert.equal(organization.length, 1);
});
test('row 194 T8b: a hire without the role files configured refuses not-configured', (t) => {
const { call, organization } = setup(t, LISTED);
assert.throws(() => call('chief', 'instance.hire', HIRE), /not-configured/);
assert.equal(organization.length, 0);
assert.throws(() => new Broker({ store: null, businesses: demo(), roleFile: 'roles' }), /roleFile must be a function/);
});
test('row 194 T9: a fire revokes the claim, cancels open requests, asks the launcher to kill the runs, then delists and logs', (t) => {
const { call, organization, allowed, lastClaim, writer } = hireSetup(t);
// An open launch request of the coder, made by the human.
const open = call('human', 'instance.launch', { instance: 'coder' }).request;
let seen = null;
writer.during = ([, verb]) => { if (verb === 'instance.fire') seen = { stopped: allowed('fire.stopped').length, claim: lastClaim('coder').op, used: allowed('role.fire').length }; };
const result = call('chief', 'instance.fire', FIRE);
// Step A ran before the writer; step C after it.
assert.deepEqual(seen, { stopped: 1, claim: 'revoke', used: 0 });
assert.deepEqual(lastClaim('coder'), { op: 'revoke', decision: null, reason: 'the work is done', by: 'chief-run' });
assert.deepEqual(result.runs, ['coder-run']);
assert.deepEqual(result.cancelled, [open]);
assert.deepEqual(organization.at(-1), ['demo', 'instance.fire', { name: 'coder' }, 'chief-of-staff.chief', CONTEXT(FIRE.reason)]);
const [end] = allowed('lifecycle.end');
assert.equal(end.subject, 'coder');
assert.deepEqual({ ...end.body, request: null }, { action: 'lifecycle.end', request: null, via: 'chief', by: 'chief', runs: ['coder-run'], reason: 'the work is done', kill: true });
assert.equal(allowed('lifecycle.cancelled').filter((e) => e.body.request === open).length, 1);
const [use] = allowed('role.fire');
assert.equal(use.subject, 'coder');
assert.equal(use.body.reason, 'the work is done');
assert.equal(use.body.transaction, result.transaction);
assert.deepEqual(allowed('fire.done').map((e) => [e.subject, e.body.transaction]), [['coder', result.transaction]]);
// The human's read of the request shows the kill.
assert.equal(call('human', 'instance.request', { request: end.body.request }).kill, true);
});
// S6 (D2.2): the launcher closes a fire's end with the runs row 191's stop killed or left alive, and
// each becomes its own event against the instance. Only a fire's end carries them.
test('row 194 T9a (bus): a fire end\'s done writes lifecycle.end.killed and lifecycle.end.alive with run and pid, and an unknown stop\'s reason; a terminate\'s done or a bad stop refuses', (t) => {
const { b, call, allowed } = hireSetup(t);
call('chief', 'instance.fire', FIRE);
const [end] = allowed('lifecycle.end');
const request = end.body.request;
assert.equal(b.lifecycleTake({ business: 'demo', request }).go, true);
const done = (stops) => b.lifecycleDone({ business: 'demo', request, stops });
for (const bad of [[{ run: 'coder-run', pid: 4242, outcome: 'stopped' }], [{ run: 'coder-run', pid: 1, outcome: 'killed' }],
[{ run: 'coder-run', outcome: 'killed' }], [{ run: 'coder-run', pid: 4242, outcome: 'killed', signals: [] }], 'killed',
// sage-2289: only an alive stop carries a reason, and only unknown.
[{ run: 'coder-run', pid: 4242, outcome: 'killed', reason: 'unknown' }], [{ run: 'coder-run', pid: null, outcome: 'alive', reason: 'stuck' }]])
assert.throws(() => done(bad), { code: 'invalid-request' }, JSON.stringify(bad));
const closed = done([{ run: 'coder-run', pid: 4242, outcome: 'killed' }, { run: 'coder-other', pid: null, outcome: 'alive' },
{ run: 'coder-unknown', pid: null, outcome: 'alive', reason: 'unknown' }]);
assert.equal(closed.state, 'closed');
assert.deepEqual(allowed('lifecycle.end.killed').map((e) => [e.subject, e.body]), [['coder', { action: 'lifecycle.end.killed', request, run: 'coder-run', pid: 4242 }]]);
assert.deepEqual(allowed('lifecycle.end.alive').map((e) => [e.subject, e.body]), [['coder', { action: 'lifecycle.end.alive', request, run: 'coder-other', pid: null }],
['coder', { action: 'lifecycle.end.alive', request, run: 'coder-unknown', pid: null, reason: 'unknown' }]]);
// A terminate's end sends SIGTERM only, so its done has no stops.
const term = call('human', 'instance.end', { instance: 'pm' }).request;
assert.equal(b.lifecycleTake({ business: 'demo', request: term }).go, true);
assert.throws(() => b.lifecycleDone({ business: 'demo', request: term, stops: [{ run: 'pm-run', pid: 4242, outcome: 'killed' }] }), { code: 'invalid-request' });
assert.equal(allowed('lifecycle.end.killed').length, 1);
assert.equal(b.lifecycleDone({ business: 'demo', request: term }).state, 'closed');
});
test('row 194 T9: fire refusals come before step A and leave the claim, the requests and the file alone', (t) => {
const { call, organization, allowed, lastClaim } = hireSetup(t, { launch: { by: 'chief', instances: ['coder'], keep: ['pm'] } });
const fire = (name) => call('chief', 'instance.fire', { name, reason: 'r' });
assert.throws(() => fire('pm'), /instance-kept/);
assert.throws(() => fire('nobody'), /unknown-role/);
assert.throws(() => fire('assistant'), /organization-definition-refused/);
assert.throws(() => fire('chief'), /organization-instance-referenced/);
assert.throws(() => fire('coder'), /invalid-request/);
assert.equal(organization.length, 0);
assert.equal(allowed('fire.stopped').length + allowed('lifecycle.end').length + allowed('role.fire').length, 0);
assert.equal(lastClaim('coder').op, 'claim');
});
test('row 194 T9c: a writer failure at B leaves the instance firing, and the repeat delists and logs once', (t) => {
const { call, b, caps, organization, allowed, writer } = hireSetup(t);
writer.during = ([, verb]) => { if (verb === 'instance.fire') throw new Error('disk'); };
assert.throws(() => call('chief', 'instance.fire', FIRE), /outcome-unknown/);
writer.during = null;
assert.equal(allowed('fire.stopped').length, 1);
assert.equal(allowed('role.fire').length, 0);
// W-N1: while it fires, no launch of the instance starts by any path.
assert.throws(() => call('human', 'instance.launch', { instance: 'coder' }), /instance-firing/);
assert.throws(() => b.authorize(caps.chief, 'role.launch', { target: 'coder' }), /instance-firing/);
// The repeat has no claim to revoke. The revoked run is still bound until the launcher reports it
// ended, so the repeat asks for its kill again, then delists and logs.
const result = call('chief', 'instance.fire', FIRE);
assert.deepEqual(result.runs, ['coder-run']);
assert.equal(organization.filter(([, verb]) => verb === 'instance.fire').length, 2);
assert.equal(allowed('role.fire').length, 1);
assert.deepEqual(allowed('lifecycle.end').map((e) => e.body.kill), [true, true]);
// Once done, the instance launches again.
assert.ok(call('human', 'instance.launch', { instance: 'coder' }).request);
});
test('row 194 T9c: a failure at C leaves the fire unlogged and the instance firing, and the repeat logs it', (t) => {
const { call, organization, allowed, store, writer } = hireSetup(t);
// The delist commits, then the step C transaction aborts.
writer.during = ([, verb]) => verb === 'instance.fire' && store.run("CREATE TRIGGER fail_c BEFORE INSERT ON events WHEN json_extract(new.body,'$.action')='fire.done' BEGIN SELECT RAISE(ABORT, 'c failed'); END");
assert.throws(() => call('chief', 'instance.fire', FIRE));
writer.during = null;
store.run('DROP TRIGGER fail_c');
assert.equal(allowed('role.fire').length, 0);
assert.equal(allowed('fire.done').length, 0);
assert.throws(() => call('human', 'instance.launch', { instance: 'coder' }), /instance-firing/);
call('chief', 'instance.fire', FIRE);
assert.equal(organization.filter(([, verb]) => verb === 'instance.fire').length, 2);
assert.equal(allowed('role.fire').length, 1);
assert.equal(allowed('fire.done').length, 1);
assert.ok(call('human', 'instance.launch', { instance: 'coder' }).request);
});
test('row 194 W-N1: a fire.done before a later fire.stopped of the same instance leaves it firing; another instance\'s done does not end it', (t) => {
const { call, store } = hireSetup(t);
const step = (instance, action) =>
store.run("INSERT INTO events(id,at,business,kind,actor_role,actor_run,subject,body) VALUES(?,?,'demo','action.allowed','chief','chief-run',?,?)",
`${action}-${instance}-${Math.random()}`, new Date().toISOString(), instance, JSON.stringify({ action, request: null, transaction: null }));
step('coder', 'fire.done');
step('coder', 'fire.stopped');
step('pm', 'fire.done');
assert.throws(() => call('human', 'instance.launch', { instance: 'coder' }), /instance-firing/);
step('coder', 'fire.done');
assert.ok(call('human', 'instance.launch', { instance: 'coder' }).request);
});
test('row 194 T10: a hire or fire of a kept name refuses instance-kept before the writer', (t) => {
const { call, organization, lastClaim, allowed } = hireSetup(t);
assert.throws(() => call('chief', 'instance.hire', { ...HIRE, name: 'pm' }), /instance-kept/);
assert.throws(() => call('chief', 'instance.fire', { ...FIRE, name: 'pm' }), /instance-kept/);
assert.equal(organization.length, 0);
assert.equal(lastClaim('pm').op, 'claim');
assert.equal(allowed('role.hire').length + allowed('role.fire').length, 0);
});
// webby-0094 B1: the file's keep, which this copy hasn't loaded, refuses at the fire check, before
// step A revokes, ends or marks anything, so the instance isn't left firing.
test('row 194 T10: a fire the writer\'s check refuses from the file stops nothing and leaves the instance launchable', (t) => {
const { call, organization, lastClaim, allowed, writer } = hireSetup(t);
writer.during = ([, verb]) => { if (verb === 'instance.fireCheck') throw Object.assign(new Error('instance-kept'), { code: 'instance-kept' }); };
assert.throws(() => call('chief', 'instance.fire', FIRE), /instance-kept/);
writer.during = null;
assert.deepEqual(organization.map(([, verb]) => verb), ['instance.fireCheck']);
assert.equal(lastClaim('coder').op, 'claim');
assert.equal(allowed('fire.stopped').length + allowed('lifecycle.end').length + allowed('role.fire').length, 0);
assert.ok(call('human', 'instance.launch', { instance: 'coder' }).request);
});
test('row 194 T18: hire and fire take only their own keys, and a refused call writes nothing', (t) => {
const { call, organization, allowed } = hireSetup(t);
for (const extra of [{ keep: ['x'] }, { launch: {} }, { revision: 'a'.repeat(64) }, { force: true }]) {
assert.throws(() => call('chief', 'instance.hire', { ...HIRE, ...extra }), /invalid-request/, JSON.stringify(extra));
assert.throws(() => call('chief', 'instance.fire', { ...FIRE, ...extra }), /invalid-request/, JSON.stringify(extra));
}
assert.throws(() => call('chief', 'instance.hire', { ...HIRE, vars: { harness: 'pi' } }), /invalid-request/);
assert.throws(() => call('chief', 'instance.fire', { ...FIRE, definition: 'coder' }), /invalid-request/);
assert.equal(organization.length, 0);
assert.equal(allowed('fire.stopped').length, 0);
});
test('row 194 T8, T9, T18: through the runtime the chief hires and fires with the real writer, the receipt carries the reason, and a refusal leaves the file alone', async (t) => {
const root = mkdtempSync(join(tmpdir(), 'bus-chief-hire-'));
const rolesDir = rolesCopy(root);
// A hireable scratch definition: version 2, no credentials, every action on HIRE_ALLOWED.
writeFileSync(join(rolesDir, 'aide.md'), '# Aide\n\n## Authority\n');
writeJson(join(rolesDir, 'aide.json'), {
roleVersion: 2, name: 'aide', title: 'Aide', contract: 'aide.md', tools: ['read'], network: 'api-only',
authority: { withinRole: ['message.send'], crossRole: [] }, credentials: [],
});
const chiefFile = join(rolesDir, 'chief-of-staff.json');
writeJson(chiefFile, { ...JSON.parse(readFileSync(chiefFile, 'utf8')), hireable: ['aide'] });
const file = join(root, 'businesses/demo.json');
writeJson(file, { ...businessDoc(root, 'demo'), plugins: { harness: ['pi'] } });
mkdirSync(join(root, 'data'), { mode: 0o700 });
// The boot copy is the file's own bus form, as cli bootConfig builds it, so a write reloads.
const busForm = (dir, roles) => {
const b = loadBusiness('demo', { dir, rolesDir: roles });
const resolved = {};
for (const instance of Object.keys(b.roles)) resolved[instance] = resolveInstance({ system: systemFor(dir), business: b, instance });
return busOrganization(b, resolved);
};
const bus = await startBroker({
dataRoot: join(root, 'data'),
businesses: { demo: busForm(root, rolesDir) },
organization: { dir: root, rolesDir, system: systemFor(root) },
});
t.after(async () => {
await bus.close();
rmSync(root, { recursive: true, force: true });
});
const { broker } = bus;
const chief = broker.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' });
broker.request(chief, { verb: 'role.claim' });
const call = (cap, verb, args) => broker.request(cap, { verb, args });
const receipts = () =>
readFileSync(join(root, 'businesses/.history/demo/changes.jsonl'), 'utf8').trim().split('\n').map(JSON.parse).filter((r) => r.state === 'committed');
const document = () => JSON.parse(readFileSync(file, 'utf8'));
// The shipped chief file grants both actions, so the runtime takes the reason form.
// A refused hire: coder declares credentials and isn't on the chief's list. The file is unchanged.
const bytes = readFileSync(file, 'utf8');
assert.throws(() => call(chief, 'instance.hire', { name: 'helper', definition: 'coder', reason: 'r' }), /definition-not-hireable/);
assert.equal(readFileSync(file, 'utf8'), bytes);
call(chief, 'instance.hire', { name: 'helper', definition: 'aide', reason: 'more hands' });
assert.deepEqual(document().roles.helper, { definition: 'aide' });
assert.ok(document().launch.instances.includes('helper'));
const [h] = receipts().slice(-1);
assert.equal(h.actor, 'chief-of-staff.chief');
assert.deepEqual(h.context, CONTEXT('more hands'));
const result = call(chief, 'instance.fire', { name: 'helper', reason: 'the work is done' });
assert.equal(result.delisted, true);
assert.ok(!document().launch.instances.includes('helper'));
assert.deepEqual(receipts().slice(-1)[0].context, CONTEXT('the work is done'));
// The reloaded business sees the delist: a refused call leaves the file alone.
const after = readFileSync(file, 'utf8');
assert.throws(() => call(chief, 'instance.fire', { name: 'helper', reason: 'again', extra: 1 }), /invalid-request/);
assert.equal(readFileSync(file, 'utf8'), after);
test('A4 the retired lifecycle verbs always require a decision and never call the writer',t=>{
const {call,organization}=setup(t,KEPT);
for(const who of ['chief','coder','human'])for(const verb of ['instance.hire','instance.fire'])
assert.throws(()=>call(who,verb,{name:'coder',definition:'coder',reason:'help'}),{code:'decision-required'});
assert.equal(organization.length,0);
});
test('row 194 W-N1: the launcher\'s take of a launch or regenerate of a firing instance closes it as instance-firing', (t) => {
const { b, call, store } = hireSetup(t);
const { b, call, store } = setup(t,LISTED);
const launch = call('human', 'instance.launch', { instance: 'coder' }).request;
const regenerate = call('human', 'instance.regenerate', { instance: 'pm' }).request;
// A fire's step A as the store holds it with no step C, for requests step A didn't see.
+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', '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' });
+135
View File
@@ -0,0 +1,135 @@
// #1873 CB1: a copy credential binding through the broker. Only the chief raises one; only the
// organization owner resolves it; no autonomy level, member right, resolver agent, tester or reader
// moves it. The written record stays pending, so nothing launches on it in CB1.
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Broker } from '../src/broker.mjs';
import { Store } from '../src/store.mjs';
import { REFUSALS } from '../src/refusals.mjs';
import { hireDecisions } from '../../organization/src/hire-decisions.mjs';
import { organizationWriter } from '../../organization/src/organization.mjs';
import { loadBusiness } from '../../organization/src/organization-config.mjs';
import { checkRootAgents } from '../../organization/src/agent-levels.mjs';
import { materializeCopies } from '../../organization/src/agent-copies.mjs';
import { activateOrg, reserveOrg } from '../../organization/src/org-registry.mjs';
import { businessDoc, rolesCopy, writeJson } from '../../organization/tests/helpers.mjs';
const SUBJECT = { provider: 'test', iss: 'https://issuer.test', sub: 'owner' };
const GITEA = { gitea: { scopes: ['read:repository'], projects: ['stack'] } };
const SECRET = 'sk-test-NOT-A-SECRET-1234';
async function fixture(t, edit = d => d) {
const root = mkdtempSync(join(tmpdir(), 'cb1-bus-')), rolesDir = rolesCopy(root);
const base = { ...businessDoc(root, 'demo'), plugins: { harness: ['pi'] }, vars: { 'bus.tester': true } };
base.projects.stack.agents = { checker: { definition: 'reviewer' } };
const doc = edit(base);
const file = writeJson(join(root, 'businesses/demo.json'), doc);
const storeRoot = join(root, 'data'); mkdirSync(storeRoot, { mode: 0o700 });
const ctx = { configDir: root, dataRoot: storeRoot, instance: 'test' };
await reserveOrg(ctx, 'demo'); await activateOrg(ctx, 'demo');
const snapshot = materializeCopies({ orgId: 'demo', doc, root: { state: 'ok', ...checkRootAgents({ agentsVersion: 1, agents: { rev: { definition: 'reviewer' } } }, { rolesDir }) }, rolesDir });
const writer = organizationWriter({ dir: root, rolesDir }), store = new Store(storeRoot);
const service = hireDecisions({ writer, rolesDir, dataRoot: storeRoot, configDir: root, assertClean: v => { if (JSON.stringify(v).includes(SECRET)) throw Object.assign(new Error('x'), { code: 'definition-holds-credential' }); }, copies: id => snapshot.org === id ? snapshot : null });
const loaded = () => { const d = loadBusiness('demo', { dir: root, rolesDir }); return { ...d, roles: Object.fromEntries(Object.entries(d.roles).map(([n, e]) => [n, { ...d.definitions[e.definition], ...e }])) }; };
let writes = 0;
const b = new Broker({ store, businesses: { demo: loaded() }, hires: { ...service, apply: async (...args) => { writes++; const r = await service.apply(...args); b.reloadBusiness('demo', loaded()); return r; } } });
const owner = b.bindConsole({ business: 'demo', human: 'jason', subject: SUBJECT });
const member = (rights, extra = {}) => b.bindHireConsole({ business: 'demo', actor: 'member', subject: { ...SUBJECT, sub: 'member' }, rights, ...extra });
const caps = { chief: b.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' }), coder: b.bindLaunch({ business: 'demo', role: 'coder', run: 'coder-run', harness: 'pi' }) };
b.request(caps.chief, { verb: 'role.claim' }); b.request(caps.coder, { verb: 'role.claim' });
const call = (cap, verb, args = {}) => Promise.resolve().then(() => b.request(cap, { verb, args }));
const bind = (extra = {}) => ({ op: 'change', level: 'root', name: 'rev', reason: 'Bind the reviewer copy', binding: { services: GITEA }, ...extra });
const raise = extra => call(caps.chief, 'hire.raise', bind(extra));
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
const bindings = () => JSON.parse(readFileSync(file, 'utf8')).copyBindings ?? {};
return { root, rolesDir, file, b, store, writer, service, owner, member, caps, call, bind, raise, bindings, writes: () => writes, loaded };
}
test('CB1 bus: only the chief raises a binding; the decision names the copy and holds no token or path', async t => {
const f = await fixture(t);
await assert.rejects(f.call(f.caps.coder, 'hire.raise', f.bind()), { code: 'role-not-chief' });
assert.equal(f.store.get('SELECT count(*) n FROM decisions').n, 0);
const d = await f.raise();
assert.equal(d.route_to, 'human');
assert.equal(d.question, 'bind credentials for copy rev?');
assert.equal(d.hire.binding.instance, 'rev');
const text = JSON.stringify(d);
for (const bad of [SECRET, f.root, 'token']) assert.ok(!text.includes(bad), bad);
assert.equal(f.writes(), 0);
assert.deepEqual(f.bindings(), {});
});
test('CB1 bus: no autonomy level routes a binding away from the owner', async t => {
for (const authority of ['near the loop', 'out of the loop']) {
const f = await fixture(t, doc => {
doc.definitions = { resolver: { rev: 1, role: { tools: ['read'], network: 'none', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.authority'], crossRole: [] }, credentials: [] }, instructions: 'Resolve independent decisions.' } };
doc.roles.cto = { definition: 'org:resolver' };
doc.plugins['limit-check'] = ['token-limit']; doc.launch.limits = { tokens: { providers: { zai: { limit: 100000, since: '2026-01-01T00:00:00.000Z' } } } };
doc.autonomy = { authority, authorityResolver: 'cto', notices: true };
return doc;
});
const live = f.loaded(); live.roles.cto.authority = { withinRole: ['decision.resolve.authority'], crossRole: [] }; f.b.reloadBusiness('demo', live);
const resolver = f.b.bindLaunch({ business: 'demo', role: 'cto', run: 'cto-run', harness: 'pi' }); f.b.request(resolver, { verb: 'role.claim' });
const d = await f.raise();
assert.equal(d.route_to, 'human', authority);
// The authority resolver cannot take it, nor can the chief through the agent verb.
await assert.rejects(f.call(resolver, 'decision.resolve', { id: d.id, choice: 'yes' }), { code: 'hire-resolution-required' }, authority);
await assert.rejects(f.call(f.caps.chief, 'decision.resolve', { id: d.id, choice: 'yes' }), { code: 'hire-resolution-required' }, authority);
assert.equal(f.writes(), 0, authority);
assert.deepEqual(f.bindings(), {}, authority);
// A project-level unbind is not root; it lists the lines it removes and still goes to the owner.
if (authority === 'near the loop') {
await f.call(f.owner, 'hire.start.console', { op: 'change', level: 'project', project: 'stack', name: 'checker', reason: 'Bind', binding: { services: GITEA } });
const u = await f.raise({ level: 'project', project: 'stack', name: 'checker', binding: { services: null } });
assert.equal(u.hire.level, 'project');
assert.equal(u.route_to, 'human');
await assert.rejects(f.call(resolver, 'decision.resolve', { id: u.id, choice: 'yes' }), { code: 'hire-resolution-required' });
assert.equal(f.bindings()['checker.stack'].state, 'pending');
}
}
});
test('CB1 bus: members with org.hire or decide, the tester and a reader cannot resolve; the owner can, once', async t => {
const f = await fixture(t);
const d = await f.raise();
const digest = d.hire.digest;
await assert.rejects(f.call(f.member(['org.hire']), 'hire.resolve.console', { id: d.id, choice: 'yes', digest }), { code: 'human-required' });
await assert.rejects(f.call(f.member(['org.hire', 'decide'], { instanceOwner: true }), 'hire.resolve.console', { id: d.id, choice: 'yes', digest }), { code: 'human-required' });
await assert.rejects(f.call(f.member(['decide']), 'hire.resolve.console', { id: d.id, choice: 'yes', digest }), { code: 'right-required' });
await assert.rejects(f.call(f.b.bindTester({ business: 'demo' }), 'hire.resolve.console', { id: d.id, choice: 'yes', digest }), { code: 'human-required' });
await assert.rejects(f.call(f.b.bindReader({ business: 'demo' }), 'hire.resolve.console', { id: d.id, choice: 'yes', digest }), { code: 'human-required' });
await assert.rejects(f.call(f.owner, 'decision.resolve', { id: d.id, choice: 'yes' }), { code: 'human-required' });
assert.equal(f.writes(), 0);
assert.deepEqual(f.bindings(), {});
await assert.rejects(f.call(f.owner, 'hire.resolve.console', { id: d.id, choice: 'yes', digest: '0'.repeat(64) }), { code: 'decision-stale' });
await f.call(f.owner, 'hire.resolve.console', { id: d.id, choice: 'yes', digest });
assert.equal(f.writes(), 1);
const rec = f.bindings().rev;
assert.equal(rec.state, 'pending');
assert.equal(rec.decision, d.id);
await assert.rejects(f.call(f.owner, 'hire.resolve.console', { id: d.id, choice: 'yes', digest }), { code: 'decision-closed' });
assert.equal(f.writes(), 1);
});
test('CB1 bus: a one-step Console binding is owner-only; a member is refused before any decision is saved', async t => {
const f = await fixture(t);
await assert.rejects(f.call(f.member(['org.hire'], { instanceOwner: true }), 'hire.start.console', f.bind()), { code: 'human-required' });
assert.equal(f.store.get('SELECT count(*) n FROM decisions').n, 0);
await f.call(f.owner, 'hire.start.console', f.bind());
assert.equal(f.bindings().rev.state, 'pending');
// Unbind is owner-only too.
await assert.rejects(f.call(f.member(['org.hire']), 'hire.start.console', f.bind({ binding: { services: null } })), { code: 'human-required' });
await f.call(f.owner, 'hire.start.console', f.bind({ binding: { services: null } }));
assert.equal(f.bindings().rev.state, 'unbound');
});
test('CB1 bus: every new refusal has a table entry with a next step', () => {
for (const code of ['organization-bindings-protected', 'credential-binding-changed', 'credential-role-changed', 'credential-definition-unpinned', 'credential-scope-exceeded', 'credential-unbound', 'copy-unavailable', 'copy-refused']) {
assert.ok(Object.hasOwn(REFUSALS, code), code);
assert.ok(REFUSALS[code].nextStep?.length > 20, code);
assert.ok(!/(^|[\s("'`])(\/|~\/)/.test(REFUSALS[code].nextStep), code);
}
});
@@ -0,0 +1,227 @@
// #1873 CB2 live acceptance: credentials.issue for an agent copy's Gitea service against a scratch
// gitea/gitea:1.27.1, a throwaway container on 127.0.0.1 over TLS with a scratch CA, removed after.
// The test never pulls an image; without Docker or the image it skips. Every token here is a scratch
// token this container made; none is printed, and nothing here reaches git.mosaicstack.dev or any
// real service.
import test from 'node:test';
import assert from 'node:assert/strict';
import { createHash, randomBytes } from 'node:crypto';
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { request } from 'node:https';
import { createServer } from 'node:net';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
import { Broker, BusError } from '../src/broker.mjs';
import { Store } from '../src/store.mjs';
import { Credentials } from '../src/credentials.mjs';
import { copyGrant } from '../src/copy-grant.mjs';
import { hireDecisions } from '../../organization/src/hire-decisions.mjs';
import { organizationWriter } from '../../organization/src/organization.mjs';
import { loadBusiness } from '../../organization/src/organization-config.mjs';
import { checkRootAgents } from '../../organization/src/agent-levels.mjs';
import { materializeCopies, copiesView } from '../../organization/src/agent-copies.mjs';
import { activateOrg, reserveOrg } from '../../organization/src/org-registry.mjs';
import { copyBotLogin } from '../../organization/src/copy-bindings.mjs';
import { businessDoc, rolesCopy, writeJson } from '../../organization/tests/helpers.mjs';
import { scratchCa } from '../../seat/tests/fake-gitea.mjs';
const IMAGE = 'gitea/gitea:1.27.1';
const SUBJECT = { provider: 'test', iss: 'https://issuer.test', sub: 'owner' };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const ahead = (days) => new Date(Date.now() + days * 86400000).toISOString().slice(0, 10);
const sha = (s) => createHash('sha256').update(s).digest('hex');
const docker = (...args) => spawnSync('docker', args, { encoding: 'utf8', timeout: 120000 });
const skip = docker('image', 'inspect', IMAGE).status === 0 ? false : `no Docker or no local ${IMAGE}; this test never pulls`;
const freePort = () =>
new Promise((resolve, reject) => {
const s = createServer();
s.once('error', reject);
s.listen(0, '127.0.0.1', () => {
const { port } = s.address();
s.close(() => resolve(port));
});
});
// One HTTPS call to the scratch container, trusting only the scratch CA.
function call(url, ca, method, path, { auth = null, body = null } = {}) {
return new Promise((resolve) => {
const data = body ? JSON.stringify(body) : null;
const headers = { accept: 'application/json', ...(data ? { 'content-type': 'application/json' } : {}), ...(auth ? { authorization: auth } : {}) };
const req = request(url + path, { method, ca, headers, timeout: 10000 }, (res) => {
let text = '';
res.on('data', (c) => (text += c));
res.on('end', () => {
let json = null;
try {
json = text ? JSON.parse(text) : null;
} catch {}
resolve({ status: res.statusCode, json });
});
});
req.on('timeout', () => req.destroy());
req.on('error', () => resolve({ status: 0, json: null }));
req.end(data ?? undefined);
});
}
test('CB2-D: on scratch 1.27.1 an owner-issued copy token is proved live: equal and narrower bind; broader, all, admin, foreign, unrelated and opaque refuse', { skip, timeout: 300000 }, async (t) => {
// Capture the child's complete TAP stdout/stderr before any of it reaches this runner, so the parent
// can search for the actual minted values without putting one in an assertion.
if (!process.env.CB2_COPY_CAPTURE) {
const capture = mkdtempSync(join(tmpdir(), 'cb2cap-'));
t.after(() => rmSync(capture, { recursive: true, force: true }));
const file = join(capture, 'minted');
const childEnv = { ...process.env, CB2_COPY_CAPTURE: file };
delete childEnv.NODE_TEST_CONTEXT;
const child = spawnSync(process.execPath, ['--test', '--test-name-pattern=^CB2-D:', fileURLToPath(import.meta.url)], {
encoding: 'utf8', timeout: 280000, env: childEnv,
});
const values = existsSync(file) ? JSON.parse(readFileSync(file, 'utf8')) : [];
const output = (child.stdout ?? '') + (child.stderr ?? '');
assert.ok(values.every((value) => !output.includes(value) && !output.includes(sha(value))), 'complete TAP stdout/stderr contain no minted token');
t.diagnostic(output);
t.diagnostic(`minted ${values.length}, hits 0 (complete TAP stdout/stderr)`);
assert.equal(child.status, 0, 'Docker child passed');
assert.equal(values.length, 6, 'all six scratch tokens were minted: the acceptance actually ran');
return;
}
const dir = mkdtempSync(join(tmpdir(), 'cb2d-'));
t.after(() => rmSync(dir, { recursive: true, force: true }));
const tlsDir = join(dir, 'tls'), tls = scratchCa(tlsDir);
// The container's git user reads the scratch key; it is a throwaway key for this run only.
chmodSync(tlsDir, 0o755);
chmodSync(join(tlsDir, 'server.key'), 0o644);
const ca = readFileSync(tls.ca, 'utf8'), port = await freePort();
const url = `https://127.0.0.1:${port}`, name = 'cb2-' + randomBytes(6).toString('hex');
t.after(() => docker('rm', '-f', name));
const started = docker('run', '-d', '--rm', '--name', name, '-p', `127.0.0.1:${port}:3000`, '-v', `${tlsDir}:/certs:ro`,
'-e', 'GITEA__security__INSTALL_LOCK=true', '-e', 'GITEA__database__DB_TYPE=sqlite3',
'-e', 'GITEA__service__DISABLE_REGISTRATION=true', '-e', 'GITEA__server__PROTOCOL=https',
'-e', 'GITEA__server__CERT_FILE=/certs/server.pem', '-e', 'GITEA__server__KEY_FILE=/certs/server.key',
'-e', `GITEA__server__ROOT_URL=${url}/`, IMAGE);
assert.equal(started.status, 0, 'scratch container starts');
const end = Date.now() + 120000;
while ((await call(url, ca, 'GET', '/api/v1/version')).status !== 200) {
assert.ok(Date.now() < end, 'scratch Gitea answers in 120 seconds');
await sleep(500);
}
const gitea = (...args) => docker('exec', '-u', 'git', name, 'gitea', ...args);
const minted = [];
const createUser = (user, admin = false) => {
const r = gitea('admin', 'user', 'create', '--username', user, '--email', `${user}@example.test`,
'--password', randomBytes(16).toString('base64url'), '--must-change-password=false', ...(admin ? ['--admin'] : []));
assert.equal(r.status, 0, `create scratch ${user}`);
};
const mint = (user, scopes) => {
const r = gitea('admin', 'user', 'generate-access-token', '--username', user, '--token-name', 'cb2-' + randomBytes(4).toString('hex'),
'--scopes', scopes.join(','), '--raw');
assert.equal(r.status, 0, `mint scratch ${user}`);
const value = r.stdout.trim();
assert.ok(/^[0-9a-f]{40}$/.test(value), 'minted token shape');
minted.push(value);
writeFileSync(process.env.CB2_COPY_CAPTURE, JSON.stringify(minted), { mode: 0o600 });
return value;
};
createUser('scratch-admin', true);
createUser('owner');
const admin = 'token ' + mint('scratch-admin', ['write:admin', 'write:repository', 'write:user']);
for (const repo of ['stack', 'other']) {
const made = await call(url, ca, 'POST', '/api/v1/admin/users/owner/repos', { auth: admin, body: { name: repo, auto_init: true, private: true } });
assert.equal(made.status, 201, `create owner/${repo}`);
}
// The organization: one root agent "rev" whose copy binding approves write:repository and
// read:user on project stack, whose remote is the scratch owner/stack.
const root = mkdtempSync(join(tmpdir(), 'cb2o-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const rolesDir = rolesCopy(root);
const doc = { ...businessDoc(root, 'demo'), plugins: { harness: ['pi'] }, vars: { 'bus.tester': true } };
doc.projects.stack.remote = `${url}/owner/stack`;
const file = writeJson(join(root, 'businesses/demo.json'), doc);
const dataRoot = join(root, 'data');
mkdirSync(dataRoot, { mode: 0o700 });
const ctx = { configDir: root, dataRoot, instance: 'test' };
await reserveOrg(ctx, 'demo');
await activateOrg(ctx, 'demo');
const snapshot = materializeCopies({ orgId: 'demo', doc, root: { state: 'ok', ...checkRootAgents({ agentsVersion: 1, agents: { rev: { definition: 'reviewer' } } }, { rolesDir }) }, rolesDir });
const writer = organizationWriter({ dir: root, rolesDir }), store = new Store(dataRoot);
t.after(() => store.close());
const service = hireDecisions({ writer, rolesDir, dataRoot, configDir: root, assertClean: () => {}, copies: (id) => (snapshot.org === id ? snapshot : null) });
const loaded = () => {
const d = loadBusiness('demo', { dir: root, rolesDir });
// The boot's copies view, as the runtime's loadWithCopies gives the broker.
return { ...d, roles: Object.fromEntries(Object.entries(d.roles).map(([n, e]) => [n, { ...d.definitions[e.definition], ...e }])), copies: copiesView(snapshot.copies) };
};
const storeRoot = join(root, 'secrets', 'agents');
mkdirSync(storeRoot, { recursive: true, mode: 0o700 });
chmodSync(join(root, 'secrets'), 0o700);
const credentials = new Credentials({ references: {}, storeRoot, repoRoots: [], dataRoot: '/nonexistent-data' });
const issue = copyGrant({ credentials, writer, copies: snapshot, organizations: () => ['demo'], ca, timeoutMs: 10000 });
const b = new Broker({
store, businesses: { demo: loaded() },
hires: { ...service, apply: async (...args) => { const r = await service.apply(...args); b.reloadBusiness('demo', loaded()); return r; } },
credentialVerbs: () => { throw new BusError('credential-unknown'); }, copyGrant: (...a) => issue(...a),
});
const consoleCap = b.bindConsole({ business: 'demo', human: 'jason', subject: SUBJECT });
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const request_ = (cap, verb, args) => Promise.resolve().then(() => b.request(cap, { verb, args }));
await request_(consoleCap, 'hire.start.console', { op: 'change', level: 'root', name: 'rev', reason: 'Bind the copy',
binding: { services: { gitea: { scopes: ['write:repository', 'read:user'], projects: ['stack'] } } } });
const rec = () => JSON.parse(readFileSync(file, 'utf8')).copyBindings.rev;
assert.equal(rec().state, 'pending');
const slot = join(storeRoot, 'demo', 'rev', 'gitea');
const issueToken = (token) => request_(human, 'credentials.issue', { agent: 'rev', service: 'gitea', date: ahead(30), token });
// The copy's dedicated account: a read collaborator on owner/stack only.
const login = copyBotLogin(rec().orgIncarnation, rec().copyId, 'gitea');
createUser(login);
const collaborate = (repo, permission) => call(url, ca, 'PUT', `/api/v1/repos/owner/${repo}/collaborators/${login}`, { auth: admin, body: { permission } });
assert.equal((await collaborate('stack', 'read')).status, 204, 'bot is a read collaborator on owner/stack');
const refused = async (token, code, label) => {
await assert.rejects(issueToken(token), { code }, label);
assert.equal(rec().state, 'pending', label);
assert.equal(rec().services.gitea.grantId, undefined, label);
assert.ok(!existsSync(slot), `${label}: a refused token leaves no slot`);
};
// Equal: the approved scopes exactly.
const equal = mint(login, ['write:repository', 'read:user']);
const self = await call(url, ca, 'GET', '/api/v1/token', { auth: 'token ' + equal });
assert.equal(self.status, 200);
assert.equal(self.json.user.login, login, 'Gitea 1.27.1 names the token owner on /api/v1/token');
const one = await issueToken(equal);
assert.equal(one.state, 'bound');
assert.equal(one.account, login);
assert.deepEqual([rec().state, rec().services.gitea.grantId, rec().services.gitea.grantGeneration], ['bound', `gitea:${self.json.user.id}:${self.json.id}`, 1]);
assert.equal(statSync(slot).mode & 0o777, 0o600);
assert.equal(readFileSync(slot, 'utf8'), equal + '\n');
// Narrower: read within the approved write.
const narrow = mint(login, ['read:repository', 'read:user']);
await issueToken(narrow);
assert.deepEqual([rec().state, rec().services.gitea.grantGeneration], ['bound', 2]);
assert.equal(readFileSync(slot, 'utf8'), narrow + '\n');
// Broader in one category, and "all": exceeded; a failed re-issue deactivates the binding.
await refused(mint(login, ['write:repository', 'write:user']), 'credential-scope-exceeded', 'write:user beyond read:user');
await refused(mint(login, ['all']), 'credential-scope-exceeded', 'all');
// The administrator's own token, inside the approved scopes: not the copy's account.
await refused(mint('scratch-admin', ['read:repository', 'read:user']), 'credential-identity-refused', 'admin account');
// A token Gitea never issued.
await refused(randomBytes(20).toString('hex'), 'credential-scope-unverified', 'opaque token');
// An unrelated repository the account can also reach.
assert.equal((await collaborate('other', 'read')).status, 204);
await refused(narrow, 'credential-scope-exceeded', 'unrelated repository');
// Nothing kept names a token, its digest, the slot path or a request.
const kept = [file, join(root, 'businesses/.history/demo/changes.jsonl')].filter(existsSync).map((p) => readFileSync(p, 'utf8')).join('\n')
+ ['events', 'decisions'].map((tb) => JSON.stringify(store.all(`SELECT * FROM ${tb}`))).join('\n');
for (const value of minted) assert.ok(!kept.includes(value) && !kept.includes(sha(value)) && !kept.includes(sha(value + '\n')), 'kept text holds no token');
for (const bad of [slot, storeRoot, '/api/v1', 'authorization']) assert.ok(!kept.includes(bad), 'kept text holds no path or request');
t.diagnostic(`minted ${minted.length}`);
});
+558
View File
@@ -0,0 +1,558 @@
// #1873 CB2 (design section 5 steps 4 to 6, section 8 row CB2): credentials.issue for an agent copy's
// bound Gitea service, verified live against a fake Gitea before any grant metadata lands. The
// organization writer's half is packages/organization/tests/copy-grant-writer.test.mjs; the real
// server acceptance is copy-grant-docker.test.mjs.
import test from 'node:test';
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
import { chmodSync, existsSync, linkSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, renameSync, rmSync, statSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Broker, BusError } from '../src/broker.mjs';
import { Store } from '../src/store.mjs';
import { Credentials } from '../src/credentials.mjs';
import { REFUSALS } from '../src/refusals.mjs';
import { copyGrant, tokenLevels, verifyGiteaToken, boundRepositories, MAX_ROTATE_DAYS, sweepCopySlots } from '../src/copy-grant.mjs';
import { fakeGiteaApi } from './fake-gitea-api.mjs';
import { hireDecisions } from '../../organization/src/hire-decisions.mjs';
import { organizationWriter } from '../../organization/src/organization.mjs';
import { loadBusiness } from '../../organization/src/organization-config.mjs';
import { checkRootAgents } from '../../organization/src/agent-levels.mjs';
import { materializeCopies, copiesView } from '../../organization/src/agent-copies.mjs';
import { activateOrg, reserveOrg } from '../../organization/src/org-registry.mjs';
import { copyBotLogin } from '../../organization/src/copy-bindings.mjs';
import { businessDoc, rolesCopy, writeJson } from '../../organization/tests/helpers.mjs';
const SUBJECT = { provider: 'test', iss: 'https://issuer.test', sub: 'owner' };
const READ = { gitea: { scopes: ['read:repository', 'read:user'], projects: ['stack'] } };
const ahead = (days) => new Date(Date.now() + days * 86400000).toISOString().slice(0, 10);
const present = (p) => { try { lstatSync(p); return true; } catch { return false; } };
const sha = (s) => createHash('sha256').update(s).digest('hex');
async function fixture(t, { services = READ, edit = (d) => d } = {}) {
const root = mkdtempSync(join(tmpdir(), 'cb2-')), rolesDir = rolesCopy(root);
const api = await fakeGiteaApi(t, root);
const base = { ...businessDoc(root, 'demo'), plugins: { harness: ['pi'] }, vars: { 'bus.tester': true } };
base.projects.stack.agents = { checker: { definition: 'reviewer' } };
base.projects.stack.remote = `${api.url}/owner/stack`;
const doc = edit(base);
const file = writeJson(join(root, 'businesses/demo.json'), doc);
const dataRoot = join(root, 'data'); mkdirSync(dataRoot, { mode: 0o700 });
const ctx = { configDir: root, dataRoot, instance: 'test' };
await reserveOrg(ctx, 'demo'); await activateOrg(ctx, 'demo');
const snapshot = materializeCopies({ orgId: 'demo', doc, root: { state: 'ok', ...checkRootAgents({ agentsVersion: 1, agents: { rev: { definition: 'reviewer' } } }, { rolesDir }) }, rolesDir });
const writer = organizationWriter({ dir: root, rolesDir }), store = new Store(dataRoot);
const service = hireDecisions({ writer, rolesDir, dataRoot, configDir: root, assertClean: () => {}, copies: (id) => snapshot.org === id ? snapshot : null });
const loaded = () => { const d = loadBusiness('demo', { dir: root, rolesDir }); return { ...d, roles: Object.fromEntries(Object.entries(d.roles).map(([n, e]) => [n, { ...d.definitions[e.definition], ...e }])), copies: copiesView((opts.copies ?? snapshot).copies) }; };
const storeRoot = join(root, 'secrets', 'agents');
mkdirSync(storeRoot, { recursive: true, mode: 0o700 }); chmodSync(join(root, 'secrets'), 0o700);
const credentials = new Credentials({ references: {}, storeRoot, repoRoots: [], dataRoot: '/nonexistent-data' });
const hooks = {};
const opts = { timeoutMs: 3000 };
const issue = (...a) => copyGrant({ credentials, writer, copies: opts.copies ?? snapshot, organizations: () => ['demo'], ca: api.ca, timeoutMs: opts.timeoutMs, hooks })(...a);
const direct = (args, ctx) => issue('demo', args, ctx);
const b = new Broker({ store, businesses: { demo: loaded() }, hires: { ...service, apply: async (...args) => { const r = await service.apply(...args); b.reloadBusiness('demo', loaded()); return r; } },
credentialVerbs: () => { throw new BusError('credential-unknown'); }, copyGrant: (...a) => issue(...a) });
const owner = b.bindConsole({ business: 'demo', human: 'jason', subject: SUBJECT });
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const chief = b.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' });
b.request(chief, { verb: 'role.claim' });
const call = (cap, verb, args = {}) => Promise.resolve().then(() => b.request(cap, { verb, args }));
const bind = (name = 'rev', svc = services, extra = {}) => call(owner, 'hire.start.console', { op: 'change', level: 'root', name, reason: 'Bind the copy', binding: { services: svc }, ...extra });
await bind();
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
const rec = (name = 'rev') => JSON.parse(readFileSync(file, 'utf8')).copyBindings?.[name];
const repo = api.addRepo('owner', 'stack');
// The copy's dedicated account, a read collaborator on owner/stack, and one token from it.
const bot = (name = 'rev', { perm = 'read', admin = false } = {}) => {
const r = rec(name);
const login = copyBotLogin(r.orgIncarnation, r.copyId, 'gitea');
const u = api.users.get(login) ?? api.addUser(login, { is_admin: admin }).user;
repo.collaborators.push(login); repo.permissions[login] = perm;
return u;
};
const token = (u, scopes = ['read:repository', 'read:user'], o) => api.addToken(u.login, `t${api.issued.length}`, scopes, o);
const slot = (name = 'rev') => join(storeRoot, 'demo', name, 'gitea');
const issueAs = (cap, args) => call(cap, 'credentials.issue', { agent: 'rev', service: 'gitea', date: ahead(30), ...args });
return { root, file, api, b, store, writer, credentials, hooks, opts, direct, owner, human, chief, call, bind, rec, bot, token, slot, repo, storeRoot, issueAs, loaded, snapshot };
}
// Everything the bus or the organization keeps after an issue: the document, the receipts, the store.
function kept(f) {
const files = [f.file, join(f.root, 'businesses/.history/demo/changes.jsonl')].filter(existsSync).map((p) => readFileSync(p, 'utf8'));
const rows = ['events', 'decisions'].map((tb) => JSON.stringify(f.store.all(`SELECT * FROM ${tb}`)));
return files.join('\n') + rows.join('\n');
}
test('CB2 an owner-issued copy token is verified live, staged and bound; nothing kept names the token or its path', async (t) => {
const f = await fixture(t);
assert.equal(f.rec().state, 'pending');
const u = f.bot();
const tok = f.token(u);
const date = ahead(30);
const result = await f.issueAs(f.human, { token: tok.sha1, date });
assert.deepEqual(result, { agent: 'rev', service: 'gitea', verb: 'issue', date, state: 'bound', account: u.login, generation: result.generation });
const r = f.rec();
assert.equal(r.state, 'bound');
assert.deepEqual(r.services.gitea, { slot: 'self', scopes: ['read:repository', 'read:user'], resources: { projects: ['stack'] }, rotateBy: date, grantId: `gitea:${u.id}:${tok.id}`, grantGeneration: 1 });
assert.equal(statSync(f.slot()).mode & 0o777, 0o600);
assert.equal(readFileSync(f.slot(), 'utf8'), tok.sha1 + '\n');
// Only GETs went to Gitea, every one as the copy's account.
assert.ok(f.api.requests.length > 0);
for (const req of f.api.requests) {
assert.equal(req.method, 'GET');
if (req.path !== '/api/v1/version') assert.equal(req.as, u.login);
}
const text = kept(f) + JSON.stringify(result);
for (const bad of [tok.sha1, sha(tok.sha1), sha(tok.sha1 + '\n'), f.slot(), f.storeRoot, '/api/v1', 'authorization']) assert.ok(!text.includes(bad), 'kept text holds a secret, path or request');
// Copies still cannot launch in CB2: the snapshot does not resolve a gitea-only copy (CB3 owns launch).
});
test('CB2 narrower and equal scopes are accepted; any broader token is exceeded and leaves the record inactive', async (t) => {
const f = await fixture(t, { services: { gitea: { scopes: ['write:repository', 'read:user'], projects: ['stack'] } } });
const u = f.bot('rev', { perm: 'write' });
for (const scopes of [['write:repository', 'write:user'], ['all'], ['write:admin', 'read:user'], ['read:repository', 'read:user', 'read:issue'], ['read:user', 'read:misc']]) {
const tok = f.token(u, scopes);
await assert.rejects(f.issueAs(f.human, { token: tok.sha1 }), { code: 'credential-scope-exceeded' }, scopes.join());
assert.equal(f.rec().state, 'pending', scopes.join());
assert.equal(f.rec().services.gitea.grantId, undefined);
assert.ok(!existsSync(f.slot()), 'a refused token leaves no slot');
}
// Equal.
await f.issueAs(f.human, { token: f.token(u, ['write:repository', 'read:user']).sha1 });
assert.equal(f.rec().state, 'bound');
// Narrower (read within the approved write) re-issues; the grant generation counts up.
f.repo.permissions[u.login] = 'read';
const narrow = f.token(u, ['read:repository', 'read:user']);
await f.issueAs(f.human, { token: narrow.sha1 });
assert.deepEqual([f.rec().state, f.rec().services.gitea.grantGeneration, f.rec().services.gitea.grantId], ['bound', 2, `gitea:${u.id}:${narrow.id}`]);
// A failed re-issue after bound deactivates the binding and removes its slot.
await assert.rejects(f.issueAs(f.human, { token: f.token(u, ['all']).sha1 }), { code: 'credential-scope-exceeded' });
assert.deepEqual([f.rec().state, f.rec().services.gitea.grantId, existsSync(f.slot())], ['pending', undefined, false]);
});
test('CB2 read is narrower than write within a category; categories are independent; push needs write', async (t) => {
assert.deepEqual(tokenLevels(['read:repository', 'write:repository', 'read:user']), { repository: 'write', user: 'read' });
assert.deepEqual(tokenLevels(['write:issue', 'read:issue']), { issue: 'write' });
for (const bad of [['all'], ['write:admin'], ['read:admin'], ['read:sudo'], ['repository'], [], null, [7]]) assert.equal(tokenLevels(bad), null, JSON.stringify(bad));
const f = await fixture(t);
// The binding approved read:repository; the account can push, so the token reaches more than read.
const u = f.bot('rev', { perm: 'write' });
await assert.rejects(f.issueAs(f.human, { token: f.token(u).sha1 }), { code: 'credential-scope-exceeded' });
// write:issue is another category: read:repository never approves it.
f.repo.permissions[u.login] = 'read';
await assert.rejects(f.issueAs(f.human, { token: f.token(u, ['read:repository', 'read:user', 'write:issue']).sha1 }), { code: 'credential-scope-exceeded' });
await f.issueAs(f.human, { token: f.token(u).sha1 });
assert.equal(f.rec().state, 'bound');
});
test('CB2 push is held to the binding, not the token: a narrower token binds for a push account on a write binding', async () => {
const push = { '/api/v1/user/repos': { status: 200, json: [{ full_name: 'owner/stack', permissions: { admin: false, push: true, pull: true } }], total: 1 } };
await assert.rejects(V(scripted(push).transport), { code: 'credential-scope-exceeded' });
const write = { approved: ['write:repository', 'read:user'] };
assert.deepEqual(await V(scripted(push).transport, write), { userId: 5, tokenId: 9, login: 'bot' });
const writeToken = { '/api/v1/token': { status: 200, json: { id: 9, scopes: ['write:repository', 'read:user'], user: { id: 5, login: 'bot' } } } };
assert.deepEqual(await V(scripted({ ...push, ...writeToken }).transport, write), { userId: 5, tokenId: 9, login: 'bot' });
await assert.rejects(V(scripted({ ...push, ...writeToken }).transport), { code: 'credential-scope-exceeded' });
});
test('CB2 the boot copy must still match the record: a missing, refused or changed copy refuses before anything is staged or sent', async (t) => {
const f = await fixture(t);
const tok = f.token(f.bot());
const rev = f.snapshot.copies.rev;
const cases = [
[{ ...f.snapshot, copies: {} }, 'copy-unavailable'],
[{ ...f.snapshot, org: 'other' }, 'copy-unavailable'],
[{ ...f.snapshot, copies: { rev: { ...rev, state: 'refused' } } }, 'copy-unavailable'],
[{ ...f.snapshot, copies: { rev: { ...rev, level: 'project', project: 'stack' } } }, 'credential-binding-changed'],
[{ ...f.snapshot, copies: { rev: { ...rev, name: 'other' } } }, 'credential-binding-changed'],
[{ ...f.snapshot, copies: { rev: { ...rev, definition: 'coder' } } }, 'credential-binding-changed'],
];
const before = kept(f), sent = f.api.requests.length;
for (const [copies, code] of cases) {
f.opts.copies = copies;
await assert.rejects(f.direct({ agent: 'rev', service: 'gitea', token: tok.sha1, date: ahead(30) }, { actor: 'jason', owner: true }), { code }, JSON.stringify(copies.copies?.rev ?? copies.org));
}
f.opts.copies = null;
// No record for the name, or a name the organization's own roles hold.
await assert.rejects(f.direct({ agent: 'nobody', service: 'gitea', token: tok.sha1, date: ahead(30) }, { actor: 'jason', owner: true }), { code: 'credential-unbound' });
f.opts.copies = { ...f.snapshot, copies: { ...f.snapshot.copies, chief: { ...rev, name: 'chief' } } };
const { document } = f.writer.read('demo');
const shadow = { ...f.writer, read: () => ({ ...f.writer.read('demo'), document: { ...document, copyBindings: { ...document.copyBindings, chief: document.copyBindings.rev } } }) };
await assert.rejects(copyGrant({ credentials: f.credentials, writer: shadow, copies: f.opts.copies, organizations: () => ['demo'] })('demo', { agent: 'chief', service: 'gitea', token: tok.sha1, date: ahead(30) }, { actor: 'jason', owner: true }), { code: 'copy-unavailable' });
f.opts.copies = null;
assert.deepEqual([kept(f) === before, f.api.requests.length, present(join(f.storeRoot, 'demo'))], [true, sent, false]);
await f.issueAs(f.human, { token: tok.sha1 });
assert.equal(f.rec().state, 'bound');
});
test('CB2 stageCopy refuses a name a loaded reference holds and an unknown service, leaving the file alone', async (t) => {
const root = mkdtempSync(join(tmpdir(), 'cb2-ref-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const storeRoot = join(root, 'agents');
mkdirSync(join(storeRoot, 'demo', 'rev'), { recursive: true, mode: 0o700 }); chmodSync(storeRoot, 0o700); chmodSync(join(storeRoot, 'demo'), 0o700);
const file = join(storeRoot, 'demo', 'rev', 'gitea');
writeFileSync(file, 'r'.repeat(40) + '\n', { mode: 0o600 });
const credentials = new Credentials({ references: { 'demo/rev': { gitea: { service: 'gitea', store: true, file, rotateBy: ahead(30) } } }, storeRoot, repoRoots: [], dataRoot: '/nonexistent-data' });
assert.throws(() => credentials.stageCopy('demo/rev', 'gitea', 'a'.repeat(40)), { code: 'credential-reference' });
assert.equal(readFileSync(file, 'utf8'), 'r'.repeat(40) + '\n');
assert.throws(() => credentials.stageCopy('demo/other', 'model', 'a'.repeat(40)), { code: 'credential-reference' });
assert.equal(present(join(storeRoot, 'demo', 'other')), false);
});
test('CB2 a Vikunja binding refuses credential-scope-unverified before anything is staged or sent', async (t) => {
const vik = { tasks: ['read_one'] };
const f = await fixture(t, { services: { gitea: READ.gitea, vikunja: { scopes: vik, projects: ['stack'] } } });
const before = readFileSync(f.file, 'utf8');
await assert.rejects(f.issueAs(f.human, { service: 'vikunja', token: 'v'.repeat(40) }), { code: 'credential-scope-unverified' });
assert.equal(readFileSync(f.file, 'utf8'), before);
assert.equal(f.api.requests.length, 0);
assert.ok(!existsSync(join(f.storeRoot, 'demo', 'rev')));
// With a Gitea grant the record still waits on Vikunja: pending, never bound.
await f.issueAs(f.human, { token: f.token(f.bot()).sha1 });
assert.equal(f.rec().state, 'pending');
assert.ok(f.rec().services.gitea.grantId);
assert.ok(REFUSALS['credential-scope-unverified'].nextStep.includes('Vikunja'));
});
test('CB2 unrelated repositories, admin rights or a missing bound repository refuse', async (t) => {
const f = await fixture(t);
const u = f.bot();
const tok = f.token(u);
const other = f.api.addRepo('someone', 'private', { collaborators: [u.login] });
other.permissions[u.login] = 'read';
await assert.rejects(f.issueAs(f.human, { token: tok.sha1 }), { code: 'credential-scope-exceeded' });
other.collaborators.length = 0;
const own = f.api.addRepo(u.login, 'scratch');
await assert.rejects(f.issueAs(f.human, { token: tok.sha1 }), { code: 'credential-scope-exceeded' });
f.api.repos.splice(f.api.repos.indexOf(own), 1);
f.repo.collaborators.length = 0;
await assert.rejects(f.issueAs(f.human, { token: tok.sha1 }), { code: 'credential-scope-unverified' });
assert.equal(f.rec().state, 'pending');
f.repo.collaborators.push(u.login);
await f.issueAs(f.human, { token: tok.sha1 });
assert.equal(f.rec().state, 'bound');
});
// A scripted Gitea for the verifier's own branches: paging, organizations and malformed answers.
function scripted(over = {}) {
const base = {
'/api/v1/version': { status: 200, json: { version: '1.27.1' } },
'/api/v1/token': { status: 200, json: { id: 9, scopes: ['read:repository', 'read:user'], user: { id: 5, login: 'bot' } } },
'/api/v1/user': { status: 200, json: { id: 5, login: 'bot', is_admin: false } },
'/api/v1/user/repos': { status: 200, json: [{ full_name: 'owner/stack', permissions: { admin: false, push: false, pull: true } }], total: 1 },
'/api/v1/user/orgs': { status: 200, json: [], total: 0 },
...over,
};
const seen = [];
const transport = async ({ path, method }) => {
seen.push(`${method} ${path}`);
const key = path.split('?')[0];
const a = base[key];
return typeof a === 'function' ? a(path) : { total: null, ...a };
};
return { transport, seen };
}
const V = (transport, extra = {}) => verifyGiteaToken({ transport, url: 'https://127.0.0.1:1', token: 'x'.repeat(40), approved: ['read:repository', 'read:user', 'read:organization'], repos: new Set(['owner/stack']), login: 'bot', rotateBy: ahead(30), ...extra });
test('CB2 verifier: paging must agree, organizations are refused with an organization scope', async () => {
assert.deepEqual(await V(scripted().transport), { userId: 5, tokenId: 9, login: 'bot' });
const pages = (p1, p2) => (path) => (path.includes('page=1') ? p1 : p2);
const repo = (n) => ({ full_name: `owner/${n}`, permissions: { admin: false, push: false, pull: true } });
const two = new Set(['owner/stack', 'owner/b']);
const many = Array.from({ length: 50 }, (_, i) => repo(`r${i}`));
const fifty = new Set(many.map((r) => r.full_name.toLowerCase()));
// A total that changes between pages, a page that comes back short, and no total at all.
await assert.rejects(V(scripted({ '/api/v1/user/repos': pages({ status: 200, json: many, total: 51 }, { status: 200, json: [repo('x')], total: 52 }) }).transport, { repos: fifty }), { code: 'credential-scope-unverified' });
await assert.rejects(V(scripted({ '/api/v1/user/repos': pages({ status: 200, json: many, total: 52 }, { status: 200, json: [], total: 52 }) }).transport, { repos: fifty }), { code: 'credential-scope-unverified' });
await assert.rejects(V(scripted({ '/api/v1/user/repos': { status: 200, json: [repo('stack')], total: null } }).transport), { code: 'credential-scope-unverified' });
await assert.rejects(V(scripted({ '/api/v1/user/repos': { status: 200, json: [repo('stack'), repo('b')], total: 2 } }).transport), { code: 'credential-scope-exceeded' });
assert.ok(await V(scripted({ '/api/v1/user/repos': { status: 200, json: [repo('stack'), repo('b')], total: 2 } }).transport, { repos: two }));
await assert.rejects(V(scripted({ '/api/v1/user/repos': { status: 200, json: [{ full_name: 'owner/stack', permissions: { push: false, pull: true } }], total: 1 } }).transport), { code: 'credential-scope-exceeded' });
await assert.rejects(V(scripted({ '/api/v1/user/repos': { status: 200, json: [{ full_name: 'owner/stack' }], total: 1 } }).transport), { code: 'credential-scope-unverified' });
// Organizations: only asked when the token has an organization level.
const plain = scripted({ '/api/v1/user/orgs': { status: 200, json: [{ id: 1, username: 'acme' }], total: 1 } });
assert.ok(await V(plain.transport));
assert.ok(!plain.seen.some((s) => s.includes('/user/orgs')));
const orgTok = { '/api/v1/token': { status: 200, json: { id: 9, scopes: ['read:organization', 'read:repository', 'read:user'], user: { id: 5, login: 'bot' } } } };
assert.ok(await V(scripted(orgTok).transport));
await assert.rejects(V(scripted({ ...orgTok, '/api/v1/user/orgs': { status: 200, json: [{ id: 1, username: 'acme' }], total: 1 } }).transport), { code: 'credential-scope-exceeded' });
await assert.rejects(V(scripted({ ...orgTok, '/api/v1/user/orgs': { status: 500, json: null } }).transport), { code: 'credential-scope-unverified' });
// The bound projects' remotes: one https Gitea origin, agreeing remotes.
const doc = (projects) => ({ projects });
assert.deepEqual([...boundRepositories(doc({ a: { remote: 'https://127.0.0.1:9/Owner/Stack' } }), ['a']).repos], ['owner/stack']);
for (const [d, ps] of [[doc({ a: { remote: 'http://127.0.0.1:9/o/r' } }), ['a']], [doc({ a: {} }), ['a']], [doc({}), ['a']], [doc({ a: { remote: 'https://127.0.0.1:9/o/r' }, b: { remote: 'https://127.0.0.2:9/o/r' } }), ['a', 'b']], [doc({}), []],
[doc({ a: { remote: 'https://127.0.0.1:9/o/r', git: { mode: 'remote', remote: 'https://127.0.0.1:9/o/s' } } }), ['a']]])
assert.throws(() => boundRepositories(d, ps), { code: 'credential-scope-unverified' });
});
test('CB2 a foreign account, an administrator or another copy\'s token is identity-refused', async (t) => {
const f = await fixture(t);
const u = f.bot();
const foreign = f.api.addUser('someone-else').user;
f.repo.collaborators.push('someone-else');
for (const tok of [f.token(foreign), { sha1: f.api.adminToken }]) {
await assert.rejects(f.issueAs(f.human, { token: tok.sha1 }), { code: 'credential-identity-refused' });
assert.deepEqual([f.rec().state, existsSync(f.slot())], ['pending', false]);
}
// The copy's own login, made an administrator.
u.is_admin = true;
await assert.rejects(f.issueAs(f.human, { token: f.token(u).sha1 }), { code: 'credential-identity-refused' });
u.is_admin = false;
// /user names another account than /token did.
const undo = f.api.rule((e) => e.path === '/api/v1/user', { status: 200, json: { id: u.id + 1000, login: u.login, is_admin: false } });
await assert.rejects(f.issueAs(f.human, { token: f.token(u).sha1 }), { code: 'credential-identity-refused' });
undo();
// Another copy's account and token: checker.stack is bound in the same organization.
await f.call(f.owner, 'hire.start.console', { op: 'change', level: 'project', project: 'stack', name: 'checker', reason: 'Bind', binding: { services: READ } });
const checker = f.bot('checker.stack');
const theirs = f.token(checker);
await assert.rejects(f.issueAs(f.human, { token: theirs.sha1 }), { code: 'credential-identity-refused' });
await f.issueAs(f.human, { agent: 'checker.stack', token: theirs.sha1 });
assert.equal(f.rec('checker.stack').state, 'bound');
assert.equal(f.rec().state, 'pending');
assert.notEqual(checker.login, u.login);
});
test('CB2 an opaque or unverifiable token refuses unverified; a malformed one refuses credential-format', async (t) => {
const f = await fixture(t);
const u = f.bot();
// A malformed token refuses before anything is staged or sent (build review 2): the bound record is
// unchanged, nothing reached Gitea and no slot exists.
const before = kept(f), sent = f.api.requests.length;
await assert.rejects(f.issueAs(f.human, { token: 'short' }), { code: 'credential-format' });
await assert.rejects(f.issueAs(f.human, { token: 'has space ' + 'x'.repeat(30) }), { code: 'credential-format' });
assert.deepEqual([kept(f) === before, f.api.requests.length, present(join(f.storeRoot, 'demo', 'rev'))], [true, sent, false]);
await assert.rejects(f.issueAs(f.human, { token: 'f'.repeat(40) }), { code: 'credential-scope-unverified' });
const tok = f.token(u);
for (const answer of [{ status: 200, json: { id: tok.id, scopes: tok.scopes } }, { status: 200, json: { id: tok.id, scopes: tok.scopes, user: { login: u.login } } }, { status: 500, json: null }, { drop: true }]) {
const undo = f.api.rule((e) => e.path === '/api/v1/token', answer);
await assert.rejects(f.issueAs(f.human, { token: tok.sha1 }), { code: 'credential-scope-unverified' }, JSON.stringify(answer));
undo();
}
f.opts.timeoutMs = 300;
const hang = f.api.rule((e) => e.path.startsWith('/api/v1/user/repos'), { hang: true });
await assert.rejects(f.issueAs(f.human, { token: tok.sha1 }), { code: 'credential-scope-unverified' });
hang();
f.opts.timeoutMs = 3000;
f.api.state.version = '1.26.0';
await assert.rejects(f.issueAs(f.human, { token: tok.sha1 }), { code: 'credential-scope-unverified' });
f.api.state.version = '1.27.1';
// The token lacks read:user, which the binding approved: its repositories cannot be listed.
await assert.rejects(f.issueAs(f.human, { token: f.token(u, ['read:repository']).sha1 }), { code: 'credential-scope-unverified' });
assert.deepEqual([f.rec().state, existsSync(f.slot())], ['pending', false]);
await f.issueAs(f.human, { token: tok.sha1 });
assert.equal(f.rec().state, 'bound');
});
test('CB2 a token any definition already holds refuses before anything is staged or sent', async (t) => {
const f = await fixture(t);
const tok = f.token(f.bot());
const before = readFileSync(f.file, 'utf8');
// The boot copy snapshot's definitions are live text for the bus's lifetime.
f.opts.copies = { ...f.snapshot, definitions: { ...f.snapshot.definitions, leaky: { note: `see ${tok.sha1}` } } };
await assert.rejects(f.issueAs(f.human, { token: tok.sha1 }), { code: 'definition-holds-credential' });
assert.deepEqual([readFileSync(f.file, 'utf8') === before, existsSync(f.slot()), f.api.requests.length], [true, false, 0]);
f.opts.copies = null;
await f.issueAs(f.human, { token: tok.sha1 });
assert.equal(f.rec().state, 'bound');
});
test('CB2 a binding without read:user is unverifiable before anything is staged', async (t) => {
const f = await fixture(t, { services: { gitea: { scopes: ['read:repository'], projects: ['stack'] } } });
const before = readFileSync(f.file, 'utf8');
await assert.rejects(f.issueAs(f.human, { token: f.token(f.bot()).sha1 }), { code: 'credential-scope-unverified' });
assert.equal(readFileSync(f.file, 'utf8'), before);
assert.equal(f.api.requests.length, 0);
});
test('CB2 dates: the rotation date is after today, at most a year ahead, and not after the token expires', async (t) => {
const f = await fixture(t);
const u = f.bot();
const tok = f.token(u);
const before = readFileSync(f.file, 'utf8');
for (const date of [ahead(0), ahead(-1), ahead(MAX_ROTATE_DAYS + 1), '2099-02-30', '2099-1-01', 'tomorrow'])
await assert.rejects(f.issueAs(f.human, { token: tok.sha1, date }), { code: 'credential-date' }, date);
assert.equal(readFileSync(f.file, 'utf8'), before);
assert.equal(f.api.requests.length, 0);
const soon = f.token(u, undefined, { expires: `${ahead(10)}T00:00:00Z` });
await assert.rejects(f.issueAs(f.human, { token: soon.sha1, date: ahead(10) }), { code: 'credential-date' });
await assert.rejects(f.issueAs(f.human, { token: soon.sha1, date: ahead(30) }), { code: 'credential-date' });
const odd = f.token(u, undefined, { expires: 'not-a-date' });
await assert.rejects(f.issueAs(f.human, { token: odd.sha1 }), { code: 'credential-scope-unverified' });
assert.deepEqual([f.rec().state, existsSync(f.slot())], ['pending', false]);
await f.issueAs(f.human, { token: soon.sha1, date: ahead(9) });
assert.equal(f.rec().services.gitea.rotateBy, ahead(9));
await f.issueAs(f.human, { token: f.token(u, undefined, { expires: `${ahead(400)}T00:00:00Z` }).sha1, date: ahead(MAX_ROTATE_DAYS) });
assert.equal(f.rec().state, 'bound');
});
test('CB2 the slot is read back by identity: symlinks, hard links, stale and replaced files refuse credential-file', async (t) => {
const f = await fixture(t);
const u = f.bot();
const tok = f.token(u);
const other = f.token(u);
const elsewhere = join(f.root, 'elsewhere');
const cases = {
symlink: ['afterStage', () => { writeFileSync(elsewhere, tok.sha1 + '\n', { mode: 0o600 }); unlinkSync(f.slot()); symlinkSync(elsewhere, f.slot()); }],
hardlink: ['afterStage', () => { rmSync(elsewhere, { force: true }); linkSync(f.slot(), elsewhere); }],
stale: ['afterStage', () => { renameSync(f.slot(), elsewhere); writeFileSync(f.slot(), tok.sha1 + '\n', { mode: 0o600 }); }],
mode: ['afterStage', () => chmodSync(f.slot(), 0o644)],
// A writer replaces a file by renaming a new one over it, as the store does.
replacedAfterRemote: ['afterRemote', () => { writeFileSync(elsewhere, other.sha1 + '\n', { mode: 0o600 }); renameSync(elsewhere, f.slot()); }],
replacedBeforeCommit: ['beforeCommit', () => { writeFileSync(elsewhere, other.sha1 + '\n', { mode: 0o600 }); renameSync(elsewhere, f.slot()); }],
// Unlinked and written again: the inode number may be reused, the content still differs.
reusedInode: ['afterStage', () => { unlinkSync(f.slot()); writeFileSync(f.slot(), other.sha1 + '\n', { mode: 0o600 }); }],
rewrittenInPlace: ['beforeCommit', () => writeFileSync(f.slot(), other.sha1 + '\n')],
};
for (const [name, [hook, act]] of Object.entries(cases)) {
rmSync(elsewhere, { force: true });
f.hooks[hook] = act;
await assert.rejects(f.issueAs(f.human, { token: tok.sha1 }), { code: 'credential-file' }, name);
delete f.hooks[hook];
assert.equal(f.rec().state, 'pending', name);
assert.equal(f.rec().services.gitea.grantId, undefined, name);
// The bus removes only the file it wrote; a file someone else put there stays.
if (['symlink', 'stale', 'replacedAfterRemote', 'replacedBeforeCommit'].includes(name)) assert.ok(present(f.slot()), name);
if (['hardlink', 'mode', 'rewrittenInPlace'].includes(name)) assert.ok(!present(f.slot()), name);
rmSync(f.slot(), { force: true });
}
await f.issueAs(f.human, { token: tok.sha1 });
assert.equal(f.rec().state, 'bound');
});
test('CB2 crash points leave the record pending and the slot removed; a new verified issue recovers', async (t) => {
const f = await fixture(t);
const u = f.bot();
const tok = f.token(u);
for (const hook of ['afterStage', 'afterRemote', 'beforeCommit']) {
f.hooks[hook] = () => { throw new Error('crash'); };
await assert.rejects(f.issueAs(f.human, { token: tok.sha1 }), { code: 'outcome-unknown' }, hook);
delete f.hooks[hook];
assert.deepEqual([f.rec().state, f.rec().services.gitea.grantId, existsSync(f.slot())], ['pending', undefined, false], hook);
}
// A hard crash after the stage (security review P2): the record is pending and the staged token is
// on disk. The next start's sweep removes it before anything loads; the record stays pending and
// only a fresh verified issue binds.
const r = f.rec();
const { revision } = f.writer.read('demo');
f.writer.stageCopyGrant('demo', { instance: 'rev', service: 'gitea', copyId: r.copyId, generation: r.generation, decision: r.decision, incarnation: r.orgIncarnation }, { revision, actor: 'jason', owner: true });
f.credentials.stageCopy('demo/rev', 'gitea', tok.sha1);
assert.equal(f.rec().state, 'pending');
assert.deepEqual(sweepCopySlots(f.storeRoot, [['demo', f.writer.read('demo').document]], {}), [{ instance: 'demo/rev', service: 'gitea' }]);
assert.deepEqual([f.rec().state, present(f.slot())], ['pending', false]);
await f.issueAs(f.human, { token: tok.sha1 });
assert.deepEqual([f.rec().state, f.rec().services.gitea.grantGeneration], ['bound', 1]);
// A lost reply: the commit landed but the caller never heard. Issuing again re-verifies.
await f.issueAs(f.human, { token: tok.sha1 });
assert.deepEqual([f.rec().state, f.rec().services.gitea.grantGeneration], ['bound', 2]);
});
test('CB2 the start sweep removes only ungranted copy slots and staged temp files', async (t) => {
const f = await fixture(t);
const u = f.bot();
const tok = f.token(u);
await f.issueAs(f.human, { token: tok.sha1 });
assert.equal(f.rec().state, 'bound');
const doc = () => [['demo', f.writer.read('demo').document]];
const copyDir = join(f.storeRoot, 'demo', 'rev');
// A granted slot stays; an interrupted write's temp file goes; a name that is no service stays.
writeFileSync(join(copyDir, 'gitea.tmp'), 'x', { mode: 0o600 });
writeFileSync(join(copyDir, 'notes'), 'x', { mode: 0o600 });
assert.deepEqual(sweepCopySlots(f.storeRoot, doc(), {}), [{ instance: 'demo/rev', service: 'gitea' }]);
assert.deepEqual([present(f.slot()), present(join(copyDir, 'gitea.tmp')), present(join(copyDir, 'notes'))], [true, false, true]);
// An ungranted service of a bound copy goes; a slot a loaded reference names stays.
writeFileSync(join(copyDir, 'vikunja'), 'x', { mode: 0o600 });
assert.deepEqual(sweepCopySlots(f.storeRoot, doc(), { 'demo/rev': { vikunja: { service: 'vikunja' } } }), []);
assert.deepEqual(sweepCopySlots(f.storeRoot, doc(), {}), [{ instance: 'demo/rev', service: 'vikunja' }]);
// A role's own slots and an unreadable organization are never touched.
const roleDir = join(f.storeRoot, 'demo', 'chief');
mkdirSync(roleDir, { mode: 0o700 }); writeFileSync(join(roleDir, 'gitea'), 'x', { mode: 0o600 });
const edited = { ...f.writer.read('demo').document };
edited.copyBindings = { ...edited.copyBindings, chief: { ...edited.copyBindings.rev, services: {} } };
assert.deepEqual(sweepCopySlots(f.storeRoot, [['demo', edited]], {}), []);
assert.equal(present(join(roleDir, 'gitea')), true);
writeFileSync(join(copyDir, 'gitea.tmp'), 'x', { mode: 0o600 });
assert.deepEqual(sweepCopySlots(f.storeRoot, [['demo', null]], {}), []);
assert.equal(present(join(copyDir, 'gitea.tmp')), true);
// No store, a missing org dir: nothing. A store dir that isn't private refuses as Credentials would.
assert.deepEqual(sweepCopySlots(null, doc(), {}), []);
assert.deepEqual(sweepCopySlots(f.storeRoot, [['other', f.writer.read('demo').document]], {}), []);
chmodSync(copyDir, 0o755);
assert.throws(() => sweepCopySlots(f.storeRoot, doc(), {}), /credential-file/);
chmodSync(copyDir, 0o700);
});
test('CB2 a stage write that fails leaves no staged token behind', async (t) => {
const f = await fixture(t);
// The slot path is a non-empty directory: the rename fails after the temp file holds the token.
mkdirSync(join(f.slot(), 'x'), { recursive: true, mode: 0o700 });
assert.throws(() => f.credentials.stageCopy('demo/rev', 'gitea', 'a'.repeat(40)));
assert.deepEqual(readdirSync(join(f.storeRoot, 'demo', 'rev')), ['gitea']);
});
test('CB2 a rebind, a failed owner recheck or a concurrent issue during the remote work refuses the commit', async (t) => {
const f = await fixture(t);
const u = f.bot();
const tok = f.token(u);
// The owner approves a new binding while Gitea is being asked.
f.hooks.afterRemote = () => f.bind('rev', { gitea: { scopes: ['read:user', 'read:repository'], projects: ['stack'] } });
// The document moved under the commit: its revision check refuses before the binding fence.
await assert.rejects(f.issueAs(f.human, { token: tok.sha1 }), { code: 'revision-conflict' });
delete f.hooks.afterRemote;
assert.deepEqual([f.rec().state, existsSync(f.slot())], ['pending', false]);
// The caller's owner recheck fails after the remote work (the broker's #copyOwner), and again
// when it fails only under the writer's locks.
for (const at of [1, 2]) {
let n = 0;
await assert.rejects(f.direct({ agent: 'rev', service: 'gitea', token: tok.sha1, date: ahead(30) }, { actor: 'jason', owner: true, recheck: () => { if (++n >= at) throw new BusError('human-required'); } }), { code: 'human-required' });
assert.deepEqual([f.rec().state, existsSync(f.slot())], ['pending', false]);
}
// Not the owner, or the tester's name, never reaches the writer.
for (const ctx of [{ actor: 'jason', owner: false }, { actor: 'tester', owner: true }])
await assert.rejects(f.direct({ agent: 'rev', service: 'gitea', token: tok.sha1, date: ahead(30) }, ctx), { code: 'human-required' });
// Two issues: the first is held after its remote work while the second binds.
const second = f.token(u);
let release, first = true;
const held = new Promise((r) => { release = r; });
f.hooks.afterRemote = () => { if (first) { first = false; return held; } };
const a = f.issueAs(f.human, { token: tok.sha1 });
await new Promise((r) => setTimeout(r, 50));
while (f.hooks.afterRemote && first) await new Promise((r) => setTimeout(r, 10));
await f.issueAs(f.human, { token: second.sha1 });
release();
await assert.rejects(a, (e) => ['revision-conflict', 'credential-binding-changed', 'credential-file'].includes(e.code));
delete f.hooks.afterRemote;
assert.equal(f.rec().state, 'bound');
assert.equal(f.rec().services.gitea.grantId, `gitea:${u.id}:${second.id}`);
assert.equal(readFileSync(f.slot(), 'utf8'), second.sha1 + '\n', 'the second issue\'s slot stays');
});
test('CB2 only the organization owner issues: the chief, the tester, a reader and an org.hire member are refused before staging', async (t) => {
const f = await fixture(t);
const tok = f.token(f.bot());
const before = readFileSync(f.file, 'utf8');
const member = (owner) => f.b.bindHireConsole({ business: 'demo', actor: 'member', subject: { ...SUBJECT, sub: 'member' }, rights: ['org.hire', 'decide'], owner, instanceOwner: true });
const got = [];
for (const cap of [f.chief, f.b.bindTester({ business: 'demo' }), f.b.bindReader({ business: 'demo' }), member(false), member(true), f.owner]) {
await assert.rejects(f.issueAs(cap, { token: tok.sha1 }), (e) => { got.push(e.code); return !String(e.message).includes(tok.sha1); });
}
// The Console caps, the owner's included, never reach credentials.issue: the token comes from the
// owner's CLI on standard input.
assert.deepEqual(got, ['human-required', 'tester-refused', 'human-required', 'human-required', 'human-required', 'human-required']);
assert.equal(readFileSync(f.file, 'utf8'), before);
assert.equal(f.api.requests.length, 0);
assert.ok(!existsSync(join(f.storeRoot, 'demo')));
// Only a name the boot's copies view holds goes to the copy verifier; any other unknown name
// answers credential-unknown as before, and nothing is staged or sent.
await assert.rejects(f.issueAs(f.human, { agent: 'nobody', token: tok.sha1 }), { code: 'credential-unknown' });
assert.deepEqual([readFileSync(f.file, 'utf8') === before, f.api.requests.length, existsSync(join(f.storeRoot, 'demo'))], [true, 0, false]);
});
test('CB2 refusal table: the new codes have rails and next steps that name no path', () => {
for (const code of ['credential-scope-unverified', 'credential-identity-refused', 'credential-date', 'credential-format', 'credential-file', 'copy-unavailable']) {
const g = REFUSALS[code];
assert.ok(g && g.rail.length && g.nextStep.length, code);
assert.doesNotMatch(g.nextStep, /\/|~|\.json\b|secrets/, code);
}
});
+29 -2
View File
@@ -4,13 +4,15 @@
// scratch root only.
import test from 'node:test';
import assert from 'node:assert/strict';
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs';
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { startBroker } from '../src/runtime.mjs';
import { Broker, CREDENTIAL_VERBS } from '../src/broker.mjs';
import { Store } from '../src/store.mjs';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../organization/tests/helpers.mjs';
import { definitionPin } from '../../organization/src/copy-bindings.mjs';
import { readTemplate } from '../../organization/src/definitions.mjs';
const TOKEN = 'fake_issued_token_0123456789';
const ROTATED = 'fake_rotated_token_9876543210';
@@ -18,16 +20,18 @@ const OTHER = 'fake_other_token_5555555555';
const DATE = '2099-01-01';
const role = { authority: { withinRole: ['message.send'], crossRole: [] } };
async function setup(t, { storeRoot = true } = {}) {
async function setup(t, { storeRoot = true, before = () => {}, edit = () => {} } = {}) {
const root = mkdtempSync(join(tmpdir(), 'bus-cred-verbs-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const rolesDir = rolesCopy(root);
const doc = { ...businessDoc(root, 'demo'), plugins: { harness: ['pi'] } };
edit(doc, rolesDir);
doc.roles.coder.credentials.gitea = { store: true, rotateBy: DATE };
writeJson(join(root, 'businesses/demo.json'), doc);
mkdirSync(join(root, 'data'), { mode: 0o700 });
if (storeRoot) mkdirSync(join(root, 'secrets', 'agents'), { mode: 0o700 });
const store = (agent, service) => join(root, 'secrets', 'agents', 'demo', agent, service);
before(store);
// The bus's resolved business carries the same references; a store one names its derived file.
const demo = {
id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, vars: { 'bus.tester': true },
@@ -187,3 +191,26 @@ test('#1886: a store root that exists with loose modes still refuses start', asy
};
await assert.rejects(startBroker({ dataRoot: join(root, 'data'), businesses: { demo }, organization: { dir: root, rolesDir, system: systemFor(root) } }), /credential-file/);
});
test('CB2 security review P2: the start sweep removes a copy slot a crashed issue left staged', async (t) => {
// A copy binding left pending by an issue killed after its stage, its token and a temp file on disk.
const pending = (doc, rolesDir) => {
doc.copyBindings = { rev: {
copyId: 'c'.repeat(32), orgIncarnation: 'a'.repeat(32), source: { level: 'root', name: 'rev', project: null },
definition: definitionPin('reviewer', { definitions: {}, rolesDir, readTemplate }), generation: 2, state: 'pending',
services: { gitea: { slot: 'self', scopes: ['read:repository', 'read:user'], resources: { projects: ['stack'] } } },
decision: '11111111-1111-4111-8111-111111111111' } };
};
const staged = (store) => {
mkdirSync(join(store('rev', 'gitea'), '..'), { recursive: true, mode: 0o700 });
chmodSync(join(store('rev', 'gitea'), '..', '..'), 0o700);
for (const name of ['gitea', 'gitea.tmp']) writeFileSync(store('rev', name), TOKEN + '\n', { mode: 0o600 });
mkdirSync(join(store('coder', 'gitea'), '..'), { recursive: true, mode: 0o700 });
writeFileSync(store('coder', 'gitea'), TOKEN + '\n', { mode: 0o600 });
};
const c = await setup(t, { edit: pending, before: staged });
assert.equal(c.file().copyBindings.rev.state, 'pending');
assert.deepEqual(readdirSync(join(c.store('rev', 'gitea'), '..')), []);
// A role's own store slot is the store's, not the sweep's.
assert.equal(existsSync(c.store('coder', 'gitea')), true);
});
+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');
});
@@ -0,0 +1,74 @@
// #1873 T1: the definition writes on the bus. Only the business's human at the cli reaches them,
// and the writer runs the credential store's held-secret check on what it would write.
import test from 'node:test';
import assert from 'node:assert/strict';
import { appendFileSync, chmodSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { DEFINITION_BUS_KEYS } from '../src/broker.mjs';
import { startBroker } from '../src/runtime.mjs';
import { DEFINITION_BUS_VERBS } from '../../organization/src/definition-verbs.mjs';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../organization/tests/helpers.mjs';
const TOKEN = 'fixture-held-token-t1-5c0e9a';
const SCOUT = { role: { tools: ['read'], network: 'none', authority: { withinRole: [], crossRole: [] }, credentials: [] }, instructions: 'Read and report.\n' };
function files(dir) {
return readdirSync(dir, { withFileTypes: true }).flatMap((e) => e.isDirectory() ? files(join(dir, e.name)) : e.isFile() ? [join(dir, e.name)] : []);
}
test('T1 the broker checks the same four definition verbs the runtime maps', () => {
assert.deepEqual(Object.keys(DEFINITION_BUS_KEYS).sort(), Object.keys(DEFINITION_BUS_VERBS).sort());
});
test('T1 definition writes: the human at the cli writes, every other caller refuses, and a held token never lands', async (t) => {
const root = mkdtempSync(join(tmpdir(), 'mosaic-def-bus-'));
const rolesDir = rolesCopy(root);
const doc = businessDoc(root, 'demo');
const file = writeJson(join(root, 'businesses/demo.json'), { ...doc, plugins: { ...doc.plugins, harness: ['pi'] } });
const data = join(root, 'data');
mkdirSync(data, { mode: 0o700 });
const token = join(root, 'held.token');
writeFileSync(token, TOKEN, { mode: 0o600 });
chmodSync(token, 0o600);
const bus = await startBroker({ dataRoot: data, businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, vars: { 'bus.tester': true }, roles: {
chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } },
coder: { authority: { withinRole: [], crossRole: [] }, credentials: { gitea: { file: token, rotateBy: '2099-01-01' } } },
} } }, organization: { dir: root, rolesDir, system: systemFor(root) } });
t.after(async () => { await bus.close(); rmSync(root, { recursive: true, force: true }); });
const b = bus.broker;
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const revision = () => b.request(human, { verb: 'limits' }).revision;
const invent = (name) => ({ verb: 'definition.invent', args: { revision: revision(), name, definition: SCOUT } });
// An agent, a reader and the tester never reach the writer.
const before = readFileSync(file);
const agent = b.bindLaunch({ business: 'demo', role: 'coder', run: 'coder-run', harness: 'pi', address: 'coder-run' });
for (const cap of [agent, b.bindReader({ business: 'demo' })]) assert.throws(() => b.request(cap, invent('scout')), /human-required/);
assert.throws(() => b.request(b.bindTester({ business: 'demo' }), invent('scout')), /tester-refused/);
// Unknown and missing arguments refuse; an actor can't be forged through them.
assert.throws(() => b.request(human, { verb: 'definition.invent', args: { ...invent('scout').args, actor: 'mallory' } }), /invalid-request/);
assert.throws(() => b.request(human, { verb: 'definition.remove', args: { revision: revision(), name: 'scout' } }), /invalid-request/);
assert.deepEqual(readFileSync(file), before);
// The human's invent commits, as the human, with a receipt that carries no role text.
const receipt = b.request(human, invent('scout'));
assert.deepEqual({ ...receipt, revision: 0, transaction: 0 }, { verb: 'invent', organization: 'demo', definition: 'scout', rev: 1, revision: 0, transaction: 0 });
assert.equal(JSON.parse(readFileSync(file, 'utf8')).definitions.scout.instructions, SCOUT.instructions);
const last = JSON.parse(readFileSync(join(root, 'businesses/.history/demo/changes.jsonl'), 'utf8').trim().split('\n').at(-1));
assert.equal(last.actor, 'jason');
assert.equal(last.state, 'committed');
// A writer refusal passes by its code.
assert.throws(() => b.request(human, invent('scout')), /definition-exists/);
// The bus refuses a request that carries the held token, and the writer refuses a template whose
// contract carries it (the import request itself holds no token). Neither writes.
const written = readFileSync(file);
assert.throws(() => b.request(human, { verb: 'definition.invent', args: { revision: revision(), name: 'leak', definition: { ...SCOUT, instructions: `Use ${TOKEN}.\n` } } }),
(e) => e.code === 'credential-leak' && !e.message.includes(TOKEN));
appendFileSync(join(rolesDir, 'coder.md'), `\nUse ${TOKEN} for the forge.\n`);
assert.throws(() => b.request(human, { verb: 'definition.import', args: { revision: revision(), template: 'coder' } }),
(e) => e.code === 'invalid-request' && !e.message.includes(TOKEN) && !String(e.stack).includes(TOKEN));
assert.deepEqual(readFileSync(file), written);
for (const path of [...files(data), ...files(join(root, 'businesses'))]) assert.ok(!readFileSync(path).includes(TOKEN), path);
});
+8 -4
View File
@@ -38,13 +38,14 @@ export async function fakeGiteaApi(t, dir, { version = '1.27.1', admin = 'mosaic
issued: [],
// A user and, with `scopes`, one token named `tokenName`. Returns the user and that token or null.
addUser(login, { is_admin = false, prohibit_login = false, full_name = login, email = `${login}@example.test`, scopes = null, tokenName = 'test' } = {}) {
const u = { id: nextId++, login, email, full_name, is_admin, prohibit_login, tokens: [] };
const u = { id: nextId++, login, email, full_name, is_admin, prohibit_login, tokens: [], orgs: [] };
users.set(lower(login), u);
return { user: u, token: scopes ? api.addToken(login, tokenName, scopes) : null };
},
addToken(login, name, scopes) {
// #1873 CB2: `expires` (an ISO instant) is reported as the token's expires_at.
addToken(login, name, scopes, { expires = null } = {}) {
const u = users.get(lower(login));
const tok = { id: nextId++, name, sha1: fakeToken(), scopes: [...scopes].sort() };
const tok = { id: nextId++, name, sha1: fakeToken(), scopes: [...scopes].sort(), expires };
u.tokens.push(tok);
api.issued.push(tok.sha1);
return tok;
@@ -96,7 +97,10 @@ export async function fakeGiteaApi(t, dir, { version = '1.27.1', admin = 'mosaic
if (!who) return [401, { message: 'unauthorized' }];
const me = who.user;
if (method === 'GET' && path === '/api/v1/user') return [200, userJson(me)];
if (method === 'GET' && path === '/api/v1/token') return [200, { id: who.token.id, name: who.token.name, scopes: who.token.scopes }];
// Gitea 1.27 names the token's owner; #1873 CB2's verifier needs it.
if (method === 'GET' && path === '/api/v1/token')
return [200, { id: who.token.id, name: who.token.name, scopes: who.token.scopes, user: { id: me.id, login: me.login }, ...(who.token.expires ? { expires_at: who.token.expires } : {}) }];
if (method === 'GET' && path === '/api/v1/user/orgs') return [200, (me.orgs ?? []).map((name, i) => ({ id: 1000 + i, username: name })), (me.orgs ?? []).length];
if (method === 'GET' && path === '/api/v1/user/repos') {
const all = reachable(me);
const limit = Number(query.get('limit') ?? 30);
+58 -34
View File
@@ -6,6 +6,8 @@
// 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';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
@@ -74,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'));
@@ -156,7 +158,7 @@ async function setup(t) {
return { revision: REV };
};
const make = () =>
new Broker({
new Broker({hires:hireServiceFixture,autonomy,
store,
businesses: { demo: structuredClone(doc) },
secretCheck: (value) => credentials.assertClean(value),
@@ -184,7 +186,7 @@ async function setup(t) {
store
.all("SELECT actor_role, subject, body FROM events WHERE business='demo' AND kind='action.allowed' ORDER BY seq")
.map((e) => ({ ...e, body: JSON.parse(e.body) }))
.filter((e) => e.body.action === action);
.filter((e) => e.body.action === action && e.body.operation !== 'decision.raise');
// The bus's step G messages to the human: no decision, so no grant notice.
const inbox = () =>
store.all("SELECT body FROM messages WHERE business='demo' AND from_role='_bus' AND to_role='human' AND decision IS NULL ORDER BY rowid").map((r) => r.body);
@@ -232,7 +234,8 @@ async function setup(t) {
adminSha256: adminSha256(grantApi.adminMeta('demo') ?? meta),
},
});
const fire = (name) => call('chief', 'instance.fire', { name, reason: 'the work is done' });
const hireConsole=fixtureConsole(broker);
const fire = name => approveFixtureFire(broker,hireConsole,name);
// A delete as the launcher's pass runs it: the human's request, the take, then lifecycleDelete. A
// refused delete closes the request with its code, as the launcher does.
const remove = (instance) => {
@@ -304,7 +307,7 @@ function allRevoked(f, hosts = ['web1', 'web2']) {
test('row 281 T2: a fire with host grants on two hosts and a Gitea grant answers with a job, then revokes all three', async (t) => {
const f = await setup(t);
await threeGrants(f);
const out = f.fire('coder');
const out = await f.fire('coder');
assert.equal(out.done, false);
assert.match(out.job, UUID);
assert.deepEqual(out.revoking, ['host:web1', 'host:web2', 'gitea']);
@@ -313,7 +316,7 @@ test('row 281 T2: a fire with host grants on two hosts and a Gitea grant answers
assert.equal(more.length, 0);
assert.equal(revoking.body.job, out.job);
assert.deepEqual(revoking.body.grants, out.revoking);
assert.equal(revoking.actor_role, 'chief');
assert.equal(revoking.actor_role, null);
assert.equal(revoking.subject, 'coder');
await f.broker.grantsIdle();
// Each host as hosts.revoke records it, with fire: true, line first so the close says removed.
@@ -340,7 +343,7 @@ test('row 281 T2: a fire with host grants on two hosts and a Gitea grant answers
assert.deepEqual(done.body.grants.map((x) => x.grant), ['host:web1', 'host:web2', 'gitea']);
assert.deepEqual(done.body.grantsLeft, []);
assert.match(done.body.transaction, /^t-/);
assert.equal(done.actor_role, 'chief');
assert.equal(done.actor_role, null);
assert.deepEqual(f.org.log.filter(([op]) => op === 'instance.fire'), [['instance.fire', 'coder']]);
assert.ok(!f.doc.launch.instances.includes('coder'));
// No step G message for a job that completed.
@@ -480,7 +483,7 @@ test('row 281 T4: host B unreachable in a fire: the job refuses, A stays revoked
const f = await setup(t);
await threeGrants(f);
f.failing.add('web2');
const out = f.fire('coder');
const out = await f.fire('coder');
await f.broker.grantsIdle();
const [refused, ...more] = f.grantEvents().filter((e) => e.action === 'grant.revoke.refused');
assert.equal(more.length, 0);
@@ -510,7 +513,7 @@ test('row 281 T4: host B unreachable in a fire: the job refuses, A stays revoked
assert.equal(f.state('coder', 'gitea'), 'valid');
// B back: the repeat revokes only B and Gitea.
f.failing.delete('web2');
const repeat = f.fire('coder');
const repeat = await f.fire('coder');
assert.deepEqual(repeat.revoking, ['host:web2', 'gitea']);
await f.broker.grantsIdle();
const [done] = f.allowed('fire.done');
@@ -529,7 +532,7 @@ test('row 281 T4b (Q1): a line a manual hosts.revoke left on C is removed by the
const tag3 = f.tag('coder', 'web3');
assert.equal(manual.hostLineLeft, tag3);
f.failing.delete('web3');
const out = f.fire('coder');
const out = await f.fire('coder');
assert.deepEqual(out.revoking, ['host:web1', 'line:web3']);
await f.broker.grantsIdle();
assert.equal(f.lines.web3.has(tag3), false);
@@ -552,7 +555,7 @@ test('row 281 T4b (Q1): with C unreachable, the fire completes and names the lin
f.failing.add('web3');
await f.call('human', 'hosts.revoke', { agent: 'coder', host: 'web3', reason: 'web3 is down' });
const tag3 = f.tag('coder', 'web3');
const out = f.fire('coder');
const out = await f.fire('coder');
assert.deepEqual(out.revoking, ['host:web1', 'line:web3']);
await f.broker.grantsIdle();
assert.equal(f.grantEvents().filter((e) => e.action === 'grant.revoke.refused').length, 0);
@@ -568,7 +571,7 @@ test("row 281 T5: Gitea's admin token can't be checked: the job refuses with the
const f = await setup(t);
await f.approveGitea('coder');
const off = f.gitea.rule((e) => e.method === 'GET' && e.path === '/api/v1/version', { status: 500, json: {} });
const out = f.fire('coder');
const out = await f.fire('coder');
assert.deepEqual(out.revoking, ['gitea']);
await f.broker.grantsIdle();
off();
@@ -597,7 +600,7 @@ test("row 281 T5b (N1): a failed token DELETE refuses gitea-left; the repeat ret
const helperDeletes = () => f.gitea.requests.filter(tokenDelete('demo-helper')).length;
const before = helperDeletes();
const offCoder = f.gitea.rule(tokenDelete('demo-coder'), { status: 500, json: {} });
const out = f.fire('coder');
const out = await f.fire('coder');
await f.broker.grantsIdle();
const [refused] = f.grantEvents().filter((e) => e.action === 'grant.revoke.refused');
assert.equal(refused.grant, 'gitea');
@@ -608,7 +611,7 @@ test("row 281 T5b (N1): a failed token DELETE refuses gitea-left; the repeat ret
assert.equal(f.allowed('fire.done').length, 0);
// Gitea back for coder only. The helper's DELETE still fails, so a business-wide sweep would show.
offCoder();
const repeat = f.fire('coder');
const repeat = await f.fire('coder');
assert.deepEqual(repeat.revoking, ['gitea']);
await f.broker.grantsIdle();
const [done] = f.allowed('fire.done');
@@ -627,7 +630,7 @@ test("row 281 T5b (N1): a failed token DELETE refuses gitea-left; the repeat ret
test('row 281 T6 (sage-2574): a host decision approved before the fire and run after it ends revoked, with nothing applied', async (t) => {
const f = await setup(t);
const id = f.fixtureHost('coder', 'web1');
const out = f.fire('coder');
const out = await f.fire('coder');
assert.deepEqual(out.revoking, ['host:web1']);
await f.broker.grantsIdle();
const [r] = f.revoked('coder', (e) => e.host === 'web1');
@@ -644,7 +647,7 @@ test('row 281 T6 (sage-2574): a host decision approved before the fire and run a
test('row 281 T6b (sage-2574): a Gitea decision approved before the fire and run after it ends revoked before any Gitea write', async (t) => {
const f = await setup(t);
const id = f.fixtureGitea('coder');
const out = f.fire('coder');
const out = await f.fire('coder');
assert.deepEqual(out.revoking, ['gitea']);
await f.broker.grantsIdle();
const [r] = f.revoked('coder', (e) => e.method === 'gitea');
@@ -689,7 +692,7 @@ test('row 281 T6c: a host executor in flight aborts and ends revoked; a Gitea ex
const gd = await f.raiseGitea('coder');
f.call('human', 'decision.resolve', { id: gd.id, choice: 'yes' });
await until(() => reached);
const out = f.fire('coder');
const out = await f.fire('coder');
assert.deepEqual(out.revoking, ['host:web1', 'gitea']);
await until(() => f.ended(host.id));
assert.equal(f.ended(host.id).code, 'revoked');
@@ -714,7 +717,7 @@ test('row 281 T6d (B1): undecided host and Gitea decisions are in the set, and a
const f = await setup(t);
const host = await f.raiseHost('coder', 'web1');
const gd = await f.raiseGitea('coder');
const out = f.fire('coder');
const out = await f.fire('coder');
assert.deepEqual(out.revoking, ['host:web1', 'gitea']);
await f.broker.grantsIdle();
assert.equal(f.allowed('fire.done').length, 1);
@@ -736,14 +739,14 @@ test('row 281 T6e (B1): a grant raise refuses instance-fired while firing and af
await f.approveHost('coder', 'web1');
// Firing with no job: step B refused, so fire.stopped stands with no fire.done.
f.org.fireError = Object.assign(new Error('refused'), { code: 'credential-revoke-failed' });
f.fire('coder');
await f.fire('coder');
await f.broker.grantsIdle();
assert.equal(f.allowed('fire.failed').length, 1);
await rejects(() => f.raiseHost('coder', 'web2'), 'instance-fired');
await rejects(() => f.raiseGitea('coder'), 'instance-fired');
// After fire.done: delisted.
f.org.fireError = null;
assert.match(f.fire('coder').transaction, /^t-/);
assert.match((await f.fire('coder')).transaction, /^t-/);
await rejects(() => f.raiseHost('coder', 'web2'), 'instance-fired');
await rejects(() => f.raiseGitea('coder'), 'instance-fired');
// A rehire lists the name again.
@@ -761,7 +764,7 @@ test('row 281 T6e (B1): a grant raise refuses instance-fired while firing and af
test('row 281 T7: an instance with no grant fires as today, synchronously, with the same events', async (t) => {
const f = await setup(t);
const out = f.fire('helper');
const out = await f.fire('helper');
assert.deepEqual(Object.keys(out).sort(), ['cancelled', 'request', 'revision', 'runs', 'taken', 'transaction']);
assert.match(out.transaction, /^t-/);
// Recorded before the verb answered: no job.
@@ -781,9 +784,9 @@ test('row 281 T9 (B3): one step G per agent; the manual verbs refuse while it ru
await f.approveHost('coder', 'web1');
await f.approveGitea('coder');
f.held.add('web1:remove');
f.fire('coder');
await f.fire('coder');
await until(() => f.sent.includes(`remove web1 ${f.tag('coder', 'web1')}`));
assert.equal(code(() => f.fire('coder')), 'fire-in-progress');
await assert.rejects(f.fire('coder'),{code:'fire-in-progress'});
assert.equal(f.remove('coder').code, 'grants-revoking');
assert.equal(f.allowed('grants.revoking').length, 0);
assert.equal(f.allowed('fire.revoking').length, 1);
@@ -820,7 +823,7 @@ test('row 281 T10 (B2): the fire and the delete answer while their jobs are held
// The verbs answer before the event loop turns, so before any part of a job has run.
let turned = false;
setImmediate(() => (turned = true));
const fired = f.fire('coder');
const fired = await f.fire('coder');
const deleted = f.remove('ops');
assert.equal(turned, false);
assert.equal(fired.done, false);
@@ -844,7 +847,7 @@ test("row 281 T11 (C1): a fire's job completes after the chief's run ends and pr
const f = await setup(t);
await f.approveHost('coder', 'web1');
f.held.add('web1:remove');
const out = f.fire('coder');
const out = await f.fire('coder');
await until(() => f.sent.includes(`remove web1 ${f.tag('coder', 'web1')}`));
f.broker.endLaunch({ business: 'demo', run: 'chief-run', reason: 'test' });
f.sw.state = { version: 1, halted: true, generation: GENERATION };
@@ -855,7 +858,7 @@ test("row 281 T11 (C1): a fire's job completes after the chief's run ends and pr
assert.match(done.body.transaction, /^t-/);
const [used, ...more] = f.allowed('role.fire');
assert.equal(more.length, 0);
assert.equal(used.actor_role, 'chief');
assert.equal(used.actor_role, null);
assert.equal(used.body.transaction, done.body.transaction);
assert.ok(!f.doc.launch.instances.includes('coder'));
assert.equal(f.allowed('fire.failed').length, 0);
@@ -867,12 +870,12 @@ test("row 281 T12 (C1, N7): a step B refusal in the job is fire.failed, and the
await f.approveHost('coder', 'web1');
// N7: the writer's own credential-revoke-failed, which step G's key revokes can't clear.
f.org.fireError = Object.assign(new Error('refused'), { code: 'credential-revoke-failed' });
const out = f.fire('coder');
const out = await f.fire('coder');
await f.broker.grantsIdle();
const [failed, ...more] = f.allowed('fire.failed');
assert.equal(more.length, 0);
assert.deepEqual([failed.body.job, failed.body.step, failed.body.code], [out.job, 'B', 'credential-revoke-failed']);
assert.equal(failed.actor_role, 'chief');
assert.equal(failed.actor_role, null);
const [message] = f.inbox();
for (const s of [out.job, 'host:web1', 'step B', 'credential-revoke-failed']) assert.ok(message.includes(s), s);
assert.equal(f.grantEvents().filter((e) => e.action === 'grant.revoke.refused').length, 0);
@@ -882,7 +885,7 @@ test("row 281 T12 (C1, N7): a step B refusal in the job is fire.failed, and the
// The grant was revoked before step B.
assert.equal(f.hostRow('coder', 'web1'), undefined);
f.org.fireError = null;
const repeat = f.fire('coder');
const repeat = await f.fire('coder');
assert.equal(repeat.job, undefined);
assert.match(repeat.transaction, /^t-/);
assert.equal(f.allowed('fire.done').length, 1);
@@ -900,7 +903,7 @@ test('row 281 T13 (N4): a restart records each held job interrupted once, with o
assert.equal(f.allowed('grants.revoked.done').length, 1);
f.held.add('web1:remove');
f.held.add('web2:remove');
const fired = f.fire('coder');
const fired = await f.fire('coder');
assert.equal(f.remove('ops').code, 'grants-revoking');
const deleteJob = f.allowed('grants.revoking').at(-1).body.job;
await until(() => f.sent.includes(`remove web1 ${f.tag('coder', 'web1')}`) && f.sent.includes(`remove web2 ${f.tag('ops', 'web2')}`));
@@ -930,7 +933,7 @@ test("row 281 T14 (N1, sage-2653): a host removed under an undecided decision st
const d = await f.raiseHost('coder', 'web1');
// N1: hosts.remove doesn't check open decisions, so the decision outlives its host.
assert.deepEqual(await f.call('human', 'hosts.remove', { host: 'web1' }), { host: 'web1', removed: true });
const out = f.fire('coder');
const out = await f.fire('coder');
assert.match(out.job, UUID);
assert.deepEqual(out.revoking, ['host:web1']);
await f.broker.grantsIdle();
@@ -946,7 +949,7 @@ test("row 281 T14 (N1, sage-2653): a host removed under an undecided decision st
f.call('human', 'decision.resolve', { id: d.id, choice: 'no' });
await f.broker.grantsIdle();
assert.equal(f.ended(d.id), undefined);
const repeat = f.fire('coder');
const repeat = await f.fire('coder');
assert.equal(repeat.job, undefined);
assert.match(repeat.transaction, /^t-/);
assert.equal(f.allowed('fire.done').length, 1);
@@ -961,7 +964,7 @@ test("row 239 T4: a refusal after the writer's revoke is named -revoked, in the
const f = await setup(t);
await f.approveHost('coder', 'web1');
f.org.fireError = Object.assign(new Error('refused'), { code: 'revision-conflict', revoked: true });
const out = f.fire('coder');
const out = await f.fire('coder');
await f.broker.grantsIdle();
const [failed] = f.allowed('fire.failed');
assert.deepEqual([failed.body.job, failed.body.step, failed.body.code], [out.job, 'B', 'revision-conflict-revoked']);
@@ -987,7 +990,7 @@ test("row 239 T4b: an unmarked step B refusal keeps its code, and the notice say
const f = await setup(t);
await f.approveHost('coder', 'web1');
f.org.fireError = Object.assign(new Error('refused'), { code: 'revision-conflict' });
const out = f.fire('coder');
const out = await f.fire('coder');
await f.broker.grantsIdle();
const [failed] = f.allowed('fire.failed');
assert.deepEqual([failed.body.job, failed.body.code], [out.job, 'revision-conflict']);
@@ -995,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);
}
});
+240
View File
@@ -0,0 +1,240 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, 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';
test('A4 direct instance.hire and instance.fire refuse and cannot write', t => {
const root = mkdtempSync(join(tmpdir(), 'a4-'));
const store = new Store(root);
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
let writes = 0;
const b = new Broker({ store, businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, roles: { chief: { definition: 'chief-of-staff', authority: { withinRole: ['role.hire','role.fire'], crossRole: [] } } }, launch: { by: 'chief', instances: [] } } }, organization: () => { writes++; } });
const chief = b.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' });
b.request(chief, { verb: 'role.claim' });
for (const verb of ['instance.hire', 'instance.fire']) assert.throws(() => b.request(chief, { verb, args: { name: 'scout', definition: 'coder', reason: 'Need help' } }), { code: 'decision-required' });
assert.equal(writes, 0);
});
import { hireDecisions } from '../../organization/src/hire-decisions.mjs';
import { organizationWriter } from '../../organization/src/organization.mjs';
import { loadBusiness } from '../../organization/src/organization-config.mjs';
import { businessDoc, rolesCopy, writeJson } from '../../organization/tests/helpers.mjs';
import { readFileSync, mkdirSync } from 'node:fs';
const INLINE = { tools: ['read'], network: 'none', authority: { withinRole: [], crossRole: [] }, credentials: [], instructions: 'Read and report sk-test-NOT-A-SECRET-1234.' };
const SUBJECT={provider:'test',iss:'https://issuer.test',sub:'owner'};
function fixture(t) {
const root=mkdtempSync(join(tmpdir(),'a4-bus-')), rolesDir=rolesCopy(root);
const file=writeJson(join(root,'businesses/demo.json'),{...businessDoc(root,'demo'),plugins:{harness:['pi']}});
const storeRoot=join(root,'data');mkdirSync(storeRoot,{mode:0o700});
const writer=organizationWriter({dir:root,rolesDir}),store=new Store(storeRoot);
const service=hireDecisions({writer,rolesDir,dataRoot:storeRoot,configDir:root,assertClean:()=>{}});
const loaded=()=>{const doc=loadBusiness('demo',{dir:root,rolesDir});return {...doc,roles:Object.fromEntries(Object.entries(doc.roles).map(([n,e])=>[n,{...doc.definitions[e.definition],...e}]))};};
let writes=0;
const b=new Broker({store,businesses:{demo:loaded()},hires:{...service,apply:async(...args)=>{writes++;const r=await service.apply(...args);b.reloadBusiness('demo',loaded());return r;}}});
const owner=b.bindConsole({business:'demo',human:'jason',subject:SUBJECT});
const member=rights=>b.bindHireConsole({business:'demo',actor:'member',subject:{...SUBJECT,sub:'member'},rights});
const caps={owner,chief:b.bindLaunch({business:'demo',role:'chief',run:'chief-run',harness:'pi'}),coder:b.bindLaunch({business:'demo',role:'coder',run:'coder-run',harness:'pi'})};
b.request(caps.chief,{verb:'role.claim'});b.request(caps.coder,{verb:'role.claim'});
const call=(cap,verb,args={})=>b.request(cap,{verb,args});
const request=extra=>({op:'hire',level:'org',name:'scout',role:{inline:INLINE},route:{harness:'pi',model:'claude-opus-5-5'},reason:'Need help',...extra});
const raise=extra=>call(caps.chief,'hire.raise',request(extra));
t.after(()=>{store.close();rmSync(root,{recursive:true,force:true});});
const reloadChief=authority=>{const doc=loaded();doc.roles.chief.authority=authority;b.reloadBusiness('demo',doc);};
return {root,rolesDir,b,store,file,writer,service,owner,member,caps,call,request,raise,reloadChief,writes:()=>writes};
}
test('A4 only the chief raises; forged flagged lines refuse without a record',async t=>{
const {call,caps,request,store}=fixture(t);
await assert.rejects(call(caps.coder,'hire.raise',request()),{code:'role-not-chief'});
await assert.rejects(call(caps.chief,'hire.raise',request({flagged:[]})),{code:'invalid-request'});
assert.equal(store.get('SELECT count(*) n FROM decisions').n,0);
});
test('A4 approve is digest bound and consumed once; generic decide never resolves hire, fire or change',async t=>{
const {raise,call,owner,writes,store,file,b,caps}=fixture(t);
const d=await raise();const p=await call(owner,'hire.preview.console',{id:d.id});
assert.ok(!JSON.stringify(p).includes('sk-test-NOT-A-SECRET-1234'));
await assert.rejects(call(owner,'hire.resolve.console',{id:d.id,choice:'yes',digest:'0'.repeat(64)}),{code:'decision-stale'});
for(const action of ['role.hire','role.fire','role.change']) {
if(action!==d.action)store.run('INSERT INTO decisions(id,at,business,raised_by_role,raised_by_run,class,action,route_to,question,options,recommendation,blocking) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)',action,'2026-01-01T00:00:00.000Z','demo','chief','chief-run','gated',action,'human','Question?',JSON.stringify([{key:'yes',text:'Yes'},{key:'no',text:'No'}]),'no',0);
assert.throws(()=>call(owner,'decision.resolve.console',{id:action===d.action?d.id:action,choice:'yes',digest:p.digest}),{code:'hire-resolution-required'});
const human=b.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});
assert.throws(()=>call(human,'decision.resolve',{id:action===d.action?d.id:action,choice:'yes'}),{code:'hire-resolution-required'});
}
const before=readFileSync(file);
await call(owner,'hire.resolve.console',{id:d.id,choice:'yes',digest:p.digest});
assert.equal(writes(),1);assert.notDeepEqual(readFileSync(file),before);
await assert.rejects(call(owner,'hire.resolve.console',{id:d.id,choice:'yes',digest:p.digest}),{code:'decision-closed'});
assert.equal(writes(),1);
for(const row of [...store.all('SELECT * FROM decisions'),...store.all('SELECT * FROM events')])assert.ok(!JSON.stringify(row).includes('sk-test-NOT-A-SECRET-1234'));
});
test('A4 denied and expired decisions leave organization bytes untouched',async t=>{
const {raise,call,owner,file,writes}=fixture(t);
const before=readFileSync(file),d=await raise();
await call(owner,'hire.resolve.console',{id:d.id,choice:'no',digest:d.hire.digest});
const e=await raise(); const original=Date.now;
try{Date.now=()=>original()+8*86400000;await assert.rejects(call(owner,'hire.resolve.console',{id:e.id,choice:'yes',digest:e.hire.digest}),{code:'decision-expired'});}finally{Date.now=original;}
assert.deepEqual(readFileSync(file),before);assert.equal(writes(),0);
});
test('A4 members with org.hire refuse flagged or credential grants; decide alone refuses all hires',async t=>{
const {raise,call,member}=fixture(t);
const plain=await raise();
await assert.rejects(call(member(['decide']),'hire.resolve.console',{id:plain.id,choice:'yes',digest:plain.hire.digest}),{code:'right-required'});
const flagged=await raise({role:{inline:{...INLINE,tools:['read','bash']}}});
const credential=await raise({role:{inline:{...INLINE,credentials:[{service:'gitea',scopes:['read:repository']}]}}});
for(const d of [flagged,credential])await assert.rejects(call(member(['org.hire']),'hire.resolve.console',{id:d.id,choice:'yes',digest:d.hire.digest}),{code:'hire-owner-required'});
await call(member(['org.hire']),'hire.resolve.console',{id:plain.id,choice:'yes',digest:plain.hire.digest});
});
test('A4 human Console start raises and resolves one recorded decision with computed lines',async t=>{
const {call,owner,request,store,writes}=fixture(t);
const r=await call(owner,'hire.start.console',request({role:{inline:{...INLINE,tools:['read','bash']}}}));
assert.equal(r.op,'resolved');assert.equal(writes(),1);
const event=store.all("SELECT body FROM events WHERE json_extract(body,'$.operation')='decision.raise'").map(e=>JSON.parse(e.body))[0];
assert.deepEqual(event.hire.flagged,[{field:'tools',value:'bash',reasons:['above-chief']}]);
assert.equal(store.get('SELECT raised_by_role FROM decisions WHERE id=?',r.id).raised_by_role,'human');
});
test('A4 a stale organization digest never reaches the writer',async t=>{
const {raise,call,owner,writer,file,writes}=fixture(t);
const d=await raise(),old=writer.read('demo');
writeJson(file,{...old.document,vars:{...old.document.vars,'tracker.pollSeconds':61}});
await assert.rejects(call(owner,'hire.resolve.console',{id:d.id,choice:'yes',digest:d.hire.digest}),{code:'decision-stale'});
assert.equal(writes(),0);
});
test('A4 approved credential role refuses launch until a binding is present',async t=>{
const {raise,call,owner,b,caps,writer,file,root,rolesDir}=fixture(t);
const d=await raise({role:{inline:{...INLINE,credentials:[{service:'gitea',scopes:['read:repository']}]}}});
await call(owner,'hire.resolve.console',{id:d.id,choice:'yes',digest:d.hire.digest});
assert.throws(()=>b.request(b.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true}),{verb:'instance.launch',args:{instance:'scout'}}),{code:'credential-unbound'});
// Binding implementation is outside A4. The existing file shape supplies its metadata here.
const doc=writer.read('demo').document;
doc.roles.scout.credentials={gitea:{store:true,rotateBy:'2099-01-01'}};
writeJson(file,doc);
const loaded=loadBusiness('demo',{dir:root,rolesDir});
b.reloadBusiness('demo',{...loaded,roles:Object.fromEntries(Object.entries(loaded.roles).map(([n,e])=>[n,{...loaded.definitions[e.definition],...e}]))});
assert.doesNotThrow(()=>b.request(b.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true}),{verb:'instance.launch',args:{instance:'scout'}}));
});
test('A4 refusals and records never carry invalid role text',async t=>{
const {raise,store}=fixture(t);
await assert.rejects(raise({role:{inline:{...INLINE,authority:{withinRole:['sk-test-NOT-A-SECRET-1234'],crossRole:[]}}}}),e=>{
assert.ok(!JSON.stringify(e).includes('sk-test-NOT-A-SECRET-1234'));return true;
});
assert.ok(!JSON.stringify(store.all('SELECT * FROM events')).includes('sk-test-NOT-A-SECRET-1234'));
});
test('A4 a human may mark a hire seen, and the chief may withdraw its own proposal',async t=>{
const {raise,call,b,caps,writes}=fixture(t),d=await raise();
const human=b.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});
assert.equal(call(human,'decision.seen',{id:d.id}).op,'seen');
assert.equal(call(caps.chief,'decision.withdraw',{id:d.id}).op,'withdrawn');
assert.equal(writes(),0);
});
test('A4 Console previews and consumes only the exact narrowed grants and route',async t=>{
const {raise,call,owner,writer,store}=fixture(t);
const d=await raise({role:{inline:{...INLINE,tools:['read','bash'],credentials:[{service:'gitea',scopes:['read:repository']}]}}});
const adjust={role:{tools:['read']},credentials:[],route:{harness:'pi',model:'claude-sonnet-5-5'}};
const p=await call(owner,'hire.preview.console',{id:d.id,adjust});
assert.notEqual(p.digest,d.hire.digest);assert.deepEqual(p.flagged,[]);assert.deepEqual(p.credentialLines,[]);
await assert.rejects(call(owner,'hire.resolve.console',{id:d.id,choice:'yes',digest:p.digest}),{code:'decision-stale'});
await call(owner,'hire.resolve.console',{id:d.id,choice:'yes',digest:p.digest,adjust});
const doc=writer.read('demo').document,entry=doc.definitions[doc.roles.scout.definition.slice(4)];
assert.deepEqual(entry.role.tools,['read']);assert.deepEqual(entry.role.credentials,[]);
const consume=JSON.parse(store.get("SELECT body FROM events WHERE json_extract(body,'$.operation')='hire.consume'").body);
assert.equal(consume.hire.digest,p.digest);assert.deepEqual(consume.hire.credentialLines,[]);
});
test('A4 Console lists template hashes without role text, under org.hire',async t=>{
const {call,owner,member}=fixture(t);
const list=await call(owner,'hire.templates.console');
assert.ok(list.templates.some(x=>x.name==='coder'&&/^[a-f0-9]{64}$/.test(x.sha256)));
assert.ok(list.templates.every(x=>Object.keys(x).sort().join(',')==='name,sha256'));
assert.throws(()=>call(member(['decide']),'hire.templates.console'),{code:'right-required'});
});
test('A4 chief proposals require current role.hire for hire/change and role.fire for fire',async t=>{
const f=fixture(t);
f.reloadChief({withinRole:['message.send'],crossRole:[]});
for(const op of ['hire','change','fire'])for(const verb of ['hire.raise','decision.raise']) {
const hire=f.request({op});
await assert.rejects(f.call(f.caps.chief,verb,verb==='decision.raise'?{action:`role.${op}`,hire}:hire),{code:'not-authorized'});
}
assert.equal(f.store.get('SELECT count(*) n FROM decisions').n,0);
f.reloadChief({withinRole:['role.hire'],crossRole:[]});
assert.ok((await f.raise()).id);
assert.ok((await f.raise({op:'change',name:'coder'})).id);
const fire={op:'fire',level:'org',name:'coder',reason:'Finished'};
await assert.rejects(f.call(f.caps.chief,'hire.raise',fire),{code:'not-authorized'});
f.reloadChief({withinRole:['role.fire'],crossRole:[]});
assert.ok((await f.call(f.caps.chief,'hire.raise',fire)).id);
await assert.rejects(f.raise(),{code:'not-authorized'});
});
test('A4 chief authority is checked again after asynchronous proposal preparation',async t=>{
const f=fixture(t),pending=f.raise();
f.reloadChief({withinRole:['message.send'],crossRole:[]});
await assert.rejects(pending,{code:'not-authorized'});
assert.equal(f.store.get('SELECT count(*) n FROM decisions').n,0);
});
test('A4 a member start needing owner approval refuses before saving a decision',async t=>{
const f=fixture(t),cap=f.member(['org.hire']),before=readFileSync(f.file);
for(const inline of [{...INLINE,tools:['read','bash']},{...INLINE,credentials:[{service:'gitea',scopes:['read:repository']}]}])
await assert.rejects(f.call(cap,'hire.start.console',f.request({role:{inline}})),{code:'hire-owner-required'});
assert.equal(f.store.get('SELECT count(*) n FROM decisions').n,0);
assert.deepEqual(readFileSync(f.file),before);
assert.equal((await f.call(cap,'hire.start.console',f.request())).op,'resolved');
});
test('A4 inbox and decision reads expire unopened hires once without changing configuration',async t=>{
const f=fixture(t),d=await f.raise({role:{inline:{...INLINE,tools:['read','bash']}}}),before=readFileSync(f.file);
const human=f.b.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});
const original=Date.now;
try {
Date.now=()=>original()+8*86400000;
await assert.rejects(f.call(f.member(['org.hire']),'hire.preview.console',{id:d.id}),{code:'decision-expired'});
assert.deepEqual(f.call(human,'inbox'),[]);
const recent=f.call(human,'decisions',{list:'recent'});
assert.equal(recent[0].closed.by,'expiry');
const another=await f.raise();
Date.now=()=>original()+16*86400000;
assert.deepEqual(f.call(human,'inbox'),[]);
f.call(human,'decisions',{list:'recent'});
assert.equal(f.store.get("SELECT count(*) n FROM decision_events WHERE decision=? AND by='expiry'",another.id).n,1);
} 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);
});
+14
View File
@@ -0,0 +1,14 @@
// Trusted proposal service stand-in for executor-only tests. Authorization and real writers are
// covered by hire-decisions.test.mjs; these tests isolate the existing grant/fire executors.
export const hireServiceFixture = {
prepare: (_business,args,{id,expiresAt}) => ({id,op:args.op,level:args.level,name:args.name,revision:'b'.repeat(64),flagged:[],credentialLines:[],digest:'a'.repeat(64),expiresAt}),
check: () => {},
};
export const fixtureConsole = (broker,business='demo') => broker.bindConsole({business,human:'jason',subject:{provider:'test',iss:'https://issuer.test',sub:'owner'}});
export const approveFixtureFire = async (broker,cap,name) => {const {id,op,choice,...result}=await broker.request(cap,{verb:'hire.start.console',args:{op:'fire',level:'org',name,reason:'The work is done'}});if(result.done===false)delete result.transaction;return result;};
export const ownerConsole = (broker,business='demo') => broker.bindConsole({business,human:'jason',subject:{provider:'test',iss:'https://issuer.test',sub:'owner'}});
export async function approveTemplateHire(broker,rolesDir,{business='demo',name,definition,reason='Fixture hire',model='claude-opus-5-5',...extra}) {
const {readTemplate}=await import('../../organization/src/definitions.mjs');
const source=readTemplate(rolesDir,definition);
return broker.request(ownerConsole(broker,business),{verb:'hire.start.console',args:{op:'hire',level:'org',name,role:{template:definition,sha256:source.sha256},route:{harness:'pi',model},reason,...extra}});
}
+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)
@@ -6,6 +6,7 @@
// use fixtures: the evidence and the gitea_grants row go in by SQL, as gitea-grant.test.mjs does for
// decisions. Tokens are made up or the fake's, and only ever compared.
import test from 'node:test';
import { hireServiceFixture, fixtureConsole, approveFixtureFire } from './hire-fixtures.mjs';
import assert from 'node:assert/strict';
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
@@ -68,7 +69,7 @@ function world(t, options) {
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
const business = makeBusiness({ ...options, root });
const held = new Set();
const broker = new Broker({ store, businesses: { acme: business }, giteaKeys: { held: (b, a) => held.has(b + '/' + a) } });
const broker = new Broker({hires:hireServiceFixture, store, businesses: { acme: business }, giteaKeys: { held: (b, a) => held.has(b + '/' + a) } });
return { store, business, broker, held, choose: () => broker.libraryFetchCredential('acme') };
}
@@ -138,7 +139,7 @@ async function verbWorld(t, opts = {}) {
}
return { revision: REV };
};
broker = new Broker({
broker = new Broker({hires:hireServiceFixture,
store, businesses: { acme: structuredClone(doc) }, secretCheck: (value) => credentials.assertClean(value), organization,
processing: { read: () => SWITCH }, giteaGrant: grantApi, giteaKeys: giteaKeys({ credentials, storeRoot }),
});
@@ -174,7 +175,7 @@ async function verbWorld(t, opts = {}) {
assert.ok(events().some((e) => e.action === 'grant.revoked' && e.agent === 'chief'));
};
const fire = async () => {
await broker.request(boss, { verb: 'instance.fire', args: { name: 'chief', reason: 'the work is done' } });
await approveFixtureFire(broker,fixtureConsole(broker,'acme'),'chief');
await until(() => !doc.launch.instances.includes('chief'));
await broker.grantsIdle();
assert.equal(row(), undefined, 'the fire\'s step G deleted the row (row 281)');
@@ -196,7 +197,7 @@ test('C2-03b T6: the slot before any chief evidence, then the listed chief\'s li
assert.deepEqual(w.choose(), { kind: 'slot' }, 'a seat grant is not the chief\'s: the chief entry keeps the fallback');
grant(w.store, 'chief', { user: 'chief-bot' });
assert.deepEqual(w.choose(), { kind: 'chief', agent: 'chief', user: 'chief-bot' });
assert.deepEqual(new Broker({ store: w.store, businesses: { acme: w.business } }).libraryFetchCredential('acme'), REFUSED,
assert.deepEqual(new Broker({hires:hireServiceFixture, store: w.store, businesses: { acme: w.business } }).libraryFetchCredential('acme'), REFUSED,
'a bus with no grant store refuses rather than guess');
assert.throws(() => w.broker.libraryFetchCredential('other'), { code: 'unknown-business' });
assert.equal(LIBRARY_FETCH_FIX, 'add the chief of staff, or grant the library through C2-21');
+10 -8
View File
@@ -1,3 +1,4 @@
import { fixtureConsole, approveFixtureFire } from './hire-fixtures.mjs';
// Row 194 (#1692, brief item 8, row 199 D13, T20): fire and delete revoke the agent's credentials
// after the writer's checks and before the write. Fire revokes each service and keeps the store
// directory. Delete calls remove (sage-1593). A failed revoke stops the verb with
@@ -54,7 +55,8 @@ async function setup(t, { drop = [], seed = null } = {}) {
const chief = bus.broker.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' });
bus.broker.request(chief, { verb: 'role.claim' });
const ask = (verb, args) => bus.broker.request(human, { verb, args });
const fire = (name) => bus.broker.request(chief, { verb: 'instance.fire', args: { name, reason: 'the work is done' } });
const hireConsole=fixtureConsole(bus.broker);
const fire = name => approveFixtureFire(bus.broker,hireConsole,name);
const agentDir = (agent) => join(root, 'secrets', 'agents', 'demo', agent);
const plant = (agent, service) => {
mkdirSync(agentDir(agent), { recursive: true, mode: 0o700 });
@@ -85,12 +87,12 @@ test('T20 fire: a revoke that fails stops the fire with the file unchanged; one
// The revoke can't unlink the token, so it fails before the write: the file still lists coder.
const before = f.bytes();
chmodSync(f.agentDir('coder'), 0o500);
assert.equal(f.code(() => f.fire('coder')), 'credential-revoke-failed');
await assert.rejects(f.fire('coder'),{code:'credential-revoke-failed'});
chmodSync(f.agentDir('coder'), 0o700);
assert.equal(f.bytes(), before);
assert.ok(JSON.parse(before).launch.instances.includes('coder'));
const fired = f.fire('coder');
const fired = await f.fire('coder');
assert.equal(fired.delisted, true);
assert.deepEqual(fired.credentials, { revoked: ['gitea', 'vikunja'], outsideStore: ['vikunja'] });
assert.ok(!JSON.parse(f.bytes()).launch.instances.includes('coder'));
@@ -109,7 +111,7 @@ test('T20 fire: an instance already delisted still has its tokens revoked and th
doc.launch.instances = doc.launch.instances.filter((name) => name !== 'rev-2');
writeFileSync(f.file, JSON.stringify(doc, null, 2) + '\n', { mode: 0o600 });
const before = f.bytes();
const fired = f.fire('rev-2');
const fired = await f.fire('rev-2');
assert.equal(fired.delisted, false);
assert.equal(fired.transaction, null);
assert.deepEqual(fired.credentials, { revoked: ['gitea', 'vikunja'], outsideStore: [] });
@@ -122,10 +124,10 @@ test('T20 fire: with no entry in the running bus, a token on disk refuses and an
const f = await setup(t, { drop: ['rev-2'] });
f.plant('rev-2', 'gitea');
const before = f.bytes();
assert.equal(f.code(() => f.fire('rev-2')), 'credential-revoke-failed');
await assert.rejects(f.fire('rev-2'),{code:'credential-revoke-failed'});
assert.equal(f.bytes(), before);
rmSync(join(f.agentDir('rev-2'), 'gitea'));
const fired = f.fire('rev-2');
const fired = await f.fire('rev-2');
assert.equal(fired.delisted, true);
assert.deepEqual(fired.credentials, { revoked: ['gitea', 'vikunja'], outsideStore: [] });
});
@@ -198,11 +200,11 @@ test('row 281 T1 fire: a host.<id> or granted gitea file in the store refuses st
const f = await setup(t);
f.plant('coder', 'host.web1');
const before = f.bytes();
assert.equal(f.code(() => f.fire('coder')), 'credential-revoke-failed');
await assert.rejects(f.fire('coder'),{code:'credential-revoke-failed'});
assert.equal(f.bytes(), before);
assert.deepEqual(readdirSync(f.agentDir('coder')), ['host.web1']);
rmSync(join(f.agentDir('coder'), 'host.web1'));
assert.equal(f.fire('coder').delisted, true);
assert.equal((await f.fire('coder')).delisted, true);
});
// T1, the start: a host_grants record whose key file is gone (what a delete left before this row). The
@@ -1,3 +1,5 @@
import {readTemplate} from '../../organization/src/definitions.mjs';
import { approveTemplateHire, ownerConsole } from './hire-fixtures.mjs';
// #655 isolated STATIC fixture: actual managed fromSession caller + runtime/writer/controller.
import fs from 'node:fs';
import http from 'node:http';
@@ -25,10 +27,10 @@ export async function ownershipFixture(t,{launches=true,listed=true,keep=false,r
// Available before any acquired resource/setup can fail; no later f/launcher TDZ.
const launchLog=()=>{const file=launchLogFile(join(root,'data'),'acme');return fs.existsSync(file)?fs.readFileSync(file,'utf8').trim().split('\n').filter(Boolean).map(line=>JSON.parse(line)):[];};
t.after(async()=>{const failures=[];for(const g of gates)g.release();try{await waitFor(()=>active.size===0,'owned operations did not settle',5000);}catch{failures.push('owned-operation-close');}for(const close of [...closers].reverse()){try{await close();}catch{failures.push('owned-close');}}try{server?.closeAllConnections();if(server?.listening)await new Promise((resolve,reject)=>server.close(e=>e?reject(e):resolve()));}catch{failures.push('owned-server-close');}try{checkHistory({logs,proxyReceipts,trace});}catch{failures.push('value-free-receipts');}try{checkHistory({launch:launchLog()});}catch{failures.push('owned-launch-log-guard');}if(stderr)process.stderr.write=stderr;try{fs.rmSync(root,{recursive:true,force:true});}catch{failures.push('owned-root-close');}if(failures.length)throw new AggregateError(failures.map(code=>Error(code)),'owned managed fixture cleanup failed');});
const f=fixture(root,'acme',doc=>{doc.plugins={...doc.plugins,harness:['pi']};for(const role of Object.values(doc.roles))role.vars={...role.vars,harness:'pi'};doc.roles.chief.vars.model='claude-opus-5-5';doc.roles.aide={definition:'aide',vars:{harness:'pi',model:'claude-opus-5-5',thinking:'high'}};if(listed)doc.launch.instances.push('aide');if(keep)doc.launch.keep=['aide'];if(retired)doc.retired=['alpha'];if(noHire)doc.launch.noHire=['aide'];return doc;});
const f=fixture(root,'acme',doc=>{doc.plugins={...doc.plugins,harness:['pi']};for(const role of Object.values(doc.roles))role.vars={...role.vars,harness:'pi'};doc.roles.chief.vars.model='claude-opus-5-5';doc.roles.aide={definition:'aide',vars:{harness:'pi',model:'claude-opus-5-5',thinking:'high'}};if(listed)doc.launch.instances.push('aide');if(keep)doc.launch.keep=['aide'];if(retired)doc.retired=['alpha'];if(noHire)doc.launch.noHire=['aide'];if(!listed)delete doc.roles.aide;return doc;});
// The established scratch aide pattern only; repository roles/policy stay untouched.
for(const name of ['aide','helper']){fs.writeFileSync(join(f.roles,name+'.md'),'# Scratch aide\n\n## Authority\n');writeJson(join(f.roles,name+'.json'),{roleVersion:2,name,title:'Aide',contract:name+'.md',tools:['read'],network:'api-only',authority:{withinRole:['message.send'],crossRole:[]},credentials:[]});}
const chiefFile=join(f.roles,'chief-of-staff.json');writeJson(chiefFile,{...JSON.parse(fs.readFileSync(chiefFile,'utf8')),hireable:['aide','helper']});
const chiefFile=join(f.roles,'chief-of-staff.json');
const env={PATH:process.env.PATH,HOME:process.env.HOME,TMPDIR:process.env.TMPDIR,MOSAIC_CONFIG:f.config,MOSAIC_ROLES_DIR:f.roles};
const configDir=join(root,'config'),prefix=`msk-${id(configDir)}-`,sentinel=prefix+'sentinel-'+randomBytes(32).toString('base64url');history.add(sentinel);
const state={keys:[sentinel],calls:[],refuseProbe,counts:{GET:0,PUT:0,PROBE:0},puts:[],failRemoval:false};
@@ -110,7 +112,7 @@ export async function ownershipFixture(t,{launches=true,listed=true,keep=false,r
const file=join(configDir,'businesses','acme.json');
const hold=(method='GET',ordinal=state.counts[method]+1)=>{let release;const promise=new Promise(r=>{release=r;});const gate={method,ordinal,seen:false,used:false,cancelled:false,promise,release};gates.push(gate);return {ordinal,seen:()=>gate.seen,entered:()=>waitFor(()=>gate.seen,'owned HTTP gate not reached',5000),release,cancel:()=>{gate.cancelled=true;release();}};};
const outcome=promise=>{const box={settled:false};box.promise=track(Promise.resolve(promise).then(value=>{box.settled=true;box.ok=true;box.value=value;return box;},error=>{box.settled=true;box.ok=false;box.code=error.code??'unexpected-error';return box;}));return box;};
const hire=(name='aide',extra={},cap=chief)=>{const called={value:false};const box=outcome(Promise.resolve().then(()=>{const operation=bus.broker.request(cap,{verb:'instance.hire',args:{name,definition:'aide',reason:'fixture ownership',...extra}});called.value=true;return operation;}));Object.defineProperty(box,'invoked',{get:()=>called.value});return box;};
const hire=(name='aide',extra={},cap=chief)=>{const called={value:false};const box=outcome(Promise.resolve().then(()=>{const operation=(async()=>{const definition=extra.definition??'aide';const d=await bus.broker.request(cap,{verb:'hire.raise',args:{op:'hire',level:'org',name,role:{template:definition,sha256:readTemplate(f.roles,definition).sha256},route:{harness:'pi',model:'claude-opus-5-5'},reason:'fixture ownership',...(extra.vars?{vars:extra.vars}:{})}});return bus.broker.request(ownerConsole(bus.broker,'acme'),{verb:'hire.resolve.console',args:{id:d.id,choice:'yes',digest:d.hire.digest}});})();called.value=true;return operation;}));Object.defineProperty(box,'invoked',{get:()=>called.value});return box;};
const record=(name='aide')=>bus.credentials.modelRecord('acme/'+name);
const snapshot=()=>{const journal=join(configDir,'businesses','.history','acme','changes.jsonl');return {membership:id(fs.readFileSync(file)),journal:fs.existsSync(journal)?id(fs.readFileSync(journal)):null,credentials:id(JSON.stringify(bus.credentials.status())),model:record()?id(JSON.stringify(record())):null,remote:id(JSON.stringify(state.keys)),calls:state.calls.length};};
return {root,f,state,trace,proxyReceipts,logs,launcher,bus,chief,record,hold,outcome,hire,snapshot,file,launchLog,expectedKeyId:()=>expectedKeyId,valueFree,
@@ -123,7 +125,7 @@ export async function ownershipFixture(t,{launches=true,listed=true,keep=false,r
assertModelAbsent:name=>{assert.equal(bus.credentials.modelRecord('acme/'+name)===null,true);assert.equal(fs.existsSync(join(configDir,'secrets','agents','acme',name,'model')),false);},
rotateBound:async()=>{const before=record();bus.credentials.writeModel('acme/aide',{...before,rotateBy:new Date(Date.now()-86400_000).toISOString().slice(0,10)});await waitFor(()=>record()?.retiring.some(r=>r.keyId===before.keyId),'original runtime sweep rotates bound generation');return before.keyId;},
explicitEnd:()=>bus.broker.request(chief,{verb:'role.revoke',args:{role:'aide',reason:'fixture explicit end'}}),
fire:()=>outcome(Promise.resolve().then(()=>bus.broker.request(chief,{verb:'instance.fire',args:{name:'aide',reason:'fixture explicit fire'}}))),
fire:()=>outcome(Promise.resolve().then(()=>bus.broker.request(ownerConsole(bus.broker,'acme'),{verb:'hire.start.console',args:{op:'fire',level:'org',name:'aide',reason:'fixture explicit fire'}}))),
runDirectories:()=>fs.existsSync(runsDir(f.dataRoot,'acme'))?fs.readdirSync(runsDir(f.dataRoot,'acme')).map(name=>join(runsDir(f.dataRoot,'acme'),name)):[],
sessions:()=>readSessions(f.dataRoot),send:body=>bus.broker.request(human,{verb:'message.send',args:{to:'aide',body,class:'REQUEST'}}),
reply:sent=>waitFor(()=>bus.broker.request(human,{verb:'message.receive'}).find(m=>m.in_reply_to===sent.id),'real managed reply observed'),
@@ -38,9 +38,8 @@ test('655 actual launch own preflight generation and bound consumer survive conc
for(const [label,options,args,code] of [
['retired name',{retired:true},{name:'alpha'},'organization-instance-retired'],
['listed duplicate vars',{}, {vars:{model:'claude-opus-5-5'}},'organization-instance-exists'],
['fired mismatched legal definition',{listed:false},{definition:'helper'},'organization-instance-exists'],
['fired matching definition with vars',{listed:false},{vars:{model:'claude-opus-5-5'}},'organization-instance-exists'],
['listed duplicate vars',{}, {vars:{model:'claude-opus-5-5'}},'invalid-request'],
['fired matching definition with vars',{listed:false},{vars:{model:'claude-opus-5-5'}},'invalid-request'],
])test('655 shared validation '+label+' has no credential effects',{timeout:30000},async t=>{
const f=await ownershipFixture(t,options),before=f.snapshot(),{name='aide',...extra}=args;await refused(f.hire(name,extra),code);
assert.deepEqual(f.snapshot(),before,'shared hire refusal precedes credential registration or proxy I/O');f.valueFree();
@@ -49,7 +48,7 @@ for(const [label,options,args,code] of [
for(const [label,options,extra,code] of [
['kept target',{keep:true},{},'instance-kept'],
['noHire definition',{noHire:true},{},'definition-not-hireable'],
['ineligible definition',{}, {definition:'security-reviewer'},'definition-not-hireable'],
['duplicate with another definition',{}, {definition:'security-reviewer'},'organization-instance-exists'],
['invalid vars',{}, {vars:{harness:'pi'}},'invalid-request'],
])test('655 existing Broker '+label+' refusal remains before model effects',{timeout:30000},async t=>{
const f=await ownershipFixture(t,options),before=f.snapshot();await refused(f.hire('aide',extra),code);assert.deepEqual(f.snapshot(),before);f.valueFree();
@@ -59,9 +58,9 @@ test('655 existing nonchief actor refusal remains before shared checks and model
const f=await ownershipFixture(t),cap=f.wrongActor(),before=f.snapshot();await refused(f.hire('aide',{},cap),'role-not-chief');assert.deepEqual(f.snapshot(),before);f.valueFree();
});
test('655 valid fired matching definition rehire publishes before queued acquisition',{timeout:30000},async t=>{
test('655 valid new hire publishes before queued acquisition',{timeout:30000},async t=>{
const f=await ownershipFixture(t,{listed:false}),put=f.hold('PUT'),h=f.hire();await put.entered();const p=f.preflight();assert.equal(f.listed('aide'),false);
put.release();await done(h);assert.equal(h.ok,true);assert.equal(typeof h.value.transaction,'string');await done(p);assert.equal(p.ok,true);assert.equal(f.listed('aide'),true);assert.equal(p.value.key===f.record().key,true);assert.equal(await f.healthy(p.value.key),200);f.valueFree();
put.release();await done(h);assert.equal(h.ok,true);assert.equal(typeof h.value.transaction,'string');await done(p);assert.equal(p.code,'unknown-role','no admission before membership publication');assert.equal(f.listed('aide'),true);const admitted=await done(f.preflight());assert.equal(admitted.ok,true);assert.equal(admitted.value.key===f.record().key,true);assert.equal(await f.healthy(admitted.value.key),200);f.valueFree();
});
test('655 preexisting admitted generation and retiring metadata survive duplicate failure',{skip:unavailable,timeout:30000},async t=>{
@@ -74,22 +73,22 @@ test('655 captured revision is checked after controller queue before generation
const f=await ownershipFixture(t),gate=f.hold('GET'),p=f.preflight('chief');await gate.entered();const h=f.hire('alpha');
// A following same-business operation proves H established its pending slot,
// rather than an arbitrary sleep before the external owner revision change.
await refused(f.hire('beta'),'organization-busy');f.bumpRevision();gate.release();await done(p);assert.equal(p.ok,true);await refused(h,'revision-conflict');
await refused(f.hire('beta'),'organization-busy');f.bumpRevision();gate.release();await done(p);assert.equal(p.ok,true);await refused(h,'decision-stale');
assert.equal(f.state.counts.PUT,1,'queued stale hire does not issue a generation');f.assertModelAbsent('alpha');assert.equal(f.listed('alpha'),false);f.valueFree();
});
test('655 genuine new generation revision failure cleans before queued acquisition receives a key',{timeout:30000},async t=>{
const f=await ownershipFixture(t,{listed:false}),put=f.hold('PUT'),h=f.hire();await put.entered();
const failedGeneration=f.state.puts[0].find(k=>!k.includes('-sentinel-'));assert.ok(failedGeneration,'real first issuance payload observed');
const p=f.preflight();f.bumpRevision();put.release();await refused(h,'revision-conflict');await done(p);
const valid=p.ok&&p.value.key!==failedGeneration&&f.record()?.key===p.value.key&&f.state.keys.includes(p.value.key)&&!f.state.keys.includes(failedGeneration);
assert.equal(valid,true,'queued acquisition receives a valid post-cleanup generation');assert.equal(f.listed('aide'),false);assert.equal(await f.healthy(p.value.key),200);f.valueFree();
const p=f.preflight();f.bumpRevision();put.release();await refused(h,'decision-stale');await done(p);
assert.equal(p.code,'unknown-role','an unpublished agent cannot acquire a model key');
assert.equal(f.state.keys.includes(failedGeneration),false,'failed generation was removed');f.assertModelAbsent('aide');assert.equal(f.listed('aide'),false);f.valueFree();
});
test('655 failed cleanup retains revoking intent and refuses queued admission',{timeout:30000},async t=>{
const f=await ownershipFixture(t,{listed:false}),put=f.hold('PUT'),h=f.hire();await put.entered();const p=f.preflight();f.bumpRevision();f.state.failRemoval=true;put.release();
await refused(h,'revision-conflict');await done(p);assert.equal(f.record()?.state,'revoking','failed removal keeps durable nonlaunchable intent');
assert.equal(!p.ok&&p.code==='proxy-key-revoking',true,'queued admission refuses retained revoking intent');assert.equal(f.listed('aide'),false);assert.equal(fs.existsSync(join(f.root,'config','secrets','agents','acme','aide','model')),true);f.valueFree();
await refused(h,'decision-stale');await done(p);assert.equal(f.record()?.state,'revoking','failed removal keeps durable nonlaunchable intent');
assert.equal(!p.ok&&p.code==='unknown-role',true,'unpublished membership refuses admission while model record retains revoking intent');assert.equal(f.listed('aide'),false);assert.equal(fs.existsSync(join(f.root,'config','secrets','agents','acme','aide','model')),true);f.valueFree();
});
test('655 no-launch remains before own preflight and grants no consumer or stop authority',{timeout:30000},async t=>{
+34 -9
View File
@@ -1,3 +1,6 @@
import {materializeCopies,withCopies} from '../../organization/src/agent-copies.mjs';
import {loadRootAgents} from '../../organization/src/agent-levels.mjs';
import { approveTemplateHire, ownerConsole } from './hire-fixtures.mjs';
// P2: real private Credentials, fake loopback management/model server. No live config.
import test from 'node:test';
import assert from 'node:assert/strict';
@@ -173,7 +176,7 @@ test('proxy-stop-failed: old proxy key and retiring record survive a failed grac
stopped = true; await overdue.sweep(); assert.equal(f.state.keys.includes(first.key), false);
});
async function bootBusiness(f, binding = {}) {
async function bootBusiness(f, {copyLevel = null,...binding} = {}) {
const rolesDir = rolesCopy(f.root), system = systemFor(f.root), doc = businessDoc(f.root, 'demo');
// Aide is needed because shipped chief hireable:[] and security-reviewer has review.verdict outside HIRE_ALLOWED.
fs.writeFileSync(join(rolesDir, 'aide.md'), '# Aide\n\n## Authority\n');
@@ -181,23 +184,26 @@ async function bootBusiness(f, binding = {}) {
roleVersion: 2, name: 'aide', title: 'Aide', contract: 'aide.md', tools: ['read'], network: 'api-only',
authority: { withinRole: ['message.send'], crossRole: [] }, credentials: [],
});
const chiefFile = join(rolesDir, 'chief-of-staff.json');
writeJson(chiefFile, { ...JSON.parse(fs.readFileSync(chiefFile, 'utf8')), hireable: ['aide'] });
doc.plugins = { ...doc.plugins, harness: ['pi'] };
doc.roles['sec-1'] = { definition: 'security-reviewer' }; doc.launch.instances.push('sec-1');
const copyEntry={definition:'aide',vars:{harness:'pi',model:'claude-opus-5-5'}};
if(copyLevel==='project')doc.projects.stack.agents={aide:copyEntry};
if(copyLevel==='root')writeJson(join(f.configDir,'agents.json'),{agentsVersion:1,agents:{aide:copyEntry}});
const copies=copyLevel?materializeCopies({orgId:'demo',doc,rolesDir,root:loadRootAgents(f.configDir,{rolesDir})}):null;
const file = writeJson(join(f.configDir, 'businesses', 'demo.json'), doc);
fs.writeFileSync(join(f.configDir, 'secrets', 'proxy-management.key'), 'fx331-manage', { mode: 0o600 });
const business = loadBusiness('demo', { dir: f.configDir, rolesDir }), resolved = {};
const business = copies?withCopies(doc,file,{rolesDir,snapshot:copies}):loadBusiness('demo', { dir: f.configDir, rolesDir }), resolved = {};
for (const instance of Object.keys(business.roles)) resolved[instance] = resolveInstance({ system, business, instance });
fs.mkdirSync(join(f.root, 'data'), { mode: 0o700 });
// Fresh mkdtemp fixture + exclusive mkdir above: no prior session registry can exist.
new Registry(join(f.root, 'data'));
const bus = await startBroker({ dataRoot: join(f.root, 'data'), businesses: { demo: busOrganization(business, resolved) },
organization: { dir: f.configDir, rolesDir, system }, proxy: { port: Number(new URL(f.baseUrl).port), binary: '/fixture-unused' }, ...binding });
copies, organization: { dir: f.configDir, rolesDir, system }, proxy: { port: Number(new URL(f.baseUrl).port), binary: '/fixture-unused' }, ...binding });
f.closers.push(() => bus.close());
const chief = bus.broker.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi' });
bus.broker.request(chief, { verb: 'role.claim' });
const fire = () => bus.broker.request(chief, { verb: 'instance.fire', args: { name: 'sec-1', reason: 'fixture fire' } });
const fire = () => bus.broker.request(ownerConsole(bus.broker), { verb: 'hire.start.console', args: { op:'fire',level:'org',name: 'sec-1', reason: 'fixture fire' } });
const listed = () => JSON.parse(fs.readFileSync(file, 'utf8')).launch.instances.includes('sec-1');
return { bus, chief, fire, listed, file, rolesDir, system };
}
@@ -233,7 +239,7 @@ test('proxy-revoke-revision-conflict: racing organization write keeps record, ne
});
test('bus hire issues and confirms implicit model key before publishing new membership', async t => {
const f = await fixture(t); const b = await bootBusiness(f); const calls = f.state.calls.length;
const result = await b.bus.broker.request(b.chief, { verb: 'instance.hire', args: { name: 'sec-new', definition: 'aide', reason: 'fixture hire' } });
const result = await approveTemplateHire(b.bus.broker,b.rolesDir,{name:'sec-new',definition:'aide'});
assert.equal(typeof result.transaction, 'string');
assert.equal(JSON.parse(fs.readFileSync(b.file, 'utf8')).launch.instances.includes('sec-new'), true);
assert.equal(b.bus.credentials.modelRecord('demo/sec-new')?.state, 'valid');
@@ -242,7 +248,7 @@ test('bus hire issues and confirms implicit model key before publishing new memb
test('proxy-key-issue-failed: bus hire failure publishes neither membership nor model file', async t => {
const f = await fixture(t); const b = await bootBusiness(f); f.state.failPut = true;
const before = fs.readFileSync(b.file, 'utf8');
await assert.rejects(b.bus.broker.request(b.chief, { verb: 'instance.hire', args: { name: 'sec-new', definition: 'aide', reason: 'fixture hire' } }), failCode('proxy-key-issue-failed'));
await assert.rejects(approveTemplateHire(b.bus.broker,b.rolesDir,{name:'sec-new',definition:'aide'}), failCode('proxy-key-issue-failed'));
assert.equal(fs.readFileSync(b.file, 'utf8'), before); assert.equal(b.bus.credentials.modelRecord('demo/sec-new') === null, true);
});
test('proxy-open: removing sentinel after boot refuses the next launch before models probe', async t => {
@@ -252,7 +258,7 @@ test('proxy-open: removing sentinel after boot refuses the next launch before mo
});
test('newly hired role can use runtime preflight without a broker restart', async t => {
const f = await fixture(t); const b = await bootBusiness(f);
await b.bus.broker.request(b.chief, { verb: 'instance.hire', args: { name: 'sec-new', definition: 'aide', reason: 'fixture hire' } });
await approveTemplateHire(b.bus.broker,b.rolesDir,{name:'sec-new',definition:'aide'});
const proxy = await b.bus.modelPreflight({ business: 'demo', role: 'sec-new' });
assert.equal(proxy.baseUrl, f.baseUrl); assert.equal(f.state.keys.includes(proxy.key), true);
});
@@ -328,3 +334,22 @@ test('beta bus registers while alpha bus runs and alpha key stays accepted', asy
assert.ok(f.state.keys.some(value => value.startsWith(`mso-${hash(f.configDir)}-beta-sentinel-`)));
assert.equal((await alpha.bus.modelPreflight({ business: 'demo', role: 'sec-1' })).key, key);
});
for(const level of ['root','project'])test(`A4 ${level} fire removes its implicit model key before publication`,async t=>{
const f=await fixture(t),b=await bootBusiness(f,{copyLevel:level});
const instance=level==='root'?'aide':'aide.stack';
const issued=await b.bus.modelPreflight({business:'demo',role:instance});
assert.ok(issued.key);
await b.bus.broker.request(ownerConsole(b.bus.broker),{verb:'hire.start.console',args:{op:'fire',level,...(level==='project'?{project:'stack'}:{}),name:'aide',reason:'Copy work finished'}});
assert.equal(f.state.keys.includes(issued.key),false,'remote generation is revoked');
assert.equal(b.bus.credentials.modelRecord('demo/'+instance),null,'private generation is removed');
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);
});
@@ -1,3 +1,4 @@
import { approveTemplateHire } from './hire-fixtures.mjs';
// #651 actual runtime + organization.hire publication with orphan private records.
// In-process trusted Broker, real SQLite/organization writer/HTTP; not process IPC.
import test from 'node:test';
@@ -16,7 +17,7 @@ async function hireFixture(t,{revoking=false}={}){
const rolesDir=rolesCopy(f.root),system=systemFor(f.root),doc=businessDoc(f.root,'demo');
// Scratch aide: shipped chief hireable:[] and review.verdict is outside HIRE_ALLOWED.
fs.writeFileSync(join(rolesDir,'aide.md'),'# Aide\n\n## Authority\n');writeJson(join(rolesDir,'aide.json'),{roleVersion:2,name:'aide',title:'Aide',contract:'aide.md',tools:['read'],network:'api-only',authority:{withinRole:['message.send'],crossRole:[]},credentials:[]});
const chiefFile=join(rolesDir,'chief-of-staff.json');writeJson(chiefFile,{...JSON.parse(fs.readFileSync(chiefFile,'utf8')),hireable:['aide']});doc.plugins={...doc.plugins,harness:['pi']};
const chiefFile=join(rolesDir,'chief-of-staff.json');doc.plugins={...doc.plugins,harness:['pi']};
const file=writeJson(join(f.configDir,'businesses','demo.json'),doc);fs.writeFileSync(join(f.configDir,'secrets','proxy-management.key'),'fx651-manage',{mode:0o600});
const business=loadBusiness('demo',{dir:f.configDir,rolesDir}),resolved={};for(const instance of Object.keys(business.roles))resolved[instance]=resolveInstance({system,business,instance});
const dataRoot=join(f.root,'data');fs.mkdirSync(dataRoot,{mode:0o700});
@@ -27,7 +28,7 @@ async function hireFixture(t,{revoking=false}={}){
assert.equal(f.state.keys.includes(issued.key),false,'runtime boot reconciled omitted orphan');assert.equal(bus.credentials.modelRecord('demo/orphan')===null,true,'orphan remains unregistered before hire');
const cap=bus.broker.bindLaunch({business:'demo',role:'chief',run:'orphan-hire-chief',harness:'pi'});bus.broker.request(cap,{verb:'role.claim'});
const listed=()=>JSON.parse(fs.readFileSync(file,'utf8')).launch.instances.includes('orphan'),observed=[];f.state.observePut=()=>observed.push(listed());f.state.calls=[];
const hire=()=>bus.broker.request(cap,{verb:'instance.hire',args:{name:'orphan',definition:'aide',reason:'fixture orphan hire'}});
const hire=()=>approveTemplateHire(bus.broker,rolesDir,{name:'orphan',definition:'aide'});
return {...f,bus,issued,file,log,listed,observed,hire};
}
test('L3b runtime hire publishes orphan only after fresh remote confirmation',async t=>{
+4 -2
View File
@@ -1,3 +1,5 @@
import { readTemplate } from '../../organization/src/definitions.mjs';
import { ownerConsole } from './hire-fixtures.mjs';
// #654 isolated real runtime/writer/controller with owned HTTP request gates.
import fs from 'node:fs';
import http from 'node:http';
@@ -22,7 +24,7 @@ export async function pendingFixture(t,{twoBusinesses=false}={}){
const configDir=join(root,'config'),dataRoot=join(root,'data'),rolesDir=rolesCopy(root),system=systemFor(root);fs.mkdirSync(dataRoot,{mode:0o700});
const doc=businessDoc(root,'demo');doc.plugins={...doc.plugins,harness:['pi']};
// Existing row331 scratch aide pattern only; repository role authority unchanged.
fs.writeFileSync(join(rolesDir,'aide.md'),'# Aide\n\n## Authority\n');writeJson(join(rolesDir,'aide.json'),{roleVersion:2,name:'aide',title:'Aide',contract:'aide.md',tools:['read'],network:'api-only',authority:{withinRole:['message.send'],crossRole:[]},credentials:[]});const chiefFile=join(rolesDir,'chief-of-staff.json');writeJson(chiefFile,{...JSON.parse(fs.readFileSync(chiefFile,'utf8')),hireable:['aide']});
fs.writeFileSync(join(rolesDir,'aide.md'),'# Aide\n\n## Authority\n');writeJson(join(rolesDir,'aide.json'),{roleVersion:2,name:'aide',title:'Aide',contract:'aide.md',tools:['read'],network:'api-only',authority:{withinRole:['message.send'],crossRole:[]},credentials:[]});
const ids=twoBusinesses?['demo','other']:['demo'],files={},businesses={};for(const name of ids){const document={...structuredClone(doc),id:name};files[name]=writeJson(join(configDir,'businesses',name+'.json'),document);const business=loadBusiness(name,{dir:configDir,rolesDir}),resolved={};for(const instance of Object.keys(business.roles))resolved[instance]=resolveInstance({system,business,instance});businesses[name]=busOrganization(business,resolved);}
fs.mkdirSync(join(configDir,'secrets'),{recursive:true,mode:0o700});fs.writeFileSync(join(configDir,'secrets','proxy-management.key'),'fx654-manage',{mode:0o600});fs.mkdirSync(join(dataRoot,'bus-host'),{recursive:true,mode:0o700});writeJson(sessionsFile(dataRoot),{sessionsVersion:SESSIONS_VERSION,sessions:[]});
const sentinel=`msk-${hash(configDir).slice(0,12)}-sentinel-${randomBytes(32).toString('base64url')}`;state.keys.push(sentinel);history.add(sentinel);
@@ -44,7 +46,7 @@ export async function pendingFixture(t,{twoBusinesses=false}={}){
const holdNext=(method='GET')=>{assert.equal(state.nextGate,null,'only one owned HTTP hold armed');const g={method,entered:deferred(),release:deferred()};gates.push(g);state.nextGate=g;return {entered:()=>bounded(g.entered.promise,'owned lifecycle HTTP boundary not observed'),release:()=>g.release.resolve()};};
const listed=(business,name)=>JSON.parse(fs.readFileSync(files[business],'utf8')).launch.instances.includes(name);
const snapshot=business=>{const journal=join(configDir,'businesses','.history',business,'changes.jsonl');return {membership:hash(fs.readFileSync(files[business])),publicationJournal:fs.existsSync(journal)?hash(fs.readFileSync(journal)):null,modelStatus:hash(JSON.stringify(bus.credentials.status())),remote:hash(JSON.stringify(state.keys)),calls:state.calls.length};};
const hire=(business,name,definition='aide',cap=chief[business])=>track(Promise.resolve().then(()=>bus.broker.request(cap,{verb:'instance.hire',args:{name,definition,reason:'fixture pending hire'}})));
const hire=(business,name,definition='aide',cap=chief[business])=>track(Promise.resolve().then(()=>(async()=>{const d=await bus.broker.request(cap,{verb:'hire.raise',args:{op:'hire',level:'org',name,role:{template:definition,sha256:readTemplate(rolesDir,definition).sha256},route:{harness:'pi',model:'claude-opus-5-5'},reason:'fixture pending hire'}});return bus.broker.request(ownerConsole(bus.broker,business),{verb:'hire.resolve.console',args:{id:d.id,digest:d.hire.digest,choice:'yes'}});})()));
const busy=async promise=>{const result=await bounded(promise.then(()=>({code:'unexpected-success'}),e=>({code:e.code??'unexpected-error'})),'same-business refusal not observed');assert.equal(result.code,'organization-busy','second same-business lifecycle call refuses exact pending code');};
return {root,configDir,dataRoot,rolesDir,bus,state,receipts,chief,holdNext,listed,snapshot,hire,busy,valueFree,
healthy:async(business,name)=>{const value=await bus.modelPreflight({business,role:name});const record=bus.credentials.modelRecord(business+'/'+name);assert.equal(value.key===record.key,true);assert.equal(state.keys.includes(record.key),true,'published member retains confirmed model credential');},
@@ -1,3 +1,4 @@
import {ownerConsole} from './hire-fixtures.mjs';
// #656 STATIC isolated fixtures. No real config/key/proxy; no production seam.
import fs from 'node:fs';
import http from 'node:http';
@@ -74,7 +75,7 @@ export async function revocationFixture(t,{runtime=false,onRoot=()=>{},missingBo
syncBuiltinESMExports();restoreFs=()=>{Object.assign(fs,original);syncBuiltinESMExports();};
const record=()=>c.modelRecord(instance),exists=path=>{try{original.lstatSync(path);return true;}catch(error){if(error.code==='ENOENT')return false;throw error;}};
const seed=({matchingTmp=true,retiring=0,valid=false}={})=>{const current=key(),old=Array.from({length:retiring},()=>({key:key(),issuedAt:'2026-10-23T00:00:00.000Z',retireAt:'2099-01-01T00:00:00.000Z'})).map(r=>({...r,keyId:id(r.key)}));const value={key:current,keyId:id(current),issuedAt:'2026-10-24T00:00:00.000Z',rotateBy:'2099-01-01',state:valid?'valid':'revoking',...(valid?{}:{revokingAt:'2026-10-25T00:00:00.000Z'}),retiring:old};state.keys.push(current,...old.map(r=>r.key));c.writeModel(instance,value);if(matchingTmp){fs.copyFileSync(file,temporary);fs.chmodSync(temporary,0o600);}removedTmp=false;return value;};
const lifecycle=how=>track((async()=>{if(how==='fire')return await bus.broker.request(chief,{verb:'instance.fire',args:{name:'aide',reason:'fixture recognized tmp'}});const revision=writer.read('demo').revision;const request=bus.broker.request(human,{verb:'instances.set',args:{revision,instance:'aide',op:'delete'}});const route={business:'demo',request:request.request},take=bus.lifecycleTake(route);assert.equal(take.go,true,'real trusted delete request is taken');try{const result=await bus.lifecycleDelete(route);bus.lifecycleDone({...route,transaction:result.transaction});return result;}catch(error){bus.lifecycleDone({...route,refusal:error.code??'outcome-unknown'});throw error;}})());
const lifecycle=how=>track((async()=>{if(how==='fire')return await bus.broker.request(ownerConsole(bus.broker),{verb:'hire.start.console',args:{op:'fire',level:'org',name:'aide',reason:'fixture recognized tmp'}});const revision=writer.read('demo').revision;const request=bus.broker.request(human,{verb:'instances.set',args:{revision,instance:'aide',op:'delete'}});const route={business:'demo',request:request.request},take=bus.lifecycleTake(route);assert.equal(take.go,true,'real trusted delete request is taken');try{const result=await bus.lifecycleDelete(route);bus.lifecycleDone({...route,transaction:result.transaction});return result;}catch(error){bus.lifecycleDone({...route,refusal:error.code??'outcome-unknown'});throw error;}})());
const writeSessions=entries=>writeJson(sessionsFile(dataRoot),{sessionsVersion:SESSIONS_VERSION,sessions:entries});
const ownRun=async({wrongTicks=false,wrongKey=false,publish=true}={})=>{
const spawnOwn=async()=>{const handle=spawn('/usr/bin/sleep',['60'],{stdio:'ignore'}),closed=new Promise(resolve=>handle.once('close',resolve));const owned={handle,closed};children.push(owned);await once(handle,'spawn');const ticks=startTimeOf(handle.pid);assert.equal(typeof ticks,'string');return {...owned,ticks,exit:async()=>{if(handle.exitCode===null&&handle.signalCode===null)handle.kill('SIGTERM');await bounded(closed,'owned stand-in exit timeout');}};};
+5 -2
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';",
@@ -41,11 +43,12 @@ test('Broker keeps the sandbox client import closure: only the listed imports, n
// Row 236 (#1742, #436b) reads giteaName and parseGiteaUrl for the Gitea grant path.
// FED R3 r5 (row 274, #414) reads covers, a user's grant check, from it.
// Row 219 reads THREAD_DEFINITIONS from the same vocabulary for creative thread admission.
"import { pluginName, ID_PATTERN, THREAD_DEFINITIONS, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers, grantScopeOf, grantInScope } from '../../organization/src/vocabulary.mjs';",
"import { pluginName, ID_PATTERN, THREAD_DEFINITIONS, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, giteaName, parseGiteaUrl, covers, grantScopeOf, grantInScope } from '../../organization/src/vocabulary.mjs';",
]);
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/);
});
+109
View File
@@ -0,0 +1,109 @@
// #1873 bus side of the Console proxy setup: proxy.init with a bus-generated key, the owner-only
// proxy.fence.clear verb, and a lock clear that re-proves restart evidence before it clears.
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Broker } from '../src/broker.mjs';
import { Store } from '../src/store.mjs';
import { createProxyLock } from '../src/proxy-lock.mjs';
import { REFUSALS } from '../src/refusals.mjs';
import { validManagementKey } from '../../model-proxy/src/common.mjs';
const role = { authority: { withinRole: ['message.send'], crossRole: [] } };
const code = name => error => error.code === name;
function fixture(t, { proxyInit = null, proxyFenceClear = null } = {}) {
const store = new Store(fs.mkdtempSync(join(tmpdir(), 'ps-bus-')));
t.after(() => store.close());
const b = new Broker({ store, proxyInit, proxyFenceClear, businesses: { demo: { id: 'demo', human: 'owner', arbiters: { technical: 'chief', delivery: 'chief' }, vars: { 'bus.tester': true }, roles: { chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } }, coder: role } } } });
return {
store, b,
human: b.bindHuman({ business: 'demo', human: 'owner', via: 'cli', outsideAgent: true }),
tester: b.bindTester({ business: 'demo' }), reader: b.bindReader({ business: 'demo' }),
agent: b.bindLaunch({ business: 'demo', role: 'coder', run: 'ps-run', harness: 'pi' }),
};
}
const events = store => JSON.stringify(store.all('SELECT body FROM events'));
const bodies = store => store.all('SELECT body FROM events').map(r => JSON.parse(r.body));
test('proxy.init generate:true makes a valid key on the bus, hands it only to init, and never returns or records it', async t => {
const seen = [];
const f = fixture(t, { proxyInit: async args => { seen.push(args); return { port: args.port ?? 18432, binary: '/fixture' }; } });
const result = await f.b.request(f.human, { verb: 'proxy.init', args: { generate: true, port: 18440, rotateGraceMinutes: 5 } });
assert.deepEqual(result, { port: 18440, binary: '/fixture' });
assert.equal(seen.length, 1);
assert.deepEqual(Object.keys(seen[0]).sort(), ['key', 'port', 'rotateGraceMinutes']);
assert.equal(validManagementKey(seen[0].key), true);
assert.match(seen[0].key, /^[A-Za-z0-9_-]{64}$/, '48 CSPRNG bytes as base64url');
assert.equal(JSON.stringify(result).includes(seen[0].key), false);
assert.equal(events(f.store).includes(seen[0].key), false);
assert.deepEqual(bodies(f.store).filter(b => b.call === 'proxy.init'), [{ call: 'proxy.init', port: 18440, generated: true }]);
await f.b.request(f.human, { verb: 'proxy.init', args: { generate: true } });
assert.notEqual(seen[1].key, seen[0].key, 'each init generates a fresh key');
});
test('proxy.init generate refuses agents and testers, and refuses a key with generate or generate other than true', async t => {
let calls = 0;
const f = fixture(t, { proxyInit: async () => { calls++; return { port: 18432, binary: '/fixture' }; } });
for (const cap of [f.agent, f.tester]) assert.throws(() => f.b.request(cap, { verb: 'proxy.init', args: { generate: true } }), code('human-required'));
assert.throws(() => f.b.request(f.reader, { verb: 'proxy.init', args: { generate: true } }), code('read-only'));
for (const args of [{ generate: true, key: 'ps-sentinel-key-0001' }, { generate: false }, { generate: 'yes' }, {}])
assert.throws(() => f.b.request(f.human, { verb: 'proxy.init', args }), code('invalid-request'), JSON.stringify(args));
assert.equal(calls, 0);
assert.equal(events(f.store).includes('ps-sentinel-key-0001'), false);
// The same verb the refusals guard does run for the owner.
await f.b.request(f.human, { verb: 'proxy.init', args: { generate: true } });
assert.equal(calls, 1);
});
test('proxy.fence.clear is owner-only, passes the evidence through, and records only the pid', async t => {
const seen = [];
const evidence = { unit: 'mosaic-proxy.service', confirmed: true, before: { pid: 1 }, after: { pid: 2 }, observedAt: new Date(0).toISOString() };
const f = fixture(t, { proxyFenceClear: async args => { seen.push(args); return { cleared: true, evidence: { after: { pid: 2 } } }; } });
for (const cap of [f.agent, f.tester]) assert.throws(() => f.b.request(cap, { verb: 'proxy.fence.clear', args: { evidence } }), code('human-required'));
assert.throws(() => f.b.request(f.reader, { verb: 'proxy.fence.clear', args: { evidence } }), code('read-only'));
assert.throws(() => f.b.request(f.human, { verb: 'proxy.fence.clear', args: {} }), code('invalid-request'));
assert.throws(() => f.b.request(f.human, { verb: 'proxy.fence.clear', args: { evidence, restarted: true } }), code('invalid-request'));
assert.equal(seen.length, 0);
assert.deepEqual(await f.b.request(f.human, { verb: 'proxy.fence.clear', args: { evidence } }), { cleared: true, evidence: { after: { pid: 2 } } });
assert.deepEqual(seen, [{ evidence }]);
assert.deepEqual(bodies(f.store).filter(b => b.call === 'proxy.fence.clear'), [{ call: 'proxy.fence.clear', cleared: true, pid: 2 }]);
});
test('proxy.fence.clear maps a refusal to its proxy code and a missing runtime to not-configured', async t => {
const f = fixture(t, { proxyFenceClear: async () => { throw Object.assign(Error('/home/x detail'), { code: 'proxy-restart-unconfirmed' }); } });
await assert.rejects(f.b.request(f.human, { verb: 'proxy.fence.clear', args: { evidence: {} } }), e => e.code === 'proxy-restart-unconfirmed' && !e.message.includes('/home/x'));
const g = fixture(t);
assert.throws(() => g.b.request(g.human, { verb: 'proxy.fence.clear', args: { evidence: {} } }), code('not-configured'));
});
test('a lock clear with verify: the fence an uncertain write set clears only on proof, and the receipt holds the evidence', async t => {
const dir = fs.mkdtempSync(join(tmpdir(), 'ps-lock-'));
t.after(() => fs.rmSync(dir, { recursive: true, force: true }));
const lock = createProxyLock({ stateDir: dir, identity: 'instance-one' });
await assert.rejects(lock.run(async () => { lock.fence({ org: 'acme', op: 'apikey.add' }); throw Error('outcome unknown'); }));
assert.throws(() => lock.assertCertain(), code('proxy-uncertain'));
let read = 0;
await assert.rejects(lock.clear({ restarted: true, read: async () => { read++; }, verify: () => { throw Object.assign(Error('no'), { code: 'proxy-restart-unconfirmed' }); } }), code('proxy-restart-unconfirmed'));
assert.equal(read, 0, 'an unproved restart never reads or clears');
assert.throws(() => lock.assertCertain(), code('proxy-uncertain'));
const seenFence = [];
const result = await lock.clear({ restarted: true, read: async () => { read++; }, verify: previous => { seenFence.push(previous); return { after: { pid: 42 } }; } });
assert.deepEqual(result, { cleared: true, evidence: { after: { pid: 42 } } });
assert.equal(seenFence[0].org, 'acme'); assert.equal(seenFence[0].op, 'apikey.add');
lock.assertCertain();
const receipt = fs.readFileSync(join(dir, 'mosaic-keys.receipts.jsonl'), 'utf8').trim().split('\n').map(JSON.parse).at(-1);
assert.deepEqual(receipt.evidence, { after: { pid: 42 } });
assert.equal(receipt.previous.org, 'acme');
});
test('every #1873 refusal code has a refusal-table entry with a next step', () => {
for (const c of ['proxy-admin-busy', 'proxy-key-transport', 'proxy-key-mismatch', 'proxy-restart-required', 'proxy-restart-unconfirmed', 'proxy-init-lock-held', 'proxy-init-lock-absent', 'proxy-unit-missing', 'proxy-unit-unhealthy', 'proxy-unit-failed', 'proxy-unit-exists', 'proxy-unit-path', 'proxy-unit-usage', 'proxy-install-missing', 'proxy-install-invalid', 'proxy-fetch-hash']) {
const entry = REFUSALS[c];
assert.ok(entry, c);
assert.equal(typeof entry.nextStep, 'string', c);
assert.ok(entry.nextStep.length > 20, c);
assert.equal(/\/(home|mnt|srv|tmp)\//.test(entry.nextStep), false, `${c} names no absolute path`);
}
});
+5 -6
View File
@@ -1,3 +1,4 @@
import {approveTemplateHire,ownerConsole} from './hire-fixtures.mjs';
// Row 194 (#1692, D3, T14): after every write that commits, the broker takes the business as a
// restart would load it, so a created instance binds with no restart. A definition change on a
// running instance refuses, and a file whose credential references changed isn't reloaded.
@@ -149,8 +150,6 @@ test('T19 a credentialed create answers restart true; issue works only after a r
// A, from the file, so the kept instance keeps its claim and isn't left firing.
test('T14 after a refused reload veto.set, instance.hire and instance.fire answer restart true, and a fire of a name kept since refuses before step A', async (t) => {
const f = await setup(t);
const roleFile = join(f.rolesDir, 'chief-of-staff.json');
writeFileSync(roleFile, JSON.stringify({ ...JSON.parse(readFileSync(roleFile, 'utf8')), hireable: ['chief-information-security-officer'] }, null, 2));
mkdirSync(join(f.root, 'secrets', 'agents'), { recursive: true, mode: 0o700 });
const chief = bind(f.bus, 'chief');
f.bus.broker.request(chief, { verb: 'role.claim' });
@@ -162,21 +161,21 @@ test('T14 after a refused reload veto.set, instance.hire and instance.fire answe
const added = f.ask('instances.set', { revision: f.revision(), instance: 'rev-2', op: 'add', definition: 'reviewer', tracker: { bot: 'bot-demo-rev-2', botId: 40 } });
assert.equal(added.restart, true);
const hired = asChief('instance.hire', { name: 'sec-2', definition: 'chief-information-security-officer', reason: 'a security lead' });
assert.equal(hired.restart, true);
const hired = await approveTemplateHire(f.bus.broker,f.rolesDir,{name:'sec-2',definition:'chief-information-security-officer'});
assert.ok(hired.transaction);
const kept = f.ask('veto.set', { revision: f.revision(), op: 'keep', name: 'reviewer' });
assert.equal(kept.restart, true);
assert.equal(f.reloads.length, 0, 'the running copy still has reviewer unkept');
const before = readFileSync(f.file, 'utf8');
assert.equal(code(() => asChief('instance.fire', { name: 'reviewer', reason: 'the work is done' })), 'instance-kept');
await assert.rejects(f.bus.broker.request(ownerConsole(f.bus.broker),{verb:'hire.start.console',args:{op:'fire',level:'org',name:'reviewer',reason:'the work is done'}}),{code:'instance-kept'});
assert.equal(readFileSync(f.file, 'utf8'), before);
assert.deepEqual(f.ask('instances').instances.reviewer.running, run, 'step A never ran: the claim is held');
assert.notEqual(code(() => f.ask('instance.launch', { instance: 'reviewer' })), 'instance-firing');
const unkept = f.ask('veto.set', { revision: f.revision(), op: 'unkeep', name: 'reviewer' });
assert.equal(unkept.restart, true);
const fired = asChief('instance.fire', { name: 'reviewer', reason: 'the work is done' });
const fired = await f.bus.broker.request(ownerConsole(f.bus.broker),{verb:'hire.start.console',args:{op:'fire',level:'org',name:'reviewer',reason:'the work is done'}});
assert.equal(fired.restart, true);
assert.equal(JSON.parse(readFileSync(f.file, 'utf8')).launch.instances.includes('reviewer'), false);
});
+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', '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');
});
+1 -1
View File
@@ -385,7 +385,7 @@ test('R3 r4: the chief\'s hire refuses an instance named user with the code it g
caps.chief = b.bindLaunch({ business: 'demo', role: 'chief', run: 'chief-run', harness: 'pi', address: 'chief' });
b.request(caps.chief, { verb: 'role.claim' });
const hire = (name) => code('chief', 'instance.hire', { name, definition: 'coder', reason: 'more hands' });
for (const name of ['human', 'tester', 'user']) assert.equal(hire(name), 'invalid-request', name);
for (const name of ['human', 'tester', 'user']) assert.equal(hire(name), 'decision-required', name);
// A name that isn't reserved passes the name check and refuses later, for want of role files.
assert.notEqual(hire('helper'), 'invalid-request');
});
+2
View File
@@ -0,0 +1,2 @@
// Compatibility path for organization autonomy policy.
export * from "../../organization/src/autonomy.mjs";
+2
View File
@@ -0,0 +1,2 @@
// Old path of packages/organization/src/copy-bindings.mjs (rename design r2, stage B).
export * from "../../organization/src/copy-bindings.mjs";
@@ -0,0 +1,2 @@
// Old path of packages/organization/src/definition-verbs.mjs (rename design r2, stage B).
export * from "../../organization/src/definition-verbs.mjs";
+2
View File
@@ -0,0 +1,2 @@
// Old path of packages/organization/src/definitions.mjs (rename design r2, stage B).
export * from "../../organization/src/definitions.mjs";
+1
View File
@@ -0,0 +1 @@
export * from "../../organization/src/hire-decisions.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";
+58
View File
@@ -28,6 +28,9 @@ mosaic inbox [--business <id>] [--json]
mosaic inbox --read <id>... | --read-all [--business <id>]
mosaic decide <decision> <option> [--note <text>] [--yes] [--business <id>]
mosaic tasks [--business <id>] [--json]
mosaic tasks create --title <text> --requirement <REQ-X-N> [--project <name>] [--state <state>] [--assignees <role,...>] [--priority 0-5] [--due <iso>|none] --business <id> [--json]
mosaic tasks edit task:<n> --expect <digest> [--title <text>] [--state <state>] [--assignees <role,...>] [--priority 0-5] [--due <iso>|none] --business <id> [--json]
mosaic tasks archive task:<n> --expect <digest> --business <id> [--json]
mosaic agents [--business <id>] [--json] [--tester]
mosaic trail <task|decision> [--business <id>] [--json]
mosaic talk <role> <text> [--wait <seconds> | --follow] [--business <id>]
@@ -298,9 +301,16 @@ mosaic bus start <business> [--pm]
the host; run by the unit, not by hand.
--pm also launches the business's PM
mosaic bus stop SIGTERM to the recorded host, then wait
mosaic bus restart stop, then start with the recorded options
mosaic bus recover clear a dead host's locks, with a receipt
mosaic bus status [--json] host state file, socket, writer.lock
```
`bus start`, `bus stop`, `bus restart` and `bus recover`, and `console start`,
`console stop` and `console restart`, refuse inside an agent run (exit 3,
agent-guard #1873). They check the environment they are given and their own
process environment. `bus status` and `console status` stay open to agents.
`bus start` does the following:
1. It reads the system config through `scripts/mosaic-config.mjs validate`,
@@ -1290,6 +1300,54 @@ mean a bus start is still in progress; if a process crashed and left its lock,
inspect it before removing it and resuming. Moves use rename and therefore
require the source and deleted folder to be on the same filesystem.
`mosaic org archive` and `mosaic org delete` stop the organization bus, so like
`bus stop` they refuse inside an agent run (exit 3, agent-guard #1873), after
their usage checks and before any config is read.
`mosaic org restore ID --if-revision N` makes the organization active and starts
its bus, as `mosaic bus start ID --dev-local-owner --detach` does (E1, P1-4). It
makes that verb's checks before the restore, so a stamped build or an instance
that refuses `--dev-local-owner` refuses the restore before anything changes
(exit 3). Starting a bus is a human action, so inside an agent run `org restore`
refuses like `bus start` (exit 3) before any config is read. The receipt's `bus`
names the outcome: `started` with the host pid, or `failed` with the message and
the verb that starts it (after `mosaic bus recover ID` when a host that died left a
stale lock). A bus that does not start leaves the restore in place;
the command prints the receipt and exits 3. `org restore ID --if-revision N
--no-bus` restores without starting the bus, as before, and stays open to agents.
The hire form and the autonomy panel from a terminal (E2, #1873, P1-3):
```
mosaic org role-templates ORG [--json]
mosaic org hire|change ORG --name NAME --template TEMPLATE --harness H --model M [--thinking T] [--level org|project|root] [--project P] --reason TEXT [--json]
mosaic org fire ORG --name NAME [--level org|project|root] [--project P] --reason TEXT [--json]
mosaic org autonomy ORG [--json]
mosaic org autonomy ORG set --revision REV [--cross-role|--merge|--policy|--authority in|near|out] [--notices on|off] [--reverse-window-hours 1-168] [--authority-resolver NAME|""] [--json]
mosaic org autonomy ORG review cross-role|merge|policy|authority [--json]
```
Each sends the page's own request to the running Console's org route, as
`org reload` does, so the Console runs the same owner, `--send`, origin, body
and session checks and the same bus verbs. `hire` and `change` read the role
templates first and send the chosen template's name and sha256. `autonomy set`
needs the 64-hex `--revision` that `mosaic org autonomy ORG` prints and sends every
setting the panel sends, changing only the named ones; a stale revision is
refused `revision-conflict`. `--authority-resolver ""` clears the resolver, as
emptying the panel's field does, and `--reverse-window-hours` takes 1 to 168.
These verbs need a named instance whose Console runs with `--dev-local-owner`;
the default instance is refused with exit 3. A Console refusal exits 3 with its code and
message, an answer that never came exits 1, an unknown template exits 2. All of
these verbs, reads included, refuse inside an agent run (exit 3) after their
usage checks (exit 4) and before any config is read.
`mosaic tasks create|edit|archive` are the task board's three writes. The
arguments pass the board's own check, then go to the bus as the person
(`tasks.create`, `tasks.edit`, `tasks.archive`), so the bus runs its human,
field, digest and archive checks. Edit and archive need the task's digest from
`mosaic tasks --json` as `--expect`; a stale one is refused `task-conflict`
(exit 2) and nothing is written. They refuse inside an agent run.
System types are installed plug-ins. `mosaic org system-types installed` prints
module metadata; `mosaic org system-types enabled ID` prints an organization's enabled
ids. The owner changes the enabled list through the existing organization file
+4 -2
View File
@@ -154,11 +154,13 @@ function pidAlive(pid) {
}
}
// Names the lock by label: its path sits beside the access file in the config
// directory, and a refusal never quotes a host path (PR #1916).
function lockRefusal(lock) {
const pid = lockPid(lock);
if (pid !== null && pidAlive(pid)) return BUSY;
if (pid === null) return `refused: lock ${lock} is unreadable or not a pid; if no write is running, remove it by hand and try again`;
return `refused: lock ${lock} is left by pid ${pid}, which is not running; if no write is running, remove it by hand and try again`;
if (pid === null) return `refused: access file lock is unreadable or not a pid; if no write is running, remove it by hand and try again`;
return `refused: access file lock is left by pid ${pid}, which is not running; if no write is running, remove it by hand and try again`;
}
function takeLock(lock, fs) {
+5 -3
View File
@@ -37,8 +37,10 @@ function parse(args) {
function owns(user, owner, providers) {
return user.subjects.some(s => s.provider === owner.provider && s.sub === owner.sub && providers.find(p => p.id === s.provider)?.issuer === owner.iss);
}
function readOwner(root) {
const owner = readJsonFile(join(root, 'owner.json'), 'organization owner', st => {
// The registry checked `id` against the id pattern, so the label may name it; the
// root is a host path and never reaches a refusal (PR #1916).
function readOwner(root, id) {
const owner = readJsonFile(join(root, 'owner.json'), `organization owner of ${id}`, st => {
if (st.uid !== process.geteuid() || (st.mode & 0o777) !== 0o600) throw new CliError('refused: unsafe organization owner', 3);
});
rejectUnknownKeys(owner, ['provider', 'iss', 'sub'], 'organization owner');
@@ -92,7 +94,7 @@ export async function accountsCommand(args, io, deps) {
// writing owner.json, so any missing owner must block removal too.
for (const entry of Object.values((await readRegistryLocked(registry)).orgs)) {
let owner;
try { owner = readOwner(entry.root); }
try { owner = readOwner(entry.root, entry.id); }
catch (e) { if (/not found/.test(e.message)) throw new CliError(`refused: org-create-in-progress: ${entry.id} has no owner metadata`, 3); throw e; }
if (owns(user, owner, providers)) throw new CliError(`refused: user-owns-org: ${entry.id}`, 3);
}
+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 });
+39 -5
View File
@@ -37,6 +37,7 @@ import { bootConfig, loadSystem, orgPaths, socketPath } from "./config.mjs";
import { bootLine, readBootStatus, recordBootOk, recordBootRefused } from "./boot-status.mjs";
import { businessFilePath, configDir } from "../../organization/src/index.mjs";
import { busExit, humanTransport, refuseInsideAgent, testerTransport } from "./transport.mjs";
import { definitionsCommand } from "./org-definitions.mjs";
import { formatAgents, formatDecision, formatInbox, formatTasks, formatTrail, shortId } from "./format.mjs";
import { hostStatus, readHostState, startHost, stopHost } from "./host.mjs";
import { createLauncher } from "./launcher.mjs";
@@ -54,6 +55,7 @@ import { agentsRootCommand, AGENTS_ROOT_USAGE, projectAgentsCommand, PROJECT_AGE
import { ORG_CONFIG, runConfigCommand } from "../../organization/src/config-command.mjs";
import { devCommand, DEV_USAGE, fixtureLaunchEnv, fixtureLauncherOptions } from "./dev-fixture.mjs";
import { runCommand, RUN_USAGE } from "./run-evidence.mjs";
import { tasksWriteCommand, TASKS_WRITE_OPS, TASKS_WRITE_USAGE } from "./tasks-write.mjs";
export const USAGE = `usage:
${ACCOUNTS_USAGE}
@@ -62,7 +64,8 @@ export const USAGE = `usage:
${LIMITS_USAGE}
${PLUGINS_USAGE}
${CREDENTIALS_USAGE}
mosaic proxy fetch | fence status [--json] | fence clear --proxy-restarted | init [--port N] [--business ID] [--rotate-grace-minutes N] [--first-output-seconds N]
mosaic proxy fetch | status [--json] | fence status [--json] | fence clear --proxy-restarted | fence clear --restart [--business ID] | init [--generate-key] [--port N] [--business ID] [--rotate-grace-minutes N] [--first-output-seconds N]
mosaic proxy unit render|install|start|stop|restart|status [--json] | init-lock status|clear
${HOSTS_USAGE}
${GRANTS_USAGE}
${ORG_INSTANCES_USAGE}
@@ -80,6 +83,7 @@ export const USAGE = `usage:
mosaic inbox --read <id>... | --read-all [--business <id>]
mosaic decide <decision> <option> [--note <text>] [--yes] [--business <id>]
mosaic tasks [--business <id>] [--json] [--tester]
${TASKS_WRITE_USAGE}
mosaic agents [--business <id>] [--json] [--tester]
mosaic trail <task|decision> [--business <id>] [--json] [--tester]
mosaic talk <role> <text> [--wait <seconds> | --follow] [--business <id>] [--tester]
@@ -518,7 +522,8 @@ async function processingCommand(verb, rest, io, deps) {
if (!result || typeof result !== 'object' || (typeof result.halted !== 'boolean' && !result.invalid))
throw new CliError('processing outcome unknown; use mosaic processing status before trying again', 1);
if (verb === 'halt' && result.quiescent !== true)
throw new CliError(`halt gate is on for instance ${system.dataRoot}; ${result.remaining ?? 'unknown'} stops unconfirmed. Use mosaic processing status`, 1);
// #1873: the refusal names the instance, never its data root; a count that is not a count is unknown.
throw new CliError(`halt gate is on for this instance; ${Number.isSafeInteger(result.remaining) && result.remaining >= 0 ? result.remaining : 'unknown'} stops unconfirmed. Use mosaic processing status`, 1);
if (parsed.flags.has('--json')) { io.stdout.write(JSON.stringify(result, null, 2) + '\n'); return; }
const status = verb === 'resume' ? 'resumed' : result.halted ? 'halted' : result.invalid ? 'uninitialized' : 'running';
io.stdout.write(`processing ${status} for instance ${system.dataRoot}\n`);
@@ -567,10 +572,21 @@ function organizationContext(io, deps) {
return { configDir: dir, dataRoot: system.dataRoot, instance, env: io.env, system };
}
// agent-guard (#1873): the bus and Console lifecycle verbs (start, stop, restart, recover) refuse
// inside an agent run, before any read, with refuseInsideAgent's refusal (exit 3). This process's
// own environment is checked as well as the one the command was given, so a sanitized io.env does
// not hide a marker (D1 security re-check P1). Status verbs stay open to agents. A person keeps
// these verbs in a human shell, and the Console's Restart bus and Restart Console controls.
function refuseAgentRun(io) {
refuseInsideAgent(process.env);
refuseInsideAgent(io.env);
}
async function consoleCommand(rest, io, deps) {
const [sub, ...args] = rest;
if (sub === "stop") {
if (args.length) throw usage("console stop takes no arguments");
refuseAgentRun(io);
io.stdout.write(`${JSON.stringify(await consoleStop(organizationContext(io, deps), { warn: s => io.stderr.write(s) }))}\n`);
return;
}
@@ -583,7 +599,7 @@ async function consoleCommand(rest, io, deps) {
// and trail checks (org-console.mjs consoleRestart); an agent run is refused before any read.
if (sub === "restart") {
if (args.length) throw usage("console restart takes no arguments");
refuseInsideAgent(io.env);
refuseAgentRun(io);
io.stdout.write(`${JSON.stringify(await consoleRestart(organizationContext(io, deps), deps.consoleRequest ?? {}))}\n`);
return;
}
@@ -596,6 +612,9 @@ async function consoleCommand(rest, io, deps) {
const names = Object.hasOwn(parsed.values, "--dev-allow-host") ? [parsed.values["--dev-allow-host"]] : [];
let lan;
try { lan = devLanOptions({ devOwner, bind, names }); } catch (err) { throw usage(err.message); }
// D1, agent-guard: an agent run never starts a Console, dev-local-owner or not (org-console.mjs
// consoleStart checks again). Usage errors above come first; devLanOptions reads nothing.
refuseAgentRun(io);
const receipt = await consoleStart(organizationContext(io, deps), { devLocalOwner: devOwner, detach: parsed.flags.has("--detach"), port: parsed.values["--port"] ?? "auto", ...(lan ? { bind: lan.bind, allowHosts: lan.allowHosts } : {}), warn: s => io.stderr.write(s) });
io.stdout.write(`${JSON.stringify(receipt)}\n`);
if (!parsed.flags.has("--detach")) {
@@ -616,6 +635,9 @@ async function bus(rest, io, deps) {
const parsed = parse(args, { flags: ["--pm", "--no-launch", "--dev-local-owner", "--detach", "--internal-host"] });
if (parsed.positional.length !== 1) throw usage("bus start takes one organization");
if (parsed.flags.has("--no-launch") && parsed.flags.has("--pm")) throw usage("bus start takes --pm or --no-launch, not both");
// agent-guard: --internal-host too. The host a Console or a person starts runs it with that
// process's environment (org-bus.mjs busStart), which carries no agent marker.
refuseAgentRun(io);
assertBuildStart({ devOwner: parsed.flags.has("--dev-local-owner") });
if (parsed.flags.has("--dev-local-owner")) devOwnerInstance(io.env);
const ctx = organizationContext(io, deps);
@@ -650,9 +672,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;
@@ -696,6 +723,9 @@ async function bus(rest, io, deps) {
if (["stop", "restart", "status", "recover"].includes(sub)) {
const parsed = parse(args, { flags: sub === "status" ? ["--json"] : [] });
if (parsed.positional.length !== 1) throw usage(`bus ${sub} takes one organization`);
// D2 security review: recovery clears host state, so an agent run is refused before any read.
// agent-guard: stop and restart too; status stays open to agents.
if (sub !== "status") refuseAgentRun(io);
if (sub === "recover") devOwnerInstance(io.env);
const ctx = organizationContext(io, deps), id = parsed.positional[0];
const result = sub === "stop" ? await busStop(ctx, id) : sub === "restart" ? await busRestart(ctx, id) : sub === "recover" ? await busRecover(ctx, id) : await busStatus(ctx, id);
@@ -737,10 +767,14 @@ export async function main(argv, io = { env: process.env, stdin: process.stdin,
// builds; `org validate FILE` stays orgCommand's draft check.
if (verb === 'org' && rest[0] === 'config') return orgConfig(rest.slice(1), io);
if (verb === 'org' && ["instances", "instance", "veto"].includes(rest[0])) return orgInstancesCommand(rest, io, (p) => callFor(p, io, deps));
// #1873 T1: definition writes go through the organization's bus, as the human.
if (verb === 'org' && rest[0] === 'definitions') return definitionsCommand(rest.slice(1), io, { ...deps, callFor: (p) => callFor(p, io, deps) });
if (verb === 'org') return orgCommand(rest, io, deps);
// A2 (#1873): the owner's root and project agent levels. `mosaic agents` without `root` stays the bus read.
if (verb === "agents" && rest[0] === "root") return agentsRootCommand(rest.slice(1), io);
if (verb === "project" && rest[0] === "agents") return projectAgentsCommand(rest.slice(1), io);
// E2 (#1873, P1-3): the task board's create, edit and archive, as the person (tasks-write.mjs).
if (verb === "tasks" && TASKS_WRITE_OPS.includes(rest[0])) return tasksWriteCommand(rest, io, (p) => callFor(p, io, deps));
if (["inbox", "decide", "tasks", "agents", "trail"].includes(verb)) return human(verb, rest, io, deps);
if (verb === "project" && rest[0] === "north-star") return northStarCommand(rest.slice(1), io, (p) => callFor(p, io, deps));
if (verb === "project" && rest[0] === "interview") return interviewCommand(rest.slice(1), io);
@@ -766,7 +800,7 @@ export async function main(argv, io = { env: process.env, stdin: process.stdin,
}
if (verb === "plugins") return pluginsCommand(rest, io, (p) => callFor(p, io, deps));
// Row 199 (#1697): issue and rotate read the token on stdin.
if (verb === "proxy") return proxyCommand(rest, io, { system: () => deps.system ?? loadSystem({ env: io.env }), callFor: p => callFor(p, io, deps), prompt: deps.promptProxyKey });
if (verb === "proxy") return proxyCommand(rest, io, { system: () => deps.system ?? loadSystem({ env: io.env }), callFor: p => callFor(p, io, deps), prompt: deps.promptProxyKey, ...(deps.proxyAdmin ? { admin: deps.proxyAdmin } : {}) });
if (verb === "credentials") return credentialsCommand(rest, io, (p) => callFor(p, io, deps));
// Row 234 (#1740, r6 E4): the host records.
if (verb === "hosts") return hostsCommand(rest, io, (p) => callFor(p, io, deps));
+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);
+6 -6
View File
@@ -46,7 +46,7 @@ function noCredentials(env) {
function ownedPrivate(path, directory) {
const st = lstatSync(path);
if (st.isSymbolicLink() || (directory ? !st.isDirectory() : !st.isFile()) || st.uid !== process.getuid() || (st.mode & 0o077))
throw refuse('fixture-state-unsafe', `${path} is not a private ${directory ? 'directory' : 'file'} of this user`);
throw refuse('fixture-state-unsafe', `${directory ? 'dev-fixture directory' : 'fixture file'} is not a private ${directory ? 'directory' : 'file'} of this user`);
}
// The recorded fixture, or null when there is none. Anything malformed refuses.
@@ -56,19 +56,19 @@ export function readFixture(dataRoot) {
catch (e) { if (e.code === 'ENOENT') return null; throw e; }
let fd;
try { fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW); }
catch (e) { if (e.code === 'ENOENT') return null; throw refuse('fixture-state-unsafe', `${file} can't be opened (${e.code})`); }
catch (e) { if (e.code === 'ENOENT') return null; throw refuse('fixture-state-unsafe', `fixture.json can't be opened (${e.code})`); }
let doc;
try {
const st = fstatSync(fd);
if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o777) !== 0o600) throw refuse('fixture-state-unsafe', `${file} is not a private file of this user`);
if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o777) !== 0o600) throw refuse('fixture-state-unsafe', 'fixture.json is not a private file of this user');
doc = JSON.parse(readFileSync(fd, 'utf8'));
} catch (e) { if (e instanceof CliError) throw e; throw refuse('fixture-state-invalid', `${file} is not JSON`); }
} catch (e) { if (e instanceof CliError) throw e; throw refuse('fixture-state-invalid', 'fixture.json is not JSON'); }
finally { closeSync(fd); }
const m = LOOPBACK.exec(doc?.endpoint ?? '');
if (!m) throw refuse('fixture-endpoint-not-loopback', `${file} names an endpoint that is not http://127.0.0.1:<port>`);
if (!m) throw refuse('fixture-endpoint-not-loopback', 'fixture.json names an endpoint that is not http://127.0.0.1:<port>');
if (doc.fixtureVersion !== 1 || doc.model !== 'deterministic' || Number(m[1]) !== doc.port || !Number.isSafeInteger(doc.pid) || doc.pid < 2
|| typeof doc.instance !== 'string' || JSON.stringify(doc.entry) !== JSON.stringify(FIXTURE_ENTRY))
throw refuse('fixture-state-invalid', `${file} is not a fixture record`);
throw refuse('fixture-state-invalid', 'fixture.json is not a fixture record');
return doc;
}
+7 -4
View File
@@ -79,12 +79,13 @@ import { spawn } from "node:child_process";
import { randomBytes } from "node:crypto";
import { closeSync, createReadStream, existsSync, lstatSync, mkdirSync, openSync, rmSync, statSync, symlinkSync, writeSync, chmodSync } from "node:fs";
import { createServer } from "node:net";
import { join } from "node:path";
import { basename, join } from "node:path";
import { GIT_GRACE_MS, GIT_TIMEOUT_MS } from "../../seat/src/checkout.mjs";
import { gitSocketPath } from "../../seat/src/session.mjs";
import { gateEnv, isMosaicStack, rootHits, runGate } from "../../seat/src/landing.mjs";
import { BUNDLE_MAX, dropLand, finishLand, FSCK, HAVE_MAX, prepareLand, publish, push, review, validBranch, workRef, WorkingError } from "../../seat/src/working.mjs";
import { CliError } from "./errors.mjs";
import { ID_PATTERN } from "../../organization/src/vocabulary.mjs";
import { withProcessing } from '../../processing-control/src/effects.mjs';
const HEADER_MAX = 4096;
@@ -385,7 +386,7 @@ export function createGitSocket({ host, business: b, dataRoot, env = process.env
if (!p) throw new Refusal("unknown-project");
mkdirSync(logs, { recursive: true, mode: 0o700 });
prepared = await prepareLand({ ...where, root: p.root, taskRef: task_ref, commit: facts.commit, dir: logs, name: id, rootHits, message: (m) => landMessage(facts, m) });
if (!(await gate.classify(p.root, env))) throw new Refusal("gate-missing", `${p.root} isn't a mosaic-stack checkout with node_modules`);
if (!(await gate.classify(p.root, env))) throw new Refusal("gate-missing", `project ${facts.project} isn't a mosaic-stack checkout with node_modules`);
symlinkSync(join(p.root, "node_modules"), join(prepared.worktree, "node_modules"));
} catch (e) {
if (prepared) await dropLand({ ...where, worktree: prepared.worktree }).catch((x) => log(`git socket: ${id}: ${x.message}`));
@@ -398,7 +399,9 @@ export function createGitSocket({ host, business: b, dataRoot, env = process.env
const r = await gate.run({ worktree: prepared.worktree, logs, env: gateEnv(env), signal: AbortSignal.any([slot.abort.signal, scope.signal]) });
scope.guard();
if (r.aborted || slot.abort.signal.aborted) throw new Refusal("host-closing", "the host closed during the gate; nothing moved");
if (!r.ok) throw new Refusal("gate-failed", `${r.step} failed, see ${r.log}`);
// The detail reaches the role, so the log is named inside the bus host directory, never by
// host path (PR #1916); its file name only when it passes the id pattern.
if (!r.ok) throw new Refusal("gate-failed", `${r.step} failed, see land/${id}${ID_PATTERN.test(basename(r.log ?? "")) ? `/${basename(r.log)}` : ""} in the bus host directory`);
await finishLand({ ...where, land: prepared, record: (m) => op({ op: "recordLand", ticket, land: { ...m, reviewed, base, patch_id } }) });
} finally {
await dropLand({ ...where, worktree: prepared.worktree }).catch((x) => log(`git socket: ${id}: ${x.message}`));
@@ -503,7 +506,7 @@ export function createGitSocket({ host, business: b, dataRoot, env = process.env
async function listen() {
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("git socket path exists and isn't a socket", 3);
rmSync(path);
}
server = createServer((socket) => {
+8 -5
View File
@@ -54,10 +54,10 @@ function readState(file, found = () => {}) {
state = JSON.parse(readFileSync(file, "utf8"));
} catch (e) {
if (e.code === "ENOENT") return null;
throw new CliError(`bus host state is unreadable: ${file}`, 3);
throw new CliError("bus host state is unreadable", 3);
}
if (!state || !Number.isSafeInteger(state.pid) || typeof state.startTime !== "string" || typeof state.business !== "string") {
throw new CliError(`bus host state is malformed: ${file}`, 3);
throw new CliError("bus host state is malformed", 3);
}
return { ...state, live: startTimeOf(state.pid) === state.startTime };
}
@@ -94,7 +94,7 @@ export function claimHostLock(dataRoot, state, { found = () => {}, between = ()
const prior = readState(file, found);
if (!prior) continue;
if (prior.live) throw new CliError(`a bus host already runs for ${prior.business} (pid ${prior.pid})`, 3);
if (refuseStale) throw new CliError(`stale bus host lock: ${file}`, 3);
if (refuseStale) throw new CliError("stale bus host lock", 3);
between();
try {
renameSync(file, aside);
@@ -111,7 +111,7 @@ export function claimHostLock(dataRoot, state, { found = () => {}, between = ()
}
rmSync(aside, { force: true });
}
throw new CliError(`${file} kept changing; no bus host started`, 3);
throw new CliError("bus host lock kept changing; no bus host started", 3);
} finally {
rmSync(tmp, { force: true });
}
@@ -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
+3 -1
View File
@@ -120,7 +120,9 @@ function setArgs(parsed) {
try {
args.vars = JSON.parse(readFileSync(values["--vars"], "utf8"));
} catch (error) {
throw new CliError(`--vars ${values["--vars"]}: ${error.message}`, 4);
// Name the flag and the error kind, never the file's path or bytes (PR #1916).
const code = typeof error?.code === "string" && /^E[A-Z0-9]+$/.test(error.code) ? error.code : "unreadable";
throw new CliError(`--vars file: ${error instanceof SyntaxError ? "not valid JSON" : code}`, 4);
}
}
if (launch !== undefined) args.launch = launch;
+52 -27
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) throw new Refusal("no-role-contract", `role ${resolved.definition} has no contract file`);
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) {
@@ -723,7 +747,7 @@ export function createLauncher({
models: b.launch.models,
proxy: sessionProxy,
resolver,
contract: readFileSync(resolved.contract, "utf8"),
contract: resolved.contract ? readFileSync(resolved.contract, "utf8") : resolved.instructions,
arbiter: Object.values(b.arbiters).includes(instance),
tracker,
library: Object.values(b.projects ?? {}).some(project => project.kind === "library"),
@@ -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) => {
+101 -3
View File
@@ -7,6 +7,9 @@ import { orgSelector } from './org-selector.mjs';
import { refuseInsideAgent } from './transport.mjs';
import { fetchPinned } from '../../model-proxy/src/fetch.mjs';
import { validManagementKey, proxyTiming } from '../../model-proxy/src/common.mjs';
import { createProxyAdmin } from '../../model-proxy/src/admin-control.mjs';
import { listOrgs } from '../../organization/src/org-registry.mjs';
import { busesStopped, instanceFromConfig } from './proxy-admin-host.mjs';
const failure = (code, exitCode = 2) => Object.assign(new CliError(code, exitCode), { code });
// A raw terminal suppresses echo; input is never argv, env, readline history or stdout.
export function promptProxyKey(io, label) {
@@ -34,12 +37,90 @@ export function promptProxyKey(io, label) {
io.stdin.setRawMode(true); io.stdin.on('data', data); io.stdin.once('end', end); io.stdin.once('error', failed); io.stdin.resume();
});
}
export async function proxyCommand(args, io, { system, callFor, prompt = promptProxyKey, fetch = fetchPinned }) {
// #1873: the setup controller the Console's Proxy page uses, for this instance. `admin` is a
// test seam; the default runs proxy-service.sh, systemctl and the pinned fetch for real.
function adminFor(io, system, deps) {
if (deps.admin) return deps.admin;
const config = typeof system === 'function' ? system() : system;
const dir = configDir(io.env);
return createProxyAdmin({
dataRoot: config.dataRoot, configDir: dir, instance: instanceFromConfig(io.env), env: io.env, fetch: deps.fetch ?? fetchPinned,
unitDir: deps.unitDir ?? null,
buses: () => busesStopped({ dataRoot: config.dataRoot, configDir: dir }),
orgs: async () => (await listOrgs({ configDir: dir, dataRoot: config.dataRoot })).filter(org => org.state === 'active').map(org => ({ id: org.id, proxy: 'shared', catalogue: null })),
});
}
const print = (io, value) => io.stdout.write(JSON.stringify(value) + '\n');
const hostCode = (error, fallback) => (/^proxy-[a-z-]+$/.test(error?.code ?? '') ? error.code : fallback);
// `mosaic proxy status | unit ... | init-lock ...`: host facts and actions, labels only.
async function setupCommand(verb, rest, io, { system, deps }) {
const admin = adminFor(io, system, deps);
try {
if (verb === 'status') {
if (!(rest.length === 0 || (rest.length === 1 && rest[0] === '--json'))) throw failure('proxy-usage', 4);
const status = await admin.status();
if (rest[0] === '--json') return print(io, status);
const { release, init, unit, fence, initLock } = status;
io.stdout.write([
`release: ${release.fetched ? `${release.version} (panel ${release.panelVersion}) ${release.verified ? 'hashes verified' : `not verified: ${release.problem}`}` : `not fetched (pinned ${release.version})`}`,
`initialized: ${init.initialized ? `yes, port ${init.settings?.port}` : init.partial ? `partly (${init.files.filter(f => f.present).map(f => f.label).join(', ')})` : 'no'}`,
`unit: ${unit.unit} ${unit.installed ? 'installed' : 'not installed'}, ${unit.active}${unit.pid ? ` pid ${unit.pid}` : ''}${unit.port ? `, port ${unit.port} ${unit.listening ? 'answering' : 'not answering'}` : ''}`,
`fence: ${fence.set ? `set${fence.op ? ` after ${fence.op}` : ''}${fence.time ? ` at ${fence.time}` : ''}` : 'clear'}`,
`init lock: ${initLock.present ? (initLock.clearable ? 'stale, clearable' : `held: ${initLock.blockers.join('; ')}`) : 'none'}`,
`own proxy: ${status.ownProxy.state}`,
`next: ${status.next.join(', ')}`,
].join('\n') + '\n');
return;
}
if (verb === 'unit') {
const [op, ...flags] = rest;
if (op === 'render' && flags.length === 0) { io.stdout.write((await admin.render()).text); return; }
if (op === 'status' && (flags.length === 0 || (flags.length === 1 && flags[0] === '--json'))) { print(io, (await admin.status()).unit); return; }
if (['install', 'start', 'stop', 'restart'].includes(op) && flags.length === 0) { print(io, await admin.unitAction(op)); return; }
throw failure('proxy-usage', 4);
}
if (verb === 'init-lock') {
const [op, ...flags] = rest;
if (flags.length !== 0) throw failure('proxy-usage', 4);
if (op === 'status') { print(io, (await admin.status()).initLock); return; }
if (op === 'clear') { print(io, await admin.initLockClear()); return; }
throw failure('proxy-usage', 4);
}
} catch (error) { if (error instanceof CliError) throw error; throw failure(hostCode(error, 'proxy-unit-failed')); }
throw failure('proxy-usage', 4);
}
export async function proxyCommand(args, io, { system, callFor, prompt = promptProxyKey, fetch = fetchPinned, ...deps }) {
const [verb, ...rest] = args;
if (args.includes('--tester')) throw failure('human-required', 3);
if (verb === 'init' || verb === 'fetch' || verb === 'fence') {
// #1873: every proxy verb, read or write, runs only from a human shell.
if (['init', 'fetch', 'fence', 'status', 'unit', 'init-lock'].includes(verb)) {
try { refuseInsideAgent(io.env); } catch { throw failure('human-required', 3); }
}
if (['status', 'unit', 'init-lock'].includes(verb)) return setupCommand(verb, rest, io, { system, deps: { ...deps, fetch } });
if (verb === 'fence' && rest[0] === 'clear' && rest.includes('--restart')) {
// #1873: restart the proxy unit, then send the restart evidence to the bus, which re-proves it
// on the host before it clears the fence. --business/--org names the bus.
const parsed = { flags: new Set(), values: {} };
const selector = orgSelector(() => failure('proxy-usage', 4));
const flags = rest.slice(1);
let restart = false;
for (let i = 0; i < flags.length; i++) {
if (flags[i] === '--restart' && !restart) { restart = true; continue; }
if (selector.owns(flags[i])) { selector.take(flags[i], flags[i + 1]); i++; continue; }
throw failure('proxy-usage', 4);
}
if (selector.given) parsed.values['--business'] = selector.value;
const call = callFor(parsed);
const admin = adminFor(io, system, { ...deps, fetch });
try {
const restarted = await admin.unitAction('restart');
if (restarted.evidence?.confirmed !== true) throw failure('proxy-restart-unconfirmed');
print(io, { restart: restarted.receipt, ...(await admin.fenceClear(call)) });
return;
} catch (error) { if (error instanceof CliError) throw error; throw failure(hostCode(error, 'proxy-down')); }
}
if (verb === 'fence') {
const [action, ...flags] = rest;
if (action === 'clear' && flags.length === 0) throw failure('proxy-restart-required');
@@ -66,6 +147,7 @@ export async function proxyCommand(args, io, { system, callFor, prompt = promptP
for (let i = 0; i < rest.length; i++) {
const flag = rest[i];
if (verb === 'init' && selector.owns(flag)) { selector.take(flag, rest[i + 1]); i++; continue; }
if (verb === 'init' && flag === '--generate-key' && !parsed.flags.has(flag)) { parsed.flags.add(flag); continue; }
if (verb !== 'init' || !['--port', '--rotate-grace-minutes', '--first-output-seconds'].includes(flag) || parsed.values[flag] !== undefined || !rest[i + 1]) throw failure('proxy-usage', 4);
parsed.values[flag] = rest[++i];
}
@@ -81,7 +163,23 @@ export async function proxyCommand(args, io, { system, callFor, prompt = promptP
try { proxyTiming(timing); } catch (error) { throw failure(error.code); }
try {
const config = typeof system === 'function' ? system() : system;
if (verb === 'fetch') { const result = await fetch({ dataRoot: config.dataRoot }); io.stdout.write(JSON.stringify({ binary: result.binary, panel: result.panel }) + '\n'); return; }
if (verb === 'fetch') {
// #1873: the page's fetch, with its progress on stderr and its receipt in proxy-admin.jsonl;
// stdout keeps its existing line.
const { result, receipt } = await adminFor(io, system, { ...deps, fetch }).fetchRun({ progress: step => io.stderr.write(`fetch: ${step}\n`) });
io.stderr.write(`receipt: ${JSON.stringify(receipt)}\n`);
io.stdout.write(JSON.stringify({ binary: result.binary, panel: result.panel }) + '\n'); return;
}
// #1873: the bus generates the key and stores it where init stores a typed key; no prompt,
// no terminal needed, and the key is never printed or returned.
if (parsed.flags.has('--generate-key')) {
// Through the page's controller, so the same init receipt lands in proxy-admin.jsonl.
const call = callFor(parsed);
const { settings, receipt } = await adminFor(io, system, deps).init(call, { generate: true, ...(port === undefined ? {} : { port }), ...timing });
io.stderr.write(`receipt: ${JSON.stringify(receipt)}\n`);
print(io, { ...settings, generated: true });
return;
}
if (!io.stdin.isTTY || !io.stdout.isTTY) throw failure('proxy-terminal-required', 3);
// Establish that the CLI is eligible before reading sensitive input. The bus verifies the proof.
const call = callFor(parsed);
+10 -10
View File
@@ -162,28 +162,28 @@ export function openJournal(file, { log = () => {}, now = () => new Date() } = {
mkdirSync(dir, { recursive: true, mode: 0o700 });
} catch (e) {
// A dangling link in place of the directory fails mkdir with ENOENT.
if (e.code === "ENOENT" && lstatSync(dir, { throwIfNoEntry: false })?.isSymbolicLink()) throw new CliError(`notify journal directory must not be a symlink: ${dir}`, 3);
if ([...UNWRITABLE, "ENOTDIR"].includes(e.code)) throw new CliError(`notify journal directory cannot be created (${e.code}): ${dir}`, 3);
if (e.code === "ENOENT" && lstatSync(dir, { throwIfNoEntry: false })?.isSymbolicLink()) throw new CliError(`notify journal directory must not be a symlink`, 3);
if ([...UNWRITABLE, "ENOTDIR"].includes(e.code)) throw new CliError(`notify journal directory cannot be created (${e.code})`, 3);
if (e.code !== "EEXIST") throw e;
}
const ds = lstatSync(dir);
if (ds.isSymbolicLink()) throw new CliError(`notify journal directory must not be a symlink: ${dir}`, 3);
if (ds.isSymbolicLink()) throw new CliError(`notify journal directory must not be a symlink`, 3);
if (!ds.isDirectory() || ds.uid !== process.getuid() || (ds.mode & 0o077) !== 0) {
throw new CliError(`notify journal directory must be mode 0700 and owned by this user: ${dir}`, 3);
throw new CliError(`notify journal directory must be mode 0700 and owned by this user`, 3);
}
try {
accessSync(dir, constants.W_OK | constants.X_OK);
} catch (e) {
if (UNWRITABLE.includes(e.code)) throw new CliError(`notify journal directory is not writable (${e.code}): ${dir}`, 3);
if (UNWRITABLE.includes(e.code)) throw new CliError(`notify journal directory is not writable (${e.code})`, 3);
throw e;
}
let fd;
try {
fd = openSync(file, O_RDWR | O_APPEND | O_CREAT | O_NOFOLLOW, 0o600);
} catch (e) {
if (e.code === "ELOOP") throw new CliError(`notify journal must not be a symlink: ${file}`, 3);
if (UNWRITABLE.includes(e.code)) throw new CliError(`notify journal is not writable (${e.code}): ${file}`, 3);
if (e.code === "EISDIR") throw new CliError(`notify journal must be a regular file, mode 0600, owned by this user: ${file}`, 3);
if (e.code === "ELOOP") throw new CliError(`notify journal must not be a symlink`, 3);
if (UNWRITABLE.includes(e.code)) throw new CliError(`notify journal is not writable (${e.code})`, 3);
if (e.code === "EISDIR") throw new CliError(`notify journal must be a regular file, mode 0600, owned by this user`, 3);
throw e;
}
const sent = new Set();
@@ -204,7 +204,7 @@ export function openJournal(file, { log = () => {}, now = () => new Date() } = {
try {
const st = fstatSync(fd);
if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o777) !== 0o600) {
throw new CliError(`notify journal must be a regular file, mode 0600, owned by this user: ${file}`, 3);
throw new CliError(`notify journal must be a regular file, mode 0600, owned by this user`, 3);
}
const bytes = readFileSync(fd);
const end = bytes.lastIndexOf(0x0a) + 1;
@@ -218,7 +218,7 @@ export function openJournal(file, { log = () => {}, now = () => new Date() } = {
r = null;
}
const bad = r === null ? "json" : badField(r);
if (bad) throw new CliError(`notify journal line ${i + 1} is malformed (${bad}): ${file}`, 3);
if (bad) throw new CliError(`notify journal line ${i + 1} is malformed (${bad})`, 3);
count(r);
});
if (end < bytes.length) {
+6 -5
View File
@@ -6,27 +6,28 @@ import { CliError } from "./errors.mjs";
// The notifier's private config: `<dataRoot>/notify/<business>/notify.json`,
// 0600, `{"notifyVersion": 1, "binding": "<discord binding>" | null}`.
// Missing refuses, so a host never starts without someone deciding whether
// it DMs; null runs the host without a notifier.
// it DMs; null runs the host without a notifier. Refusals name the business
// and the path under the data root, never the host path (PR #1916).
export function readNotifyConfig(dataRoot, business) {
const file = join(dataRoot, "notify", business, "notify.json");
let st;
try {
st = lstatSync(file);
} catch {
throw new CliError(`no notifier config at ${file}; write {"notifyVersion": 1, "binding": "<discord binding>"} there, mode 0600, or "binding": null to run without DMs`, 3);
throw new CliError(`no notifier config for ${business}: write {"notifyVersion": 1, "binding": "<discord binding>"} to notify/${business}/notify.json under the data root, mode 0600, or "binding": null to run without DMs`, 3);
}
if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o777) !== 0o600) {
throw new CliError(`notifier config must be a regular file, mode 0600, owned by this user: ${file}`, 3);
throw new CliError(`notifier config for ${business} must be a regular file, mode 0600, owned by this user`, 3);
}
let doc;
try {
doc = JSON.parse(readFileSync(file, "utf8"));
} catch {
throw new CliError(`notifier config is not JSON: ${file}`, 3);
throw new CliError(`notifier config for ${business} is not JSON`, 3);
}
const keys = doc && typeof doc === "object" && !Array.isArray(doc) ? Object.keys(doc).sort().join(",") : "";
if (keys !== "binding,notifyVersion" || doc.notifyVersion !== 1 || (doc.binding !== null && !(typeof doc.binding === "string" && BINDING_NAME.test(doc.binding)))) {
throw new CliError(`notifier config must be exactly {"notifyVersion": 1, "binding": <binding name> | null}: ${file}`, 3);
throw new CliError(`notifier config for ${business} must be exactly {"notifyVersion": 1, "binding": <binding name> | null}`, 3);
}
return doc.binding;
}
+65 -11
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';
@@ -138,7 +138,9 @@ export async function busStart(ctx, id, { detach = false, devLocalOwner = false,
await new Promise(r => setTimeout(r, 50));
}
if (!exited) { child.kill('SIGTERM'); await done; }
throw new CliError(`bus failed to start; see ${join(org.paths.hostDir, 'host.log')}`, 3);
// Name the log by its place in the organization's runtime directory, never by host path (PR #1916);
// resolveOrg admitted `id` only after the registry's id check.
throw new CliError(`bus failed to start; see bus-host/host.log in the runtime directory of organization ${id}`, 3);
}
export async function busRestart(ctx, id) {
const org = await target(ctx, id);
@@ -149,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 () => {
@@ -175,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) {
@@ -196,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;
}
+109 -14
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 { join } from 'node:path';
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;
@@ -95,7 +113,8 @@ async function launch(ctx, args, lan, detach) {
}
await sleep(50);
}
fail(`console failed to start; see ${log}`);
// The log is named under the data root, never by host path (PR #1916).
fail(`console failed to start; see console/${basename(log)} under the data root`);
} catch (e) {
if (child.exitCode === null && child.signalCode === null) { child.kill('SIGTERM'); await done; }
throw e;
@@ -152,15 +171,77 @@ function supervise(ctx, first, args, lan, warn) {
})();
return { done, stop };
}
// D1 (#1873): `--dev-local-owner --detach` runs the foreground supervision above in a process of
// its own session (console-supervisor.mjs), so Restart Console works on a detached dev Console. The
// supervisor takes the start lock and writes the console state as a foreground start does. This
// process waits for the supervisor's one-line reply, passes its warnings on and leaves it running.
const SUPERVISOR = fileURLToPath(new URL('./console-supervisor.mjs', import.meta.url));
async function detachedSupervisor(ctx, { port, bind, allowHosts, warn }) {
checkDir(ctx);
lastStamp = Math.max(Date.now(), lastStamp + 1);
const name = `supervisor-${process.pid}-${lastStamp}.log`;
const fd = openSync(join(directory(ctx), name), constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
let child;
try {
child = spawn(process.execPath, [SUPERVISOR, JSON.stringify({ configDir: ctx.configDir, dataRoot: ctx.dataRoot, instance: ctx.instance, port, bind, allowHosts })],
{ env: ctx.env ?? process.env, detached: true, stdio: ['ignore', 'pipe', fd] });
} finally { closeSync(fd); }
const supervisorStart = child.pid ? startTimeOf(child.pid) : null;
const done = new Promise(resolve => { child.once('error', () => resolve(1)); child.once('exit', code => resolve(code ?? 1)); });
// The log is named under the data root, never by host path (PR #1916).
const unclear = `console failed to start; see console/${name} under the data root`;
let text = '', timer;
child.stdout.setEncoding('utf8');
const line = await Promise.race([
new Promise(resolve => {
child.stdout.on('data', c => { text += c; const n = text.indexOf('\n'); if (n >= 0) resolve(text.slice(0, n)); else if (text.length > 65536) resolve(null); });
child.stdout.once('end', () => resolve(null));
}),
new Promise(resolve => { timer = setTimeout(() => resolve(undefined), 60000); }),
]);
clearTimeout(timer);
child.stdout.destroy();
// The supervisor leads its own process group, with the Console it may have started: a timed-out
// start signals that group, then kills it if it is still there after 10 seconds. A supervisor
// killed while it held the start lock never released it, so the lock it named is cleared.
if (line === undefined) {
const group = signal => { try { process.kill(-child.pid, signal); } catch { /* already gone */ } };
group('SIGTERM');
let grace; const exited = await Promise.race([done.then(() => true), new Promise(r => { grace = setTimeout(() => r(false), 10000); })]);
clearTimeout(grace);
if (!exited) { group('SIGKILL'); await done; }
clearDeadLock(ctx, child.pid, supervisorStart);
fail(unclear);
}
let reply = null;
try { reply = JSON.parse(line); } catch { /* no reply */ }
for (const w of Array.isArray(reply?.warnings) ? reply.warnings : []) if (typeof w === 'string') warn(w);
child.unref();
if (typeof reply?.receipt?.url !== 'string' || !Number.isSafeInteger(reply.receipt.pid)) {
const code = [1, 2, 3, 4].includes(reply?.exitCode) ? reply.exitCode : 1;
throw new CliError(typeof reply?.error === 'string' ? reply.error : unclear, code);
}
const result = { ...reply.receipt, supervisor: child.pid };
Object.defineProperty(result, 'done', { value: done });
Object.defineProperty(result, 'stop', { value: () => child.kill('SIGTERM') });
return result;
}
// `bind` and `allowHosts` are --dev-insecure-bind and --dev-allow-host (org-lan-bind): the dev
// owner Console on the LAN with no sign-in. Neither is set for a loopback Console.
// In the foreground the Console runs with --supervised: its restart route exits RESTART_EXIT and
// this process relaunches it in place (F3). A detached Console has no supervisor.
// this process relaunches it in place (F3). A detached dev-local-owner Console runs under a
// detached supervisor (D1); any other detached Console has none.
export async function consoleStart(ctx, { devLocalOwner = false, detach = false, port = 'auto', bind = null, allowHosts = [], warn = s => process.stderr.write(s) } = {}) {
const value = port === 'auto' ? 0 : Number(port);
if (!(port === 'auto' || (typeof port === 'string' && /^\d+$/.test(port) && value >= 1 && value <= 65535 && !forbidden.has(value)))) fail('invalid or forbidden console port');
let lan;
try { lan = devLanOptions({ devOwner: devLocalOwner, bind, names: allowHosts }); } catch (e) { throw new CliError(e.message, 4); }
// D1 fix round (security review P1): a dev-local-owner Console acts as the owner with no sign-in,
// so an agent run never starts one, detached or not. agent-guard (#1873): nor any other Console.
// This process's own environment is checked as well as the one given for the Console (security
// re-check P1). A Restart Console relaunch goes through relaunch(), not here.
refuseInsideAgent(process.env); if (ctx.env) refuseInsideAgent(ctx.env);
if (detach && devLocalOwner) return detachedSupervisor(ctx, { port, bind, allowHosts, warn });
const release = takeLock(ctx);
let run, args;
try {
@@ -193,11 +274,8 @@ export async function consoleStop(ctx, { warn = s => process.stderr.write(s) } =
const argv = cmdlineOf(s.pid);
if (!argv || !argv.length) fail('console process identity is uncertain');
const i = argv.indexOf(WEBUI);
if (i < 0 || argv[i + 1] !== 'serve') {
const serve = argv.indexOf('serve');
const entry = serve > 0 ? ` ${argv[serve - 1]}` : '';
fail(`console was started from another path${entry}; stop it there`);
}
// The other entry script is a host path, so the refusal never names it (PR #1916).
if (i < 0 || argv[i + 1] !== 'serve') fail('console was started from another checkout; stop it there');
process.kill(s.pid, 'SIGTERM');
const deadline = Date.now() + 20000;
while (Date.now() < deadline) {
@@ -240,9 +318,10 @@ function consoleTarget(ctx) {
const address = s.bind && s.bind !== '0.0.0.0' ? s.bind : '127.0.0.1';
return { address, port, host: `${address}:${port}` };
}
function consoleCall(target, { method = 'GET', path, timeoutMs }) {
function consoleCall(target, { method = 'GET', path, timeoutMs, body = {} }) {
return new Promise((resolve, reject) => {
const headers = { host: target.host, ...(method === 'POST' ? { origin: `http://${target.host}`, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json', 'content-length': 2 } : {}) };
const payload = method === 'POST' ? JSON.stringify(body) : undefined;
const headers = { host: target.host, ...(method === 'POST' ? { origin: `http://${target.host}`, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload) } : {}) };
let sent = false;
const req = httpRequest({ host: target.address, port: target.port, method, path, headers, timeout: timeoutMs }, res => {
let text = '';
@@ -254,14 +333,14 @@ function consoleCall(target, { method = 'GET', path, timeoutMs }) {
req.on('timeout', () => req.destroy(Object.assign(new Error('timed out'), { code: 'timeout' })));
req.on('finish', () => { sent = true; });
req.on('error', e => reject(Object.assign(e, { sent })));
req.end(method === 'POST' ? '{}' : undefined);
req.end(payload);
});
}
// A refused connection never reached the Console. Anything else after the request left may have
// changed something, so it is outcome-unknown and nothing is retried.
async function consolePost(target, path, { timeoutMs, unknown }) {
async function consolePost(target, path, { timeoutMs, unknown, body }) {
let answer;
try { answer = await consoleCall(target, { method: 'POST', path, timeoutMs }); }
try { answer = await consoleCall(target, { method: 'POST', path, timeoutMs, body }); }
catch (e) {
if (e.code === 'ECONNREFUSED' && !e.sent) fail('the Console refused the connection; see mosaic console status');
throw new CliError(`outcome unknown: ${unknown}`, 1);
@@ -296,3 +375,19 @@ export async function consoleBusRestart(ctx, id, { timeoutMs = 120000 } = {}) {
if (answer.status !== 200 || answer.body?.outcome !== 'done' || answer.body.step !== 'bus-restart') throw refusal(answer, 'org reload');
return answer.body;
}
// E2 (#1873, P1-3): the owner's org page actions from a terminal (org-owner.mjs): the hire form and
// the autonomy page, each through the running Console's own org route, sent as the page sends it.
// The route applies its owner, --send, origin, body and live-session checks and asks the org's own
// bus; nothing here decides or writes anything. A GET that fails changed nothing, so it is exit 1
// with no outcome-unknown wording; a POST that fails after it left is outcome-unknown.
export async function consoleOrgRequest(ctx, id, { method = 'GET', path, body, timeoutMs = 30000, unknown }) {
if (typeof id !== 'string' || !ORG_ID.test(id)) throw new CliError('invalid organization id', 4);
const target = consoleTarget(ctx);
const route = `/api/orgs/${id}/${path}`;
if (method === 'POST') return consolePost(target, route, { timeoutMs, unknown, body });
try { return await consoleCall(target, { path: route, timeoutMs }); }
catch (e) {
if (e.code === 'ECONNREFUSED' && !e.sent) fail('the Console refused the connection; see mosaic console status');
throw new CliError('the Console did not answer; see mosaic console status', 1);
}
}
+96
View File
@@ -0,0 +1,96 @@
// #1873 T1: `mosaic org definitions`, the human verbs on an organization's own role definitions and
// the template hash and diff reads. Reads run here, from the file. A write runs only from a human
// shell (refuseInsideAgent) and goes through the organization's bus as its human: the bus holds the
// credential store, whose held-secret check every definition write needs (R1), and no other process
// holds the tokens it looks for. Each write prints its receipt.
import { readFile } from 'node:fs/promises';
import { configDir } from '../../organization/src/organization-config.mjs';
import { organizationWriter } from '../../organization/src/organization.mjs';
import { definitionVerbs } from '../../organization/src/definition-verbs.mjs';
import { checkDefinition } from '../../organization/src/definitions.mjs';
import { rolesDir } from './config.mjs';
import { CliError } from './errors.mjs';
import { busExit, refuseInsideAgent } from './transport.mjs';
export const DEFINITIONS_USAGE = 'mosaic org definitions templates | definitions ID list | definitions ID show NAME | definitions ID diff NAME | definitions ID import TEMPLATE [--as NAME] | definitions ID invent NAME --from FILE | definitions ID edit NAME --from FILE --if-rev N | definitions ID remove NAME --if-rev N';
const WRITES = ['import', 'invent', 'edit', 'remove'];
const VALUES = ['--as', '--from', '--if-rev'];
function parse(args) {
const pos = [];
const values = {};
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (VALUES.includes(a)) {
if (values[a] !== undefined || !args[i + 1] || args[i + 1].startsWith('--')) throw new CliError(`${a} needs one value`, 4);
values[a] = args[++i];
} else if (a.startsWith('--')) throw new CliError(`unknown option ${a}`, 4);
else pos.push(a);
}
return { pos, values };
}
async function definitionFile(file) {
try {
return JSON.parse(await readFile(file, 'utf8'));
} catch (e) {
// Never echo the file's text: it may hold what the composition check refuses.
throw new CliError(`cannot read definition ${file}: ${e instanceof SyntaxError ? 'not valid JSON' : e.code ?? 'unreadable'}`, 2);
}
}
// The bus answers a refusal with its code only. These say what the code means for this request;
// none quotes the definition's text.
function refusal(code, verb, name, rev, read) {
const why = {
'definition-in-use': () => `definition ${name} is named by ${read().usedBy.join(', ')}; a change to a hired agent's role is a change decision`,
'definition-rev-conflict': () => `definition ${name} is not at rev ${rev}`,
'definition-exists': () => `definitions already declares ${name}`,
'definition-missing': () => `definitions declares no ${name}`,
'revision-conflict': () => 'the organization file changed while this ran; run it again',
'credential-leak': () => `the ${verb} request holds a credential the organization's bus holds; nothing was written`,
'invalid-request': () => `the bus refused the ${verb}: the definition fails the composition check or holds a held credential`,
}[code];
let detail = null;
try { detail = why?.() ?? null; } catch { detail = null; }
const error = new CliError(detail ? `${code}: ${detail}` : code, busExit(code));
error.code = code;
return error;
}
// deps.callFor(parsed) returns the human's call(verb, args) for the organization parsed names (cli.mjs
// passes its callFor). Without one, a write refuses: it never runs without the bus's secret check.
export async function definitionsCommand(args, io, deps = {}) {
const { pos, values } = parse(args);
const allowed = (...keys) => { for (const k of Object.keys(values)) if (!keys.includes(k)) throw new CliError(`unexpected ${k}`, 4); };
const need = (key) => { if (!values[key]) throw new CliError(`${key} is required`, 4); return values[key]; };
const ifRev = () => { const r = need('--if-rev'); if (!/^[1-9][0-9]*$/.test(r)) throw new CliError('invalid --if-rev', 4); return Number(r); };
const [id, verb, name] = pos;
if (WRITES.includes(verb)) refuseInsideAgent(io.env);
const writer = deps.writer ?? organizationWriter({ dir: configDir(io.env), rolesDir: rolesDir(io.env) });
const verbs = definitionVerbs({ writer, rolesDir: rolesDir(io.env) });
let result;
if (id === 'templates' && pos.length === 1) { allowed(); result = verbs.templates(); }
else if (verb === 'list' && pos.length === 2) { allowed(); result = verbs.list(id); }
else if (['show', 'diff'].includes(verb) && pos.length === 3) { allowed(); result = verbs[verb](id, name); }
else if (WRITES.includes(verb) && pos.length === 3) {
let request, rev;
if (verb === 'import') { allowed('--as'); request = { template: name, as: values['--as'] ?? name }; }
else if (verb === 'invent') { allowed('--from'); request = { name, definition: await definitionFile(need('--from')) }; }
else if (verb === 'edit') { allowed('--from', '--if-rev'); rev = ifRev(); request = { rev, name, definition: await definitionFile(need('--from')) }; }
else { allowed('--if-rev'); rev = ifRev(); request = { rev, name }; }
// The file's own checks first, so a bad key is named here; the bus repeats them with its secret check.
if (request.definition) checkDefinition(name, { ...request.definition, rev: rev ?? 1 });
if (typeof deps.callFor !== 'function') throw new CliError('a definition write goes through the organization bus; run it as `mosaic org definitions`', 3);
const call = deps.callFor({ positional: [id], flags: new Set(), values: { '--business': id } });
const { revision } = writer.read(id);
try {
result = await call(`definition.${verb}`, { revision, ...request });
} catch (e) {
if (typeof e?.code !== 'string' || e.code === '') throw e;
throw refusal(e.code, verb, request.as ?? name, rev, () => verbs.show(id, request.as ?? name));
}
} else throw new CliError(DEFINITIONS_USAGE, 4);
io.stdout.write(JSON.stringify(result, null, 2) + '\n');
}
+213
View File
@@ -0,0 +1,213 @@
// E2 (#1873, P1-3, org ruling 6): the owner's org page actions from a terminal. Each verb sends the
// request the WebUI page sends, to the running Console's own org route (org-console.mjs
// consoleOrgRequest), so the same route checks the owner, --send, the origin, the body and the live
// session, and the same bus verb runs on the organization's own bus:
// - role-templates, hire, fire, change: the inbox hire form (public/bus.js hireSubmit), the routes
// /api/orgs/ID/decisions/hire/templates and /start (decision-http.mjs), the bus verbs
// hire.templates.console and hire.start.console.
// - autonomy, autonomy set, autonomy review: the org Autonomy panel (public/autonomy.js), the
// route /api/orgs/ID/autonomy[/review] (autonomy-http.mjs), the bus verbs autonomy.get.console,
// autonomy.set.console (with its revision check) and autonomy.review.console.
// No new authority: a Console that is not the owner's, not started with --send, or not signed in
// refuses exactly as it refuses the page. Usage errors (exit 4) come first; then every verb refuses
// inside an agent run, before any config is read, as `org archive|delete` do (agent guard #1942).
import { configDir } from '../../organization/src/organization-config.mjs';
import { DEV_OWNER, devOwnerInstance } from './dev-local-owner.mjs';
import { loadSystem } from './config.mjs';
import { CliError } from './errors.mjs';
import { consoleOrgRequest } from './org-console.mjs';
import { refuseInsideAgent } from './transport.mjs';
export const ORG_OWNER_VERBS = Object.freeze(['role-templates', 'hire', 'fire', 'change', 'autonomy']);
export const ORG_OWNER_USAGE = 'mosaic org role-templates ID [--json] | hire|change ID --name NAME --template TEMPLATE --harness HARNESS --model MODEL [--thinking LEVEL] [--level org|project|root] [--project PROJECT] --reason TEXT [--json] | fire ID --name NAME [--level org|project|root] [--project PROJECT] --reason TEXT [--json] | autonomy ID [--json] | autonomy ID set --revision REV [--cross-role|--merge|--policy|--authority in|near|out] [--notices on|off] [--reverse-window-hours N] [--authority-resolver NAME|""] [--json] | autonomy ID review cross-role|merge|policy|authority [--json]';
const usage = why => new CliError(`${why}\nusage: ${ORG_OWNER_USAGE}`, 4);
const ORG_ID = /^[a-z0-9][a-z0-9._-]{0,63}$/;
const CLASSES = Object.freeze(['cross-role', 'merge', 'policy', 'authority']);
const LEVELS = Object.freeze({ in: 'in the loop', near: 'near the loop', out: 'out of the loop' });
const HIRE_VALUES = ['--name', '--template', '--harness', '--model', '--thinking', '--level', '--project', '--reason'];
const AUTONOMY_VALUES = ['--revision', ...CLASSES.map(c => `--${c}`), '--notices', '--reverse-window-hours', '--authority-resolver'];
function parse(args, values) {
const pos = [], got = {}, flags = new Set();
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (a === '--json') { if (flags.has(a)) throw usage('duplicate --json'); flags.add(a); }
else if (values.includes(a)) {
if (Object.hasOwn(got, a)) throw usage(`duplicate ${a}`);
if (i + 1 >= args.length || args[i + 1].startsWith('--')) throw usage(`${a} needs one value`);
got[a] = args[++i];
} else if (a.startsWith('--')) throw usage(`unknown option ${a}`);
else pos.push(a);
}
return { pos, values: got, json: flags.has('--json') };
}
// The hire form's body (bus.js hireSubmit), from flags. The bus's proposal checks (hire-decisions.mjs
// prepare) run on it unchanged; these checks only refuse what the form itself would not send.
function hireBody(op, v) {
const need = key => { if (!v[key]?.trim()) throw usage(`${op} needs ${key}`); return v[key]; };
const level = v['--level'] ?? 'org';
if (!['org', 'project', 'root'].includes(level)) throw usage('--level takes org, project or root');
if (level === 'project' ? !v['--project']?.trim() : v['--project'] !== undefined) throw usage(level === 'project' ? '--level project needs --project' : '--project goes with --level project');
const reason = need('--reason');
if (reason.length > 500) throw usage('--reason takes at most 500 characters');
if (op === 'fire') for (const k of ['--template', '--harness', '--model', '--thinking']) if (v[k] !== undefined) throw usage(`fire takes no ${k}`);
const hire = { op, level, name: need('--name'), reason, ...(level === 'project' ? { project: v['--project'] } : {}) };
if (op === 'fire') return { hire, template: null };
const template = need('--template');
const thinking = v['--thinking']?.trim();
return { hire: { ...hire, route: { harness: need('--harness'), model: need('--model'), ...(thinking ? { thinking } : {}) } }, template };
}
function autonomyChanges(v) {
const revision = v['--revision'];
if (revision === undefined) throw usage('autonomy set needs --revision, the revision mosaic org autonomy ID shows');
// The panel's revision is the bus's 64-hex digest of the organization (autonomy.set.console).
if (!/^[a-f0-9]{64}$/.test(revision)) throw usage('--revision takes the 64-hex revision mosaic org autonomy ID shows');
const changes = {};
for (const c of CLASSES) {
const value = v[`--${c}`];
if (value === undefined) continue;
if (!Object.hasOwn(LEVELS, value)) throw usage(`--${c} takes in, near or out`);
changes[c] = LEVELS[value];
}
if (v['--notices'] !== undefined) {
if (!['on', 'off'].includes(v['--notices'])) throw usage('--notices takes on or off');
changes.notices = v['--notices'] === 'on';
}
if (v['--reverse-window-hours'] !== undefined) {
if (!/^[1-9][0-9]{0,2}$/.test(v['--reverse-window-hours']) || Number(v['--reverse-window-hours']) > 168) throw usage('--reverse-window-hours takes a whole number of hours from 1 to 168');
changes.reverseWindowHours = Number(v['--reverse-window-hours']);
}
if (v['--authority-resolver'] !== undefined) changes.authorityResolver = v['--authority-resolver'];
if (!Object.keys(changes).length) throw usage('autonomy set needs a setting to change');
return { revision, changes };
}
// The panel's save sends every class with the level it shows, notices, the reverse window (24 when
// none is set) and the resolver when one is set (autonomy.js). The terminal sends the same document
// with the named settings changed, so an unchanged field keeps the value the page would send.
export function autonomyDocument(current, changes) {
const shown = current?.autonomy ?? {};
const doc = Object.fromEntries(CLASSES.map(c => [c, current?.levels?.[c]]));
doc.notices = shown.notices === true;
doc.reverseWindowHours = shown.reverseWindowHours ?? 24;
if (shown.authorityResolver) doc.authorityResolver = shown.authorityResolver;
const out = { ...doc, ...changes };
// An empty --authority-resolver clears it: the panel leaves an emptied field out of its save.
if (out.authorityResolver === '') delete out.authorityResolver;
return out;
}
// Autonomy codes carry no message from the route; these are the panel's own words (autonomy.js).
const AUTONOMY_GUIDANCE = {
'revision-conflict': 'the organization changed; read it again with mosaic org autonomy ID',
'autonomy-precondition': 'this move needs a separate resolver, a tested reverse, owner notices and an enforced budget covering new agents for authority; out of the loop needs a near-loop decision followed by your review of its trail',
'policy-reverse-unavailable': 'policy has no near-loop setting until a reverse exists',
'authority-limits-unavailable': 'authority cannot move out of the loop',
'owner-only': 'only the owner changes autonomy',
'read-only': 'start the Console with --send to make changes',
'sign-in-required': 'sign in to the Console',
};
// A refusal is exit 3 and changed nothing. outcome-unknown (and a 5xx the route did not name) may
// have changed something, so it is exit 1 and nothing is retried.
function refused(answer, what, unknown, guidance = {}) {
const raw = answer.body?.error;
const code = typeof raw === 'string' && /^[a-z-]{1,64}$/.test(raw) ? raw : `http ${answer.status}`;
if (code === 'outcome-unknown' || (answer.status >= 500 && !raw)) return new CliError(`outcome unknown: ${unknown}`, 1);
const said = typeof answer.body?.message === 'string' ? answer.body.message.slice(0, 500) : guidance[code];
return new CliError(`${what} refused (${code})${said ? `: ${said}` : ''}`, 3);
}
function ok(answer) { return answer.status === 200 && answer.body && typeof answer.body === 'object'; }
export async function orgOwnerCommand(args, io, deps = {}) {
const verb = args[0];
const autonomy = verb === 'autonomy';
const p = parse(args.slice(1), autonomy ? AUTONOMY_VALUES : verb === 'role-templates' ? [] : HIRE_VALUES);
const id = p.pos[0];
if (id === undefined) throw usage(`org ${verb} takes an organization id`);
if (!ORG_ID.test(id)) throw usage('invalid organization id');
let run;
if (verb === 'role-templates') {
if (p.pos.length !== 1) throw usage('role-templates takes one organization id');
run = async ctx => {
const answer = await consoleOrgRequest(ctx, id, { method: 'POST', path: 'decisions/hire/templates', body: { business: id }, unknown: 'the templates read did not finish; nothing was changed' });
if (!ok(answer) || !Array.isArray(answer.body.templates)) throw answer.status === 200 ? new CliError('the Console answered without templates', 1) : refused(answer, 'role-templates', 'the templates read did not finish; nothing was changed');
return { value: answer.body, text: b => b.templates.length ? b.templates.map(t => `${t.name} ${String(t.sha256 ?? '').slice(0, 12)}\n`).join('') : 'role templates: none\n' };
};
} else if (['hire', 'fire', 'change'].includes(verb)) {
if (p.pos.length !== 1) throw usage(`${verb} takes one organization id`);
const { hire, template } = hireBody(verb, p.values);
run = async ctx => {
let body = hire;
// The form chooses a template from the ones it read and sends its name and sha256.
if (template !== null) {
const read = await consoleOrgRequest(ctx, id, { method: 'POST', path: 'decisions/hire/templates', body: { business: id }, unknown: 'the templates read did not finish; nothing was changed' });
if (!ok(read) || !Array.isArray(read.body.templates)) throw refused(read, verb, 'the templates read did not finish; nothing was changed');
const chosen = read.body.templates.find(t => t?.name === template);
if (!chosen) throw new CliError(`${verb}: no role template ${JSON.stringify(template)}; see mosaic org role-templates ${id}`, 2);
const { route, ...rest } = hire;
body = { ...rest, role: { template: chosen.name, sha256: chosen.sha256 }, route };
}
const unknown = `the ${verb} may have been recorded; check mosaic inbox --business ${id} and mosaic trail before trying again`;
const answer = await consoleOrgRequest(ctx, id, { method: 'POST', path: 'decisions/hire/start', body: { business: id, hire: body }, timeoutMs: 120000, unknown });
if (!ok(answer)) throw refused(answer, verb, unknown);
if (typeof answer.body.id !== 'string' || answer.body.op !== 'resolved') throw new CliError(`outcome unknown: ${unknown}`, 1);
return { value: answer.body, text: b => `recorded decision ${b.id}: ${verb} ${hire.name} (${b.op} ${b.choice})\n` };
};
} else if (autonomy) {
const step = p.pos[1] ?? null;
if (step === null) {
if (p.pos.length !== 1 || Object.keys(p.values).length) throw usage('autonomy ID reads; use autonomy ID set to change it');
run = async ctx => {
const answer = await consoleOrgRequest(ctx, id, { path: 'autonomy' });
if (!ok(answer)) throw refused(answer, 'autonomy', 'the autonomy read did not finish; nothing was changed', AUTONOMY_GUIDANCE);
return { value: answer.body, text: formatAutonomy };
};
} else if (step === 'set') {
if (p.pos.length !== 2) throw usage('autonomy ID set takes options only');
const { revision, changes } = autonomyChanges(p.values);
run = async ctx => {
const read = await consoleOrgRequest(ctx, id, { path: 'autonomy' });
if (!ok(read)) throw refused(read, 'autonomy set', 'the autonomy read did not finish; nothing was changed', AUTONOMY_GUIDANCE);
const unknown = `the setting may have been saved; check mosaic org autonomy ${id} before trying again`;
const answer = await consoleOrgRequest(ctx, id, { method: 'POST', path: 'autonomy', body: { revision, autonomy: autonomyDocument(read.body, changes) }, unknown });
if (!ok(answer)) throw refused(answer, 'autonomy set', unknown, AUTONOMY_GUIDANCE);
return { value: answer.body, text: b => `saved autonomy for ${id}: revision ${b.revision ?? 'unknown'}${b.transaction ? `, receipt ${b.transaction}` : ''}\n` };
};
} else if (step === 'review') {
if (p.pos.length !== 3 || !CLASSES.includes(p.pos[2])) throw usage(`autonomy ID review takes one class: ${CLASSES.join(', ')}`);
if (Object.keys(p.values).length) throw usage('autonomy ID review takes no options but --json');
run = async ctx => {
const unknown = `the review may have been recorded; check mosaic trail before trying again`;
const answer = await consoleOrgRequest(ctx, id, { method: 'POST', path: 'autonomy/review', body: { decisionClass: p.pos[2] }, unknown });
if (!ok(answer)) throw refused(answer, 'autonomy review', unknown, AUTONOMY_GUIDANCE);
return { value: answer.body, text: () => `recorded your trail review for ${p.pos[2]} on ${id}\n` };
};
} else throw usage(`autonomy takes set or review, not ${JSON.stringify(step)}`);
} else throw usage(`unknown org verb ${verb}`);
// agent-guard (#1873): after the usage checks, before any config is read; this process's own
// environment as well as io.env, as org archive|delete check.
refuseInsideAgent(process.env);
refuseInsideAgent(io.env);
let instance;
try { instance = devOwnerInstance(io.env); }
catch (e) { throw new CliError(`${e.message}; these verbs need a named instance whose Console runs with --dev-local-owner`, 3); }
const system = deps.system ?? loadSystem({ env: io.env, instance });
const ctx = { configDir: configDir(io.env), dataRoot: system.dataRoot, instance, principal: DEV_OWNER };
const { value, text } = await run(ctx);
io.stdout.write(p.json ? `${JSON.stringify(value, null, 2)}\n` : text(value));
}
function formatAutonomy(b) {
const a = b.autonomy ?? {};
return [
`revision ${b.revision}${b.writable ? '' : ' (read-only: this Console takes no changes)'}`,
...CLASSES.map(c => `${c}: ${b.levels?.[c] ?? 'unknown'}`),
`notices: ${a.notices === true ? 'on' : 'off'}`,
`reverse window: ${a.reverseWindowHours === undefined ? 'not set (the page offers 24 hours)' : `${a.reverseWindowHours} hours`}`,
`authority resolver: ${a.authorityResolver ?? 'none'}`,
].join('\n') + '\n';
}
+55 -7
View File
@@ -6,14 +6,35 @@ import { installedSystemTypes } from '../../plugins/src/system-types.mjs';
import { DEV_OWNER, devOwnerInstance } from './dev-local-owner.mjs';
import { loadSystem } from './config.mjs';
import { CliError } from './errors.mjs';
import { busStopForDelete } from './org-bus.mjs';
import { busStart, busStopForDelete } from './org-bus.mjs';
import { assertBuildStart, assertDevInstance } from './deployment.mjs';
import { consoleBusRestart } from './org-console.mjs';
import { refuseInsideAgent } from './transport.mjs';
export const ORG_USAGE = 'mosaic org system-types installed | system-types enabled ID | lock show|clear ID | reload ID | archive ID --if-revision N [--json] | restore ID --if-revision N [--json] | delete ID --confirm ID [--if-revision N] [--json] | template render ID [--fixture local] --output FILE | validate FILE | create ID --from FILE | show ID [--json] | list [--json] | update ID --from FILE --if-revision N | queue init ID (--local-only --accept-local-loss-risk | --remote URL) | queue show ID [--json] | project repo init ID PROJECT (--local-only --accept-local-loss-risk | --remote URL) | project repo show ID PROJECT [--json] | config validate ID | config resolve ID INSTANCE [--project ID]';
function parse(args) { const pos = [], values = {}, flags = new Set(); for (let i = 0; i < args.length; i++) { const a = args[i]; if (['--json', '--local-only', '--accept-local-loss-risk'].includes(a)) { if (flags.has(a)) throw new CliError(`duplicate ${a}`, 4); flags.add(a); } else if (['--fixture', '--output', '--from', '--if-revision', '--remote', '--confirm'].includes(a)) { if (values[a] !== undefined || !args[i+1] || args[i+1].startsWith('--')) throw new CliError(`${a} needs one value`, 4); values[a] = args[++i]; } else if (a.startsWith('--')) throw new CliError(`unknown option ${a}`, 4); else pos.push(a); } return { pos, values, flags }; }
async function document(file) { try { return JSON.parse(await readFile(file, 'utf8')); } catch (e) { throw new CliError(`cannot read document ${file}: ${e instanceof SyntaxError ? 'not valid JSON' : e.message}`, 2); } }
import { definitionsCommand, DEFINITIONS_USAGE } from './org-definitions.mjs';
import { orgOwnerCommand, ORG_OWNER_USAGE, ORG_OWNER_VERBS } from './org-owner.mjs';
export const ORG_USAGE = 'mosaic org system-types installed | system-types enabled ID | lock show|clear ID | reload ID | archive ID --if-revision N [--json] | restore ID --if-revision N [--no-bus] [--json] | delete ID --confirm ID [--if-revision N] [--json] | template render ID [--fixture local] --output FILE | validate FILE | create ID --from FILE | show ID [--json] | list [--json] | update ID --from FILE --if-revision N | queue init ID (--local-only --accept-local-loss-risk | --remote URL) | queue show ID [--json] | project repo init ID PROJECT (--local-only --accept-local-loss-risk | --remote URL) | project repo show ID PROJECT [--json] | ' + DEFINITIONS_USAGE.slice('mosaic org '.length) + ' | ' + ORG_OWNER_USAGE.slice('mosaic org '.length) + ' | config validate ID | config resolve ID INSTANCE [--project ID]';
function parse(args) { const pos = [], values = {}, flags = new Set(); for (let i = 0; i < args.length; i++) { const a = args[i]; if (['--json', '--local-only', '--accept-local-loss-risk', '--no-bus'].includes(a)) { if (flags.has(a)) throw new CliError(`duplicate ${a}`, 4); flags.add(a); } else if (['--fixture', '--output', '--from', '--if-revision', '--remote', '--confirm'].includes(a)) { if (values[a] !== undefined || !args[i+1] || args[i+1].startsWith('--')) throw new CliError(`${a} needs one value`, 4); values[a] = args[++i]; } else if (a.startsWith('--')) throw new CliError(`unknown option ${a}`, 4); else pos.push(a); } return { pos, values, flags }; }
// A refusal names the document by label and the error by its code, never by host path or bytes (PR #1916).
const errorCode = e => typeof e?.code === 'string' && /^E[A-Z0-9]+$/.test(e.code) ? e.code : 'unreadable';
async function document(file) { try { return JSON.parse(await readFile(file, 'utf8')); } catch (e) { throw new CliError(`cannot read organization document: ${e instanceof SyntaxError ? 'not valid JSON' : errorCode(e)}`, 2); } }
// agent-guard (#1873): archive and delete stop the organization's bus, so an agent run is refused
// as it is for `mosaic bus stop`: after the usage checks, before any config is read, and checking
// this process's own environment as well as io.env. Restore starts the organization's bus (E1, P1-4),
// so it is refused the same way unless --no-bus is given; `restore --no-bus` starts nothing.
function refuseAgentRun(io) { refuseInsideAgent(process.env); refuseInsideAgent(io.env); }
// E1 (P1-4): the bus a restore starts is the one `mosaic bus start ID --dev-local-owner --detach`
// starts, after that verb's checks. The checks run before the restore, so a refusal changes nothing.
function restoreBusCheck(system, env) {
try { assertBuildStart({ devOwner: true }); assertDevInstance(system, env); }
catch (e) { throw new CliError(`restore starts the bus as the local dev owner, and ${e.message}; restore with --no-bus, then start the bus with mosaic bus start`, 3); }
}
const busStartVerb = id => `mosaic bus start ${id} --dev-local-owner --detach`;
export async function orgCommand(args, io, deps = {}) {
try {
// #1873 T1: the definitions verbs take their own options.
if (args[0] === 'definitions') return await definitionsCommand(args.slice(1), io, deps);
// E2 (#1873, P1-3): the hire form and the autonomy panel, through the running Console (org-owner.mjs).
if (ORG_OWNER_VERBS.includes(args[0])) return await orgOwnerCommand(args, io, deps);
const p = parse(args); const words = p.pos; let result; let accepted = [];
const allowed = (flags, values) => { for (const f of p.flags) if (!flags.includes(f)) throw new CliError(`unexpected ${f}`, 4); for (const f of Object.keys(p.values)) if (!values.includes(f)) throw new CliError(`unexpected ${f}`, 4); };
const need = key => { if (!p.values[key]) throw new CliError(`${key} is required`, 4); return p.values[key]; };
@@ -21,14 +42,35 @@ export async function orgCommand(args, io, deps = {}) {
if (words[0] === 'validate' && words.length === 2) { allowed([], []); result = await validateOrg(await document(words[1])); if (!result.ok) throw new CliError(result.errors.map(e => `${e.path}: ${e.message}`).join('\n'), 2); io.stdout.write('valid\n'); return; }
// U1 (#1873): `org reload` is a human action; an agent run is refused before any config is read.
if (words[0] === 'reload') refuseInsideAgent(io.env);
const revision = (r, required) => { if (r === undefined && !required) return undefined; if (!/^[1-9][0-9]*$/.test(r ?? '')) throw new CliError(r === undefined ? '--if-revision is required' : 'invalid revision', 4); return Number(r); };
let lifecycle = null;
if (['archive', 'restore'].includes(words[0]) && words.length === 2) { allowed(['--json', ...(words[0] === 'restore' ? ['--no-bus'] : [])], ['--if-revision']); lifecycle = { ifRevision: revision(p.values['--if-revision'], true) }; if (words[0] === 'archive' || !p.flags.has('--no-bus')) refuseAgentRun(io); }
else if (words[0] === 'delete' && words.length === 2) { allowed(['--json'], ['--confirm', '--if-revision']); const ifRevision = revision(p.values['--if-revision'], false); lifecycle = { confirm: need('--confirm'), ifRevision }; refuseAgentRun(io); }
if (words[0] === 'system-types' && words[1] === 'installed' && words.length === 2) { allowed(['--json'], []); io.stdout.write(JSON.stringify(await installedSystemTypes(), null, 2) + '\n'); return; }
const instance = devOwnerInstance(io.env); const system = deps.system ?? loadSystem({ env: io.env, instance }); const ctx = { configDir: configDir(io.env), dataRoot: system.dataRoot, instance, principal: DEV_OWNER };
if (words[0] === 'system-types' && words[1] === 'enabled' && words.length === 3) { allowed(['--json'], []); const org = await showOrg(ctx, words[2]); result = { organization: words[2], enabled: org.doc.plugins?.['system-type'] ?? [] }; }
else if (words[0] === 'lock' && ['show', 'clear'].includes(words[1]) && words.length === 3) { allowed([], []); result = await orgLock(ctx, words[2], { clear: words[1] === 'clear' }); }
else if (words[0] === 'create' && words.length === 2) { allowed([], ['--from']); result = await createOrg(ctx, words[1], await document(need('--from'))); }
else if (words[0] === 'update' && words.length === 2) { allowed([], ['--from', '--if-revision']); const r = need('--if-revision'); if (!/^[1-9][0-9]*$/.test(r)) throw new CliError('invalid revision', 4); result = await updateOrg(ctx, words[1], await document(need('--from')), { ifRevision: Number(r) }); }
else if (['archive', 'restore'].includes(words[0]) && words.length === 2) { allowed(['--json'], ['--if-revision']); const r = need('--if-revision'); if (!/^[1-9][0-9]*$/.test(r)) throw new CliError('invalid revision', 4); const opts = { ifRevision: Number(r) }; result = words[0] === 'archive' ? await archiveOrg(ctx, words[1], { ...opts, busStop: busStopForDelete }) : await restoreOrg(ctx, words[1], opts); }
else if (words[0] === 'delete' && words.length === 2) { allowed(['--json'], ['--confirm', '--if-revision']); const r = p.values['--if-revision']; if (r !== undefined && !/^[1-9][0-9]*$/.test(r)) throw new CliError('invalid revision', 4); result = await deleteOrg(ctx, words[1], { busStop: busStopForDelete, confirm: need('--confirm'), ifRevision: r === undefined ? undefined : Number(r) }); }
else if (words[0] === 'archive' && words.length === 2) result = await archiveOrg(ctx, words[1], { ...lifecycle, busStop: busStopForDelete });
else if (words[0] === 'restore' && words.length === 2) {
const startBus = !p.flags.has('--no-bus');
if (startBus) restoreBusCheck(system, io.env);
result = await restoreOrg(ctx, words[1], lifecycle);
// The restore stands whatever the bus does. A bus that does not start is said so, with the verb
// that starts it, and the command exits 3 after printing the restore receipt.
if (startBus) {
try { const started = await (deps.busStart ?? busStart)({ ...ctx, env: io.env }, words[1], { detach: true, devLocalOwner: true, pm: false }); result = { ...result, bus: { outcome: 'started', pid: started.pid } }; }
catch (e) {
// A host that died left its lock behind; `bus start` refuses it until `bus recover` clears it.
const message = e instanceof CliError ? e.message : 'the bus did not start', dead = /^stale /.test(message);
result = { ...result, bus: { outcome: 'failed', message, next: dead ? `mosaic bus recover ${words[1]}; ${busStartVerb(words[1])}` : busStartVerb(words[1]) } };
io.stdout.write(JSON.stringify(result, null, 2) + '\n');
throw new CliError(`${words[1]} was restored, but its bus did not start: ${message}; ${dead ? `clear the dead host with mosaic bus recover ${words[1]}, then start it with` : 'start it with'} ${busStartVerb(words[1])}`, 3);
}
}
}
else if (words[0] === 'delete' && words.length === 2) result = await deleteOrg(ctx, words[1], { busStop: busStopForDelete, ...lifecycle });
// U1 (#1873): the F3 bus control from a terminal, through the running Console's own route, which
// checks the owner and writes the action trail (org-console.mjs consoleBusRestart).
else if (words[0] === 'reload' && words.length === 2) { allowed([], []); result = await consoleBusRestart(ctx, words[1], deps.consoleRequest ?? {}); }
@@ -41,5 +83,11 @@ export async function orgCommand(args, io, deps = {}) {
else throw new CliError(ORG_USAGE, 4);
} else throw new CliError(ORG_USAGE, 4);
io.stdout.write(JSON.stringify(result, null, 2) + '\n');
} catch (e) { if (e instanceof CliError) throw e; throw new CliError(`${e.code ? e.code + ': ' : ''}${e.message}`, 2); }
} catch (e) {
if (e instanceof CliError) throw e;
// A raw Node filesystem error ("CODE: description, syscall 'path'") is named by its operation
// and code, never by the host path in its message (PR #1916).
const message = typeof e?.syscall === 'string' && typeof e.code === 'string' && e.message.startsWith(`${e.code}:`) ? `cannot ${e.syscall} (${errorCode(e)})` : e.message;
throw new CliError(`${e.code ? e.code + ': ' : ''}${message}`, 2);
}
}
+49
View File
@@ -0,0 +1,49 @@
// #1873 proxy setup: host facts that `mosaic proxy` and the Console page share. Never reads a
// key, proxy setting or bus record; labels only, never an absolute path.
import net from 'node:net';
import { hostStatus } from './host.mjs';
import { socketPath, orgPaths } from './config.mjs';
import { devOwnerInstance } from './dev-local-owner.mjs';
import { listOrgs } from '../../organization/src/org-registry.mjs';
// A socket that accepts a connection, or fails other than "absent"/"refused", counts as running.
function socketLive(file, timeout = 1000) {
return new Promise(resolve => {
const socket = net.connect(file);
const done = value => { socket.destroy(); resolve(value); };
socket.setTimeout(timeout, () => done(true));
socket.once('connect', () => done(true));
socket.once('error', error => done(!['ENOENT', 'ECONNREFUSED'].includes(error.code)));
});
}
async function rootRunning(root, socket) {
try {
const status = hostStatus(root);
if (status.host?.live || status.writerLock?.live) return true;
} catch { return true; }
return socketLive(socket);
}
// The init-lock README condition "the bus is stopped", proved for the instance's own bus and
// every registered organization's bus. Anything that cannot be examined counts as running.
// `listOrgsFn` and `running` are for tests.
export async function busesStopped({ dataRoot, configDir, listOrgsFn = listOrgs, running = rootRunning }) {
const out = [];
if (await running(dataRoot, socketPath(dataRoot))) out.push('the instance bus');
let orgs;
try { orgs = await listOrgsFn({ configDir }); }
catch { return { stopped: false, running: [...out, 'organization registry unreadable'] }; }
for (const org of orgs) {
const label = `organization ${typeof org?.id === 'string' ? org.id : '?'}`;
let paths;
try { paths = orgPaths(org.root); } catch { out.push(label); continue; }
if (await running(org.root, paths.socket)) out.push(label);
}
return { stopped: out.length === 0, running: out };
}
// The named instance MOSAIC_CONFIG selects, or null for the default (dev) instance.
export function instanceFromConfig(env = process.env) {
try { return devOwnerInstance(env); } catch { return null; }
}
+2 -2
View File
@@ -63,7 +63,7 @@ const inside = (root, path) => { const r = relative(root, path); return r !== ''
export function threadRecords(file, thread) {
const log = fileEvidence(file);
if (!log.ok && log.problem === 'missing') return { file, missing: true, launches: [], ends: [], malformed: 0 };
if (!log.ok) throw new CliError(`run-evidence-refused: launch log ${file}: ${log.problem}`, 3);
if (!log.ok) throw new CliError(`run-evidence-refused: launch log: ${log.problem}`, 3);
const launches = [], ends = []; let malformed = 0;
log.bytes.toString('utf8').split('\n').forEach((line, i) => {
if (!line) return;
@@ -186,7 +186,7 @@ export async function runCommand(rest, io, deps = {}) {
lines.push(verify ? `verified: ${verified ? 'yes' : 'no'}` : 'not verified (pass --verify)');
io.stdout.write(`${lines.join('\n')}\n`);
}
if (runs.length === 0) throw new CliError(`no launch for thread ${thread.label} in ${log.file}`, 2);
if (runs.length === 0) throw new CliError(`no launch for thread ${thread.label} in the launch log`, 2);
if (verify && !verified) throw new CliError('run evidence failed verification', 1);
return report;
}
+104
View File
@@ -0,0 +1,104 @@
// E2 (#1873, P1-3, org ruling 6): the task board's writes from a terminal. `mosaic tasks create`,
// `edit` and `archive` send the board's three ops (workspace-tasks.mjs) to the organization's bus as
// the person, through the human transport (U4a: the bus's tasks.create, tasks.edit and tasks.archive
// are the person's writes "from the terminal (human-cli)"). The arguments pass the board's own
// check, parseTaskWrite, before anything is sent, so the terminal takes exactly what the board
// takes; the bus then runs its own human, field, digest and archive checks, as it does for the
// board. Writes take no --tester and refuse inside an agent run (callFor), and the bus refuses any
// caller that is not the person (human-required).
import { parseTaskWrite } from '../../webui/src/workspace-tasks.mjs';
import { CliError } from './errors.mjs';
import { orgSelector } from './org-selector.mjs';
import { refuseInsideAgent } from './transport.mjs';
export const TASKS_WRITE_OPS = Object.freeze(['create', 'edit', 'archive']);
export const TASKS_WRITE_USAGE = 'mosaic tasks create --title TEXT --requirement REQ-X-N [--project NAME] [--state STATE] [--assignees ROLE,...] [--priority 0-5] [--due ISO|none] --business <id> [--json]\n' +
' mosaic tasks edit task:N --expect DIGEST [--title TEXT] [--state STATE] [--assignees ROLE,...] [--priority 0-5] [--due ISO|none] --business <id> [--json]\n' +
' mosaic tasks archive task:N --expect DIGEST --business <id> [--json]';
const usage = why => new CliError(`${why}\nusage: ${TASKS_WRITE_USAGE}`, 4);
// Each op's options, as the board's op takes its keys (workspace-tasks.mjs OPS).
const OPTIONS = {
create: ['--title', '--requirement', '--project', '--state', '--assignees', '--priority', '--due'],
edit: ['--expect', '--title', '--state', '--assignees', '--priority', '--due'],
archive: ['--expect'],
};
const KEY = { '--title': 'title', '--requirement': 'requirement', '--project': 'group', '--state': 'state', '--assignees': 'assignees', '--priority': 'priority', '--due': 'due_date', '--expect': 'expect' };
// The board's answer when the bus refuses, by code (workspace-tasks.mjs REFUSALS), so the terminal
// reads the same next step.
const GUIDANCE = {
'human-required': 'the bus takes task changes only from a person; nothing was written',
'tracker-not-store': 'this organization keeps its tasks in Vikunja; nothing was written',
'task-conflict': 'the task changed after you read it; nothing was written. Read it again with mosaic tasks --json',
'task-archived': 'the task is archived; nothing was written',
'task-already-archived': 'the task was already archived; nothing was written',
'task-not-found': 'the task is not on this organization\'s bus; nothing was written',
'task-position-full': 'this project has no room for another task position; nothing was written',
'invalid-state': 'the bus does not have that state; nothing was written',
'unknown-role': 'an assignee is not a role in this organization; nothing was written',
'invalid-request': 'the bus refused the change as invalid; nothing was written',
'credential-leak': 'the change holds part of a stored credential, so the bus refused it; nothing was written',
};
export function parseTasksWrite(args) {
const op = args[0];
if (!TASKS_WRITE_OPS.includes(op)) throw usage(`tasks takes create, edit or archive, not ${JSON.stringify(op)}`);
const selector = orgSelector(usage);
const values = {}, positional = [];
let json = false;
for (let i = 1; i < args.length; i++) {
const a = args[i];
if (selector.owns(a)) { selector.take(a, args[i + 1]); i++; }
else if (a === '--json') { if (json) throw usage('duplicate --json'); json = true; }
else if (OPTIONS[op].includes(a)) {
if (Object.hasOwn(values, a)) throw usage(`duplicate ${a}`);
if (i + 1 >= args.length || args[i + 1].startsWith('--')) throw usage(`${a} needs one value`);
values[a] = args[++i];
} else if (a.startsWith('--')) throw usage(`tasks ${op} does not take ${a}`);
else positional.push(a);
}
// The organization is named before anything else is checked, so a missing --business is a usage
// error (exit 4) inside an agent run too, as for the org owner verbs.
if (!selector.given) throw usage('name the organization with --business');
if (positional.length !== (op === 'create' ? 0 : 1)) throw usage(op === 'create' ? 'tasks create takes no task' : `tasks ${op} takes one task, task:N`);
const body = op === 'create' ? {} : { task_ref: positional[0] };
for (const [flag, raw] of Object.entries(values)) {
const key = KEY[flag];
if (key === 'assignees') body[key] = raw === '' ? [] : raw.split(',').map(r => r.trim());
else if (key === 'priority') body[key] = /^[0-9]$/.test(raw) ? Number(raw) : raw;
else if (key === 'due_date') body[key] = raw === 'none' ? null : raw;
else body[key] = raw;
}
// The board's own check. Its org is the page's; here the organization is checked by the selector
// and the bus, so a fixed valid id stands in for it.
let write;
try { write = parseTaskWrite({ op, org: 'terminal', ...body }); }
catch (e) { if (typeof e?.status === 'number') throw usage(`tasks ${op}: ${e.message}`); throw e; }
return { op, verb: write.verb, args: write.args, json, business: selector.given ? selector.value : undefined };
}
export async function tasksWriteCommand(args, io, callFor) {
const write = parseTasksWrite(args);
// agent-guard (#1873): this process's own environment as well as io.env (callFor checks io.env),
// as the org owner verbs check.
refuseInsideAgent(process.env);
const parsed = { positional: [], flags: new Set(), values: write.business === undefined ? {} : { '--business': write.business } };
const call = callFor(parsed);
let result;
try { result = await call(write.verb, write.args); }
catch (e) {
if (e?.code === 'outcome-unknown' || e?.exitCode === 1) throw new CliError(`outcome unknown: the task change may or may not have been written; read it with mosaic tasks --json before trying again`, 1);
if (typeof e?.code === 'string' && Object.hasOwn(GUIDANCE, e.code)) {
const refused = new CliError(`tasks ${write.op} refused (${e.code}): ${GUIDANCE[e.code]}`, e.exitCode ?? 2);
refused.code = e.code;
throw refused;
}
throw e;
}
const taskRef = typeof result?.task_ref === 'string' ? result.task_ref : write.args.task_ref ?? null;
const digest = typeof result?.digest === 'string' ? result.digest : null;
// The board's answer shape (workspace-tasks.mjs), with the organization the call went to.
const answer = { ok: true, org: parsed.business ?? write.business ?? null, op: write.op, task_ref: taskRef, digest };
const done = { create: 'created', edit: 'changed', archive: 'archived' }[write.op];
io.stdout.write(write.json ? `${JSON.stringify(answer, null, 2)}\n` : `${done} ${taskRef}${digest ? `, digest ${digest}` : ''}\n`);
}
+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);
}
});
+4 -3
View File
@@ -148,7 +148,7 @@ test("a lock left by a dead pid refuses, names the pid, and is not reclaimed", a
const dead = spawnSync(process.execPath, ["-e", ""]);
writeFileSync(`${s.file}.lock`, `${dead.pid}\n`, { mode: 0o600 });
const before = readFileSync(s.file, "utf8");
await refusal(run([...GRANT, "--revision", "1"], s.deps), new RegExp(`${s.file}\\.lock is left by pid ${dead.pid}, which is not running`));
await refusal(run([...GRANT, "--revision", "1"], s.deps), new RegExp(`^refused: access file lock is left by pid ${dead.pid}, which is not running`));
assert.equal(readFileSync(s.file, "utf8"), before);
assert.equal(readFileSync(`${s.file}.lock`, "utf8"), `${dead.pid}\n`);
assert.equal(s.receipts(), "");
@@ -165,13 +165,14 @@ test("a failed rename leaves the old bytes, removes the temp file, and writes no
assert.equal(s.receipts(), "");
});
test("a group-writable access file refuses with exit 3, names the file, and changes nothing", async (t) => {
test("a group-writable access file refuses with exit 3, names the file by label, never by path, and changes nothing", async (t) => {
const s = setup(t);
chmodSync(s.file, 0o664);
const before = readFileSync(s.file, "utf8");
const err = await refusal(run([...GRANT, "--revision", "1"], s.deps), /must not be writable by group or other/);
assert.equal(err.exitCode, 3);
assert.match(err.message, new RegExp(s.file.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
assert.equal(err.message, "refused: access file must not be writable by group or other (mode 664)");
assert.ok(!err.message.includes(s.file), err.message);
assert.equal(readFileSync(s.file, "utf8"), before);
assert.equal(s.receipts(), "");
assert.equal(existsSync(`${s.file}.lock`), false);
+14 -1
View File
@@ -1,6 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, chmodSync, writeFileSync, readFileSync, mkdirSync, existsSync, rmSync } from 'node:fs';
import { mkdtempSync, chmodSync, writeFileSync, readFileSync, mkdirSync, existsSync, rmSync, symlinkSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { accountsCommand } from '../src/accounts.mjs';
@@ -101,3 +101,16 @@ test('org-acct-anchor: init, list and remove refuse a 0777 ancestor before any l
chmodSync(parent, 0o1777);
await accountsCommand(['init'], io, deps); assert.match(output, /accounts init: u-[0-9a-f]{12}, revision 1/);
});
// PR #1916 (org-redact5): an unreadable owner file is named by the registry's checked
// id, never by the organization root's host path.
test('remove: an invalid or linked owner file is named by the org id, never by host path', async t => {
const SENTINEL = 'sk-test-NOT-A-SECRET-1234';
const s = setup(t); await s.run(['init']); await s.run(['user', 'add', '--name', 'Pat', '--subject', 'pocket:pat', '--revision', '1']); const id = s.read().users[1].id;
const root = join(s.configDir, SENTINEL, 'broken'); mkdirSync(root, { recursive: true, mode: 0o700 });
await withRegistry(s.deps, async registry => { registry.orgs.broken = { id: 'broken', root, state: 'active', revision: 1, created: 'now' }; writeFileSync(join(root, 'owner.json'), '{not json', { mode: 0o600 }); });
const clean = (pattern) => e => { assert.equal(e.exitCode, 3); assert.match(e.message, pattern); assert.ok(!e.message.includes(SENTINEL) && !e.message.includes(root), e.message); return true; };
await assert.rejects(s.run(['user', 'remove', id, '--revision', '2']), clean(/^refused: .*organization owner of broken is not valid JSON$/));
rmSync(join(root, 'owner.json')); writeFileSync(join(root, 'real.json'), '{}', { mode: 0o600 }); symlinkSync(join(root, 'real.json'), join(root, 'owner.json'));
await assert.rejects(s.run(['user', 'remove', id, '--revision', '2']), clean(/^refused: .*organization owner of broken must be a regular, non-symbolic-link file$/));
assert.equal(s.read().users.length, 2, 'nothing was removed');
});
+287
View File
@@ -0,0 +1,287 @@
// agent-guard (#1873, D2 security re-check P1): the bus and Console lifecycle verbs refuse inside an
// agent run with refuseInsideAgent's refusal (exit 3), before any read. The marker is found in the
// command's environment and in this process's own one, so a sanitized io.env does not hide it.
// Status verbs stay open to agents. A person keeps every verb in a human shell, and the Console's
// Restart bus and Restart Console keep working: the Console starts a bus host with its own
// environment, which carries no marker.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawn, spawnSync } from 'node:child_process';
import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { request } from 'node:http';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { reserveOrg, activateOrg } from '../../organization/src/org-registry.mjs';
import { businessDoc, rolesCopy, writeJson } from '../../organization/tests/helpers.mjs';
import { humanEnv, notifyConfig } from './helpers.mjs';
import { run } from '../src/cli.mjs';
import { busStart, busStatus, busStop } from '../src/org-bus.mjs';
import { AGENT_MARKERS } from '../src/transport.mjs';
import { createOrg, templateRender } from '../../organization/src/org-service.mjs';
import { consoleStart, consoleStop } from '../src/org-console.mjs';
const REPO = new URL('../../../', import.meta.url).pathname;
const MOSAIC = join(REPO, 'scripts/mosaic');
const DEV = { provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner' };
const FORBIDDEN = [7332, 7340, 7341, 33223, 17450, 17443, 44307, 7350];
const REFUSED = marker => new RegExp(`^mosaic: refused: this runs only from a human shell, outside any agent run \\(found ${marker} in the environment\\)\\n$`);
// Every guarded verb, as a person types it.
const GUARDED = [
['bus', 'start', 'alpha', '--no-launch', '--detach'],
['bus', 'start', 'alpha', '--internal-host'],
['bus', 'stop', 'alpha'],
['bus', 'restart', 'alpha'],
['bus', 'recover', 'alpha'],
['console', 'start', '--detach', '--port', 'auto'],
['console', 'start', '--port', 'auto'],
['console', 'stop'],
['console', 'restart'],
// archive and delete stop the organization's bus (review finding 2).
['org', 'archive', 'alpha', '--if-revision', '1'],
['org', 'delete', 'alpha', '--confirm', 'alpha'],
// E1 (P1-4): restore starts the organization's bus unless --no-bus is given.
['org', 'restore', 'alpha', '--if-revision', '1'],
// E2 (#1873, P1-3): the hire form and the autonomy panel from a terminal, reads included.
['org', 'role-templates', 'alpha'],
['org', 'hire', 'alpha', '--name', 'ada', '--template', 'worker', '--harness', 'pi', '--model', 'glm', '--reason', 'r'],
['org', 'change', 'alpha', '--name', 'ada', '--template', 'worker', '--harness', 'pi', '--model', 'glm', '--reason', 'r'],
['org', 'fire', 'alpha', '--name', 'ada', '--reason', 'r'],
['org', 'autonomy', 'alpha'],
['org', 'autonomy', 'alpha', 'set', '--revision', 'a'.repeat(64), '--merge', 'near'],
['org', 'autonomy', 'alpha', 'review', 'merge'],
];
// The CLI in this process, with the given io.env; stdout and stderr are collected.
async function inProcess(argv, env) {
let stdout = '', stderr = '';
const code = await run(argv, { env, stdin: null, stdout: { write: s => { stdout += s; } }, stderr: { write: s => { stderr += s; } } });
return { code, stdout, stderr };
}
// A marker in this process's own environment for the length of fn.
async function withOwnMarker(name, fn) {
const prior = process.env[name];
process.env[name] = 'agent-guard';
try { return await fn(); }
finally { if (prior === undefined) delete process.env[name]; else process.env[name] = prior; }
}
// The CLI as a child, without blocking this process (an in-process Console must keep answering).
function mosaic(env, args, timeout = 60000) {
return new Promise((resolve, reject) => {
const child = spawn(MOSAIC, args, { env, stdio: ['ignore', 'pipe', 'pipe'] });
let stdout = '', stderr = '';
child.stdout.setEncoding('utf8').on('data', c => { stdout += c; });
child.stderr.setEncoding('utf8').on('data', c => { stderr += c; });
const timer = setTimeout(() => child.kill('SIGKILL'), timeout);
child.on('error', reject);
child.on('close', status => { clearTimeout(timer); resolve({ status, stdout, stderr }); });
});
}
// A request the way the page sends it to its own Console.
function post(port, path) {
return new Promise((resolve, reject) => {
const host = `127.0.0.1:${port}`;
const req = request({ host: '127.0.0.1', port, path, method: 'POST', headers: { host, origin: `http://${host}`, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json', 'content-length': 2 } }, res => {
let text = ''; res.setEncoding('utf8'); res.on('data', c => { text += c; });
res.on('end', () => { let body = null; try { body = JSON.parse(text); } catch { /* not JSON */ } resolve({ status: res.statusCode, body }); });
});
req.on('error', reject); req.end('{}');
});
}
// A scratch instance made by bootstrap.sh, with org alpha active and its organization file.
async function instance(t) {
const home = mkdtempSync(join(tmpdir(), 'ag-'));
const env = humanEnv({ HOME: home, MOSAIC_CONFIG: join(home, '.config/mosaic-agtest/config.json') });
const setup = spawnSync(join(REPO, 'scripts/bootstrap.sh'), ['--instance', 'agtest'], { env, encoding: 'utf8' });
assert.equal(setup.status, 0, setup.stderr);
env.MOSAIC_ROLES_DIR = rolesCopy(home);
const ctx = { configDir: join(home, '.config/mosaic-agtest'), dataRoot: join(home, '.mosaic-agtest'), instance: 'agtest', env };
const org = await reserveOrg(ctx, 'alpha');
await activateOrg(ctx, 'alpha');
writeJson(join(ctx.configDir, 'businesses', 'alpha.json'), businessDoc(home, 'alpha'));
notifyConfig(org.root, 'alpha', null);
t.after(async () => {
try { await busStop(ctx, 'alpha'); } catch {}
try { await consoleStop(ctx); } catch {}
rmSync(home, { recursive: true, force: true });
});
return { ctx, env, root: org.root };
}
// One test per guarded verb. The config path is not an instance config, so any read would refuse
// with another message: each refusal below happened before the command read anything.
const NOWHERE = () => humanEnv({ MOSAIC_CONFIG: '/nonexistent/ag/config.json' });
for (const args of GUARDED) {
test(`${args.join(' ')} refuses with a marker in the environment it is given and with one only in its own process`, async () => {
// A marker in the command's environment (a child started from an agent run).
const child = await mosaic({ ...NOWHERE(), MOSAIC_AGENT_RUN: '1' }, args, 30000);
assert.equal(child.status, 3, child.stderr);
assert.equal(child.stdout, '');
assert.match(child.stderr, REFUSED('MOSAIC_AGENT_RUN'));
// A sanitized io.env does not hide a marker in the process that runs the verb.
const own = await withOwnMarker('MOSAIC_RUN_ID', () => inProcess(args, NOWHERE()));
assert.equal(own.code, 3, own.stderr);
assert.equal(own.stdout, '');
assert.match(own.stderr, REFUSED('MOSAIC_RUN_ID'));
});
}
// The library start a supervisor runs checks this process too, whatever environment it is given.
test('consoleStart refuses a marker in its own process whatever environment it is given', async () => {
await withOwnMarker('CODEX_THREAD_ID', async () => {
const ctx = { configDir: '/nonexistent/ag', dataRoot: '/nonexistent/ag-data', instance: 'ag', env: NOWHERE() };
for (const devLocalOwner of [false, true]) {
await assert.rejects(consoleStart(ctx, { devLocalOwner, detach: true, port: 'auto', warn: () => {} }), e => e.exitCode === 3 && /found CODEX_THREAD_ID/.test(e.message));
}
});
});
test('usage errors still come first, so an agent sees the usage line, not the refusal', async () => {
const env = { ...NOWHERE(), MOSAIC_AGENT_RUN: '1' };
for (const args of [['bus', 'stop'], ['bus', 'start'], ['bus', 'start', 'alpha', '--pm', '--no-launch'], ['console', 'stop', 'extra'], ['console', 'restart', 'extra'], ['console', 'start', '--dev-insecure-bind', '0.0.0.0'], ['console', 'start', '--dev-allow-host', 'x'], ['console', 'start', 'extra'], ['org', 'archive', 'alpha'], ['org', 'restore', 'alpha'], ['org', 'restore', 'alpha', '--if-revision', '1', '--no-bus', '--no-bus'], ['org', 'delete', 'alpha'], ['org', 'delete', 'alpha', '--confirm', 'alpha', '--if-revision', 'x'], ['org', 'hire', 'alpha', '--name', 'ada'], ['org', 'fire', 'alpha'], ['org', 'autonomy', 'alpha', 'set', '--merge', 'near'], ['org', 'autonomy', 'alpha', 'review'], ['org', 'role-templates']]) {
const r = await mosaic(env, args, 30000);
assert.equal(r.status, 4, `${args.join(' ')}: ${r.stderr}`);
}
});
test('bus status and console status stay open to an agent run', { timeout: 120000 }, async t => {
const { ctx, env } = await instance(t);
const started = await mosaic(env, ['bus', 'start', 'alpha', '--no-launch', '--detach']);
assert.equal(started.status, 0, started.stderr);
const agent = { ...env, CLAUDECODE: '1', MOSAIC_AGENT_RUN: '1' };
const bus = await mosaic(agent, ['bus', 'status', 'alpha', '--json']);
assert.equal(bus.status, 0, bus.stderr);
assert.equal(JSON.parse(bus.stdout).healthy, true);
const console = await mosaic(agent, ['console', 'status']);
assert.equal(console.status, 0, console.stderr);
assert.deepEqual(JSON.parse(console.stdout), { running: false });
await withOwnMarker('PI_AGENT_DIR', async () => {
const own = await inProcess(['bus', 'status', 'alpha'], env);
assert.equal(own.code, 0, own.stderr);
assert.equal(JSON.parse(own.stdout).healthy, true);
assert.equal((await inProcess(['console', 'status'], env)).code, 0);
});
// The refused verbs changed nothing: the bus still runs on the same host.
const pid = JSON.parse(started.stdout).pid;
for (const args of [['bus', 'stop', 'alpha'], ['bus', 'restart', 'alpha']]) {
const r = await mosaic(agent, args);
assert.equal(r.status, 3, `${args.join(' ')}: ${r.stderr}`);
}
const after = await busStatus(ctx, 'alpha');
assert.equal(after.healthy, true); assert.equal(after.pid, pid);
});
test('from a human shell every lifecycle verb works as before', { timeout: 180000 }, async t => {
const { ctx, env, root } = await instance(t);
// bus start runs its host as `bus start alpha --internal-host` with this environment.
const start = await mosaic(env, ['bus', 'start', 'alpha', '--no-launch', '--detach']);
assert.equal(start.status, 0, start.stderr);
const first = JSON.parse(start.stdout);
const restart = await mosaic(env, ['bus', 'restart', 'alpha']);
assert.equal(restart.status, 0, restart.stderr);
assert.notEqual(JSON.parse(restart.stdout).pid, first.pid);
assert.equal((await busStatus(ctx, 'alpha')).healthy, true);
const stop = await mosaic(env, ['bus', 'stop', 'alpha']);
assert.equal(stop.status, 0, stop.stderr);
assert.equal(JSON.parse(stop.stdout).stopped, true);
assert.equal((await busStatus(ctx, 'alpha')).healthy, false);
// A dead host's state, which bus recover clears with a receipt.
const dead = { pid: 2147483647, startTime: '1', business: 'alpha', startedAt: '2026-01-01T00:00:00Z' };
for (const name of ['host.json', 'host.lock']) writeFileSync(join(root, 'bus-host', name), JSON.stringify(dead), { mode: 0o600 });
const recover = await mosaic(env, ['bus', 'recover', 'alpha']);
assert.equal(recover.status, 0, recover.stderr);
assert.equal(JSON.parse(recover.stdout).recovered, true);
assert.equal(existsSync(join(root, 'bus-host/host.json')), false);
const consoleUp = await mosaic(env, ['console', 'start', '--detach', '--port', 'auto']);
assert.equal(consoleUp.status, 0, consoleUp.stderr);
assert.ok(!FORBIDDEN.includes(Number(new URL(JSON.parse(consoleUp.stdout).url).port)));
const consoleDown = await mosaic(env, ['console', 'stop']);
assert.equal(consoleDown.status, 0, consoleDown.stderr);
assert.equal(JSON.parse(consoleDown.stdout).stopped, true);
// console restart needs a supervised Console; a foreground start supervises in this process.
const supervised = await consoleStart(ctx, { devLocalOwner: true, port: 'auto', warn: () => {} });
t.after(async () => { supervised.stop(); await supervised.done; });
const consoleRestart = await mosaic(env, ['console', 'restart']);
assert.equal(consoleRestart.status, 0, consoleRestart.stderr);
assert.equal(JSON.parse(consoleRestart.stdout).outcome, 'restarted');
});
// The Console's Restart bus runs org-bus.mjs in the Console process. Its new bus host is
// `cli.mjs bus start ID --internal-host`, a guarded entry, run with the Console's own environment.
// The organization is made the way the Console makes one (the service, as the local dev owner), and
// its bus first starts with --no-launch from a human shell; a restart keeps that option.
test("the Console's Restart bus works: the new host runs with the Console's environment", { timeout: 180000 }, async t => {
const home = mkdtempSync(join(tmpdir(), 'ag-'));
const env = humanEnv({ HOME: home, MOSAIC_CONFIG: join(home, '.config/mosaic-agcon/config.json') });
const setup = spawnSync(join(REPO, 'scripts/bootstrap.sh'), ['--instance', 'agcon'], { env, encoding: 'utf8' });
assert.equal(setup.status, 0, setup.stderr);
const ctx = { configDir: join(home, '.config/mosaic-agcon'), dataRoot: join(home, '.mosaic-agcon'), instance: 'agcon', env };
t.after(async () => {
try { await busStop(ctx, 'alpha'); } catch {}
try { await consoleStop(ctx); } catch {}
rmSync(home, { recursive: true, force: true });
});
await createOrg({ ...ctx, principal: DEV }, 'alpha', await templateRender('alpha', { fixture: 'local' }));
const started = await mosaic(env, ['bus', 'start', 'alpha', '--no-launch', '--dev-local-owner', '--detach']);
assert.equal(started.status, 0, started.stderr);
const first = JSON.parse(started.stdout).pid;
const run = await consoleStart(ctx, { devLocalOwner: true, detach: true, port: 'auto', warn: () => {} });
const port = Number(new URL(run.url).port);
assert.ok(!FORBIDDEN.includes(port));
const restart = await post(port, '/api/orgs/alpha/restart/bus');
assert.equal(restart.status, 200, JSON.stringify(restart.body));
assert.equal(restart.body.step, 'bus-restart'); assert.equal(restart.body.healthy, true);
const after = await busStatus(ctx, 'alpha');
assert.equal(after.healthy, true); assert.notEqual(after.pid, first);
// The new host carries the Console's environment: no agent marker. Only the names are read.
const names = readFileSync(`/proc/${after.pid}/environ`, 'utf8').split('\0').map(l => l.split('=')[0]);
for (const marker of AGENT_MARKERS) assert.ok(!names.includes(marker), marker);
// org archive and org delete stop this bus for a person, but refuse an agent run and leave the
// bus and the organization as they were.
const show = () => mosaic(env, ['org', 'show', 'alpha', '--json']).then(r => { assert.equal(r.status, 0, r.stderr); return JSON.parse(r.stdout); });
const before = await show();
const agent = { ...env, CLAUDECODE: '1' };
for (const args of [['org', 'archive', 'alpha', '--if-revision', String(before.revision)], ['org', 'delete', 'alpha', '--confirm', 'alpha', '--if-revision', String(before.revision)]]) {
const r = await mosaic(agent, args);
assert.equal(r.status, 3, `${args.join(' ')}: ${r.stderr}`);
assert.match(r.stderr, REFUSED('CLAUDECODE'));
}
const still = await busStatus(ctx, 'alpha');
assert.equal(still.healthy, true); assert.equal(still.pid, after.pid);
assert.deepEqual(await show(), before);
// The same archive from a human shell stops the bus; restore makes alpha active again.
const archived = await mosaic(env, ['org', 'archive', 'alpha', '--if-revision', String(before.revision), '--json']);
assert.equal(archived.status, 0, archived.stderr);
// An archived organization has no bus status, so the old host's /proc entry shows it stopped.
for (let i = 0; i < 100 && existsSync(`/proc/${after.pid}`); i++) await new Promise(r => setTimeout(r, 100));
assert.ok(!existsSync(`/proc/${after.pid}`), 'archive from a human shell stops the bus host');
// E1 (P1-4): restore starts the bus, so an agent run is refused before anything changes, and
// `--no-bus` (which starts nothing) is the only restore an agent run may make.
const archivedRevision = JSON.parse(archived.stdout).revision;
for (const marker of ['CLAUDECODE', 'MOSAIC_AGENT_RUN']) {
const r = await mosaic({ ...env, [marker]: '1' }, ['org', 'restore', 'alpha', '--if-revision', String(archivedRevision), '--json']);
assert.equal(r.status, 3, r.stderr); assert.match(r.stderr, REFUSED(marker)); assert.equal(r.stdout, '');
}
const listed = await mosaic(env, ['org', 'list', '--json']); assert.equal(listed.status, 0, listed.stderr);
assert.equal(JSON.parse(listed.stdout).find(o => o.id === 'alpha').state, 'archived', 'the refused restore changed nothing');
const quiet = await mosaic(agent, ['org', 'restore', 'alpha', '--if-revision', String(archivedRevision), '--no-bus', '--json']);
assert.equal(quiet.status, 0, quiet.stderr); assert.equal(JSON.parse(quiet.stdout).bus, null);
assert.equal((await busStatus(ctx, 'alpha')).healthy, false, 'restore --no-bus starts nothing');
// A person's restore starts the bus as the dev owner, with no agent marker in its host.
const again = await mosaic(env, ['org', 'archive', 'alpha', '--if-revision', String((await show()).revision), '--json']);
assert.equal(again.status, 0, again.stderr);
const restored = await mosaic(env, ['org', 'restore', 'alpha', '--if-revision', String(JSON.parse(again.stdout).revision), '--json']);
assert.equal(restored.status, 0, restored.stderr);
const restoredBus = JSON.parse(restored.stdout).bus;
assert.equal(restoredBus.outcome, 'started');
const up = await busStatus(ctx, 'alpha'); assert.equal(up.healthy, true); assert.equal(up.pid, restoredBus.pid);
const hostNames = readFileSync(`/proc/${up.pid}/environ`, 'utf8').split('\0').map(l => l.split('=')[0]);
for (const marker of AGENT_MARKERS) assert.ok(!hostNames.includes(marker), marker);
await busStop({ ...ctx, env }, 'alpha');
// A marker in the environment the host is started with stops it at the guarded entry.
await assert.rejects(busStart({ ...ctx, env: { ...env, MOSAIC_AGENT_RUN: '1' } }, 'alpha', { detach: true, noLaunch: true }), e => e.exitCode === 3 && /bus failed to start/.test(e.message));
assert.match(readFileSync(join(after.root ?? join(ctx.dataRoot, 'org-runtime/alpha'), 'bus-host/host.log'), 'utf8'), /outside any agent run \(found MOSAIC_AGENT_RUN/);
assert.equal((await busStatus(ctx, 'alpha')).healthy, false);
});
+3 -1
View File
@@ -72,7 +72,9 @@ test("an unknown key and a JSON syntax error each leave the bus down with the re
assert.equal(s.business, "acme");
assert.equal(s.file, file);
assert.match(s.reason, reason);
assert.ok(s.reason.includes(file) || /surprise/.test(s.reason));
// The reason names the file by label, never by host path (PR #1916); the path stays in `file`.
assert.ok(!s.reason.includes(file), s.reason);
assert.ok(/surprise/.test(s.reason) || /business file acme\.json is not valid JSON/.test(s.reason), s.reason);
// `bus status` shows it, in text and in JSON.
const out = run(f, "bus", "status", "acme", "--json");
assert.equal(out.status, 0, out.stderr);
@@ -0,0 +1,27 @@
// Invoked inside a private PID namespace. Check residual processes BEFORE its
// init exits: namespace teardown would otherwise mask a missed descendant kill.
import { readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { spawnSync } from 'node:child_process';
import { instrumentCleanupTimer } from './case-inner-timing-fixture.mjs';
import { blockingQuerySource, killRecordedPids, recordedPids, recordedLivePids } from './case-sudo-fixture.mjs';
const [script,dir]=process.argv.slice(2),clock=join(dir,"clock '$;literal"),records=join(dir,'pids');
try {
writeFileSync(clock,'',{mode:0o600});
const worker=join(dir,'blocked.mjs');
writeFileSync(worker,blockingQuerySource(records,'sleep')+'\nloadSystem();');
const source=instrumentCleanupTimer(readFileSync(script,'utf8'),clock);
const body=source.slice(source.indexOf('# row253-supervisor-begin'),source.indexOf('# row253-supervisor-end'));
const result=spawnSync('/bin/bash',['-c',body+'\nexec /bin/bash -c "$TESTER_SUPERVISOR" -- 2 /usr/bin/setsid /usr/bin/sleep "$@"','--',process.execPath,worker],{
encoding:'utf8',timeout:12000,killSignal:'SIGKILL',
});
// These PIDs belong to this namespace; never use them to signal host PIDs.
process.stdout.write(JSON.stringify({
status:result.status,signal:result.signal,error:result.error?.message,
stdout:result.stdout,stderr:result.stderr,pairs:recordedPids(records),
live:recordedLivePids(records),clock:readFileSync(clock,'utf8'),
}));
} finally {
killRecordedPids(records);
}
@@ -0,0 +1,69 @@
// A delay in finish(), after timing2's end stamp, must fail independently of
// the inner timer. One fixed process tree runs at a time, without host setup,
// fake sudo, or a parent-side elapsed-time assertion.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { instrumentCleanupTimer, removeInnerObservers, readProbeSeconds } from './case-inner-timing-fixture.mjs';
const scripts=['case1-accept.sh','case2-unit.sh'];
const read=name=>readFileSync(new URL(`../../../scripts/${name}`,import.meta.url),'utf8');
// The namespace fixes /proc inventory size; it does not replace clean_session.
// See org-cleanup-time-build.md for the standalone and loaded calibration. The
// bound must reject 1s/2s delays and leave most of the 2s production headroom.
// Constant delays below 0.5s can pass; this is not a bound on host-wide scan
// cost. In particular, per-entry work needs a shipped scan-cost follow-up.
const CLEANUP_LIMIT_SECONDS=0.5;
const probe=fileURLToPath(new URL('./case-cleanup-time-fixture.mjs',import.meta.url));
for(const name of scripts)test(`cleanup timing ${name}: observers preserve bytes and reject exit-anchor drift`,()=>{
const source=read(name),clock="/unused/clock '$;literal";
const copy=instrumentCleanupTimer(source,clock);
assert.equal(removeInnerObservers(copy,clock),source);
for(const marker of ['SO_PEERCRED','MOSAIC_TESTER_SOCKET_V1','MOSAIC_LEGACY_STATUS'])
assert.equal(copy.split(marker).length,source.split(marker).length,marker);
const exit=' exit "$rc"\n';
assert.throws(()=>instrumentCleanupTimer(source.replace(exit,''),clock),/anchor/);
assert.throws(()=>instrumentCleanupTimer(source.replace(exit,exit+exit),clock),/anchor/);
});
// Both shipped copies are executed: Case1 shares this supervisor across legacy
// calls, queries and P10; Case2 uses its copy for config queries.
for(const name of scripts)test(`cleanup timing ${name}: inner timeout kills descendants within the cleanup budget`,t=>{
const dir=mkdtempSync(join(tmpdir(),'cl-'));
t.after(()=>rmSync(dir,{recursive:true,force:true}));
// No skip/fallback: this Linux test requires unprivileged user/PID/mount
// namespaces. --kill-child also removes our namespace if the probe times out.
const outer=spawnSync('/usr/bin/unshare',[
'--user','--map-root-user','--pid','--fork','--kill-child=KILL','--mount-proc',
process.execPath,probe,fileURLToPath(new URL(`../../../scripts/${name}`,import.meta.url)),dir,
],{
encoding:'utf8',timeout:15000,killSignal:'SIGKILL',
});
const prerequisite='requires /usr/bin/unshare and unprivileged user/PID/mount namespaces (check kernel/AppArmor/container seccomp)';
assert.equal(outer.error,undefined,`${prerequisite}: ${outer.error?.message}`);
assert.equal(outer.status,0,`${prerequisite}; probe output:\n${outer.stdout}${outer.stderr}`);
assert.equal(outer.stderr,'');
const result=JSON.parse(outer.stdout);
assert.equal(result.error,undefined,result.error);
assert.equal(result.signal,null,result.stderr);
assert.equal(result.status,81,result.stderr);
assert.equal(result.stdout,'');
assert.equal(result.stderr,'');
const pairs=result.pairs;
assert.equal(pairs.length,2,'the command and its live descendant both started');
assert.notEqual(pairs[0][0],pairs[0][1],'the trusted parent leads the command session');
assert.notEqual(pairs[1][0],pairs[0][0]);
assert.equal(pairs[1][1],pairs[0][1],'the descendant belongs to the supervised session');
assert.deepEqual(result.live,[],'cleanup must kill the whole process tree before namespace teardown');
// A positive centisecond pair relies on clean_session's current mandatory
// 0.01s retry sleep. Revisit this measurement if shipped cleanup removes it;
// keep the shared inner-timer parser strict in the meantime.
const seconds=readProbeSeconds(result.clock);
t.diagnostic(`${name}: cleanup ${seconds}s; limit ${CLEANUP_LIMIT_SECONDS}s`);
assert.ok(seconds<CLEANUP_LIMIT_SECONDS,`${name}: cleanup ${seconds}s must be < ${CLEANUP_LIMIT_SECONDS}s`);
});
@@ -0,0 +1,50 @@
// Test-copy observations only: bracket the supervisor's inner timer before
// clean_session scans the host's /proc. Keep all shipped control flow intact.
import { readProbeSeconds } from './case-p10-timing-fixture.mjs';
export { readProbeSeconds };
const BEGIN = '# row253-supervisor-begin\n';
const END = '# row253-supervisor-end\n';
const TIMER = '"$sleep_bin" "$seconds" & timer=$!\n';
const FINISH = 'finish() {\n local rc="$?"\n';
function unique(source, anchor) {
const at=source.indexOf(anchor);
if(at<0 || source.indexOf(anchor,at+anchor.length)>=0) throw new Error('inner observer anchor must occur exactly once');
return at;
}
function innerObservers(clock) {
// The literal path survives env -i in the fake sudo. It is a private scratch
// file, never a product input. Quote even paths containing shell metacharacters.
const file="'"+clock.replaceAll("'", "'\\''")+"'";
return {
begin:`# inner-observer-begin\n{ read -r timing2_up timing2_spare < /proc/uptime &&\n printf 'begin %s\\n' "$timing2_up" >> ${file}; } 2>/dev/null || :\n# inner-observer-begin-end\n`,
end:` # inner-observer-end\n { read -r timing2_up timing2_spare < /proc/uptime &&\n printf 'end %s\\n' "$timing2_up" >> ${file}; } 2>/dev/null || :\n # inner-observer-end-end\n`,
};
}
export function instrumentInnerTimer(source,clock) {
const from=unique(source,BEGIN),to=unique(source,END);
if(to<=from)throw new Error('inner observer scope is reversed');
let supervisor=source.slice(from,to);
unique(supervisor,TIMER);unique(supervisor,FINISH);
const blocks=innerObservers(clock);
supervisor=supervisor.replace(TIMER,blocks.begin+TIMER).replace(FINISH,FINISH+blocks.end);
return source.slice(0,from)+supervisor+source.slice(to);
}
export function removeInnerObservers(source,clock) {
for(const block of Object.values(innerObservers(clock))){unique(source,block);source=source.replace(block,'');}
return source;
}
// Reuse the timing2 finish-entry stamp, moving only the other observation.
// The end is in the supervisor, after clean_session and both waits, immediately
// before exit. Parent scheduling and process start-up never enter this pair.
export function instrumentCleanupTimer(source,clock) {
const blocks=innerObservers(clock);
let observed=instrumentInnerTimer(source,clock);
const from=unique(observed,BEGIN),to=unique(observed,END);
let supervisor=observed.slice(from,to);
const exit=' exit "$rc"\n';
unique(supervisor,exit);
supervisor=supervisor.replace(blocks.begin,'').replace(blocks.end,blocks.begin);
supervisor=supervisor.replace(exit,blocks.end+exit);
return observed.slice(0,from)+supervisor+observed.slice(to);
}
@@ -0,0 +1,33 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, writeFileSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { join } from 'node:path';
import { tmp } from './helpers.mjs';
import { instrumentInnerTimer, removeInnerObservers, readProbeSeconds } from './case-inner-timing-fixture.mjs';
const scripts=['case1-accept.sh','case2-unit.sh'];
const read=name=>readFileSync(new URL(`../../../scripts/${name}`,import.meta.url),'utf8');
for(const name of scripts)test(`inner timing ${name}: private copy preserves shipped bytes and rejects anchor drift`,t=>{
const clock=join(tmp(t,'timer-'),'clock');
const source=read(name),copy=instrumentInnerTimer(source,clock);
assert.equal(removeInnerObservers(copy,clock),read(name));
for(const marker of ['SO_PEERCRED','MOSAIC_TESTER_SOCKET_V1','MOSAIC_LEGACY_STATUS'])
assert.equal(copy.split(marker).length,source.split(marker).length,marker);
for(const anchor of ['# row253-supervisor-begin\n','# row253-supervisor-end\n','"$sleep_bin" "$seconds" & timer=$!\n','finish() {\n local rc="$?"\n']){
assert.throws(()=>instrumentInnerTimer(source.replace(anchor,''),clock),/anchor/);
assert.throws(()=>instrumentInnerTimer(source.replace(anchor,anchor+anchor),clock),/anchor/);
}
});
for(const name of scripts)test(`inner timing ${name}: real supervisor retains streams status and EXIT cleanup`,t=>{
const clock=join(tmp(t,'timer-'),"clock '$;literal");writeFileSync(clock,'');
const source=instrumentInnerTimer(read(name),clock);
const body=source.slice(source.indexOf('# row253-supervisor-begin'),source.indexOf('# row253-supervisor-end'));
const result=spawnSync('/bin/bash',['-c',body+'\n/bin/bash -c "$TESTER_SUPERVISOR" -- 2 /usr/bin/setsid /usr/bin/sleep /bin/bash -c \'printf exact-out; printf exact-err >&2; /usr/bin/sleep 0.1; exit 7\''],{encoding:'utf8'});
assert.equal(result.status,87,result.stderr);
assert.equal(result.stdout,'exact-out');assert.equal(result.stderr,'exact-err');
assert.match(readFileSync(clock,'utf8'),/^begin [0-9]+\.[0-9]{2}\nend [0-9]+\.[0-9]{2}\n$/);
// The same strict parser used by the timing bands rejects a guard's partial,
// duplicate or reversed pair, rather than substituting wall time.
for(const invalid of ['begin 10.00\n','end 11.00\n','begin 10.00\nend 11.00\nbegin 12.00\nend 13.00\n','begin 11.00\nend 10.00\n'])
assert.throws(()=>readProbeSeconds(invalid));
});
+36 -1
View File
@@ -5,6 +5,13 @@ const LEGACY = '# row276-legacy-begin';
// Stable even when a mutant removes the outer timeout from this command.
const LAUNCH = ' LC_ALL=C ';
const WAIT = ' wait "$child" 2>/dev/null || rc=$?\n';
// org-base-reds: the root pair above also spans supervisor start-up and clean_session, a scan of
// every host /proc entry whose cost grows with the host's process count. The timer pair brackets
// only the inner timer: from just before the supervisor starts its sleep to the start of finish().
const SUPERVISOR = '# row253-supervisor-begin\n';
const SUPERVISOR_END = '# row253-supervisor-end\n';
const TIMER = '"$sleep_bin" "$seconds" & timer=$!\n';
const FINISH = 'finish() {\n local rc="$?"\n';
export const P10_OBSERVERS = Object.freeze({
begin: ` # row299-observer-begin-start
@@ -20,6 +27,26 @@ export const P10_OBSERVERS = Object.freeze({
printf 'end %s\\n' "$row299_up" >> "$ROW299_TIMING_FILE"; } 2>/dev/null || :
fi
# row299-observer-end-end
`,
// Supervisor-side pair. Only the P10 peer's arguments carry the peer-credential option name.
// The pattern is split so this block never adds that literal to the supervisor text: the fake
// sudo stalls any command whose arguments contain it, and the supervisor text is an argument of
// every capture. finish() has no arguments of its own, so its stamp follows exactly one begin
// and is written at most once.
timerBegin: `# row299-timer-begin-start
if [ -n "\${ROW299_TIMER_FILE-}" ] && [[ "$*" == *SO_PEER*CRED* ]]; then
{ read -r row299_up row299_spare < /proc/uptime &&
printf 'begin %s\\n' "$row299_up" >> "$ROW299_TIMER_FILE"; } 2>/dev/null || :
fi
# row299-timer-begin-end
`,
timerEnd: ` # row299-timer-end-start
if [ -n "\${ROW299_TIMER_FILE-}" ]; then
{ read -r row299_up row299_spare < /proc/uptime &&
[[ "$(< "$ROW299_TIMER_FILE")" == begin* && "$(< "$ROW299_TIMER_FILE")" != *end* ]] &&
printf 'end %s\\n' "$row299_up" >> "$ROW299_TIMER_FILE"; } 2>/dev/null || :
fi
# row299-timer-end-end
`,
});
@@ -40,7 +67,15 @@ export function instrumentP10Probe(source) {
uniqueIndex(capture, WAIT);
capture = capture.replace(LAUNCH, P10_OBSERVERS.begin + LAUNCH);
capture = capture.replace(WAIT, WAIT + P10_OBSERVERS.end);
return source.slice(0, start) + capture + source.slice(end);
source = source.slice(0, start) + capture + source.slice(end);
const from = uniqueIndex(source, SUPERVISOR), to = uniqueIndex(source, SUPERVISOR_END);
if (to <= from) throw new Error('P10 observer supervisor scope is reversed');
let supervisor = source.slice(from, to);
uniqueIndex(supervisor, TIMER);
uniqueIndex(supervisor, FINISH);
supervisor = supervisor.replace(TIMER, P10_OBSERVERS.timerBegin + TIMER);
supervisor = supervisor.replace(FINISH, FINISH + P10_OBSERVERS.timerEnd);
return source.slice(0, from) + supervisor + source.slice(to);
}
export function removeP10Observers(source) {
@@ -0,0 +1,34 @@
// No real process is signalled. These hostile kernel-boundary controls expose
// a new session reusing the old SID after the original leader disappears.
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {readFileSync} from 'node:fs';
import {spawnSync} from 'node:child_process';
for(const name of ['case1-accept.sh','case2-unit.sh'])for(const mode of ['already-dead','dies-before-signal'])test(`${name}: ${mode} original session never signals a recycled SID`,()=>{
const source=readFileSync(new URL(`../../../scripts/${name}`,import.meta.url),'utf8');
const program=/\/usr\/bin\/python3 -I -S -c '\n([\s\S]*?)\n' "\$child"/.exec(source)?.[1];
assert.ok(program,'the shipped cleanup program exists');
const anchor='\ntry:\n';assert.equal(program.split(anchor).length,2);
const control=`
import atexit, json
from types import SimpleNamespace
attempts = []
checks = 0
atexit.register(lambda: print(json.dumps(attempts)))
def observed(pid):
global checks
if pid == "self/fd/42":
checks += 1
return (b"T", session) if ${mode==='already-dead'?'False':'checks < 3'} else None
return (b"R", session)
def send(fd, sig):
attempts.append([fd, sig])
raise PermissionError("never signal a real task")
fields = observed
os = SimpleNamespace(listdir=lambda path: ["999"], pidfd_open=lambda pid: 17 if pid == session else 99, close=lambda fd: None)
signal = SimpleNamespace(SIGKILL=9, SIGCONT=18, pidfd_send_signal=send)
`;
const r=spawnSync('/usr/bin/python3',['-I','-S','-c',program.replace(anchor,'\n'+control+anchor),'424242','42'],{encoding:'utf8'});
assert.equal(r.status,1,r.stderr);assert.equal(r.stderr,'');
assert.deepEqual(JSON.parse(r.stdout),[],'a stale SID must never authorize a signal');
});
@@ -0,0 +1,85 @@
// Regression controls run the real supervisor. Only the kernel observation is
// fault-injected: a transient empty stat and a delayed first observation of an
// already completed fast command. Neither may authorize an unverified session.
import {test} from 'node:test';
import assert from 'node:assert/strict';
import {readFileSync} from 'node:fs';
import {spawnSync} from 'node:child_process';
const names=['case1-accept.sh','case2-unit.sh'];
function body(name){const s=readFileSync(new URL(`../../../scripts/${name}`,import.meta.url),'utf8');return s.slice(s.indexOf('# row253-supervisor-begin'),s.indexOf('# row253-supervisor-end'));}
function replaceOnce(source,anchor,replacement){assert.equal(source.split(anchor).length,2,'fault anchor must be unique');return source.replace(anchor,replacement);}
function run(source,setsid='/usr/bin/setsid',command=['/bin/true']){
return spawnSync('/bin/bash',['-c',source+'\nexec /bin/bash -c "$TESTER_SUPERVISOR" -- 1 "$@"','--',setsid,'/usr/bin/sleep',...command],{encoding:'utf8',timeout:8000});
}
test('shipped supervisors remain byte-identical',()=>assert.equal(body(names[0]),body(names[1])));
for(const name of names){
test(`${name}: a transient empty stat read retries within the inner budget`,()=>{
const source=replaceOnce(body(name),'stat_fields() {','stat_fields() {\n if [ "${empty_once:-0}" = 0 ]; then empty_once=1; return 1; fi');
const r=run(source);assert.equal(r.status,0,r.stderr);assert.equal(r.stdout,'');
});
test(`${name}: fast success cannot disappear before session verification`,()=>{
// Delay only the parent's first observation. BASE lets /bin/true exit and
// loses its proc entry; a stopped trusted launcher remains verifiable.
const source=replaceOnce(body(name),'stat_fields() {','stat_fields() {\n if [ "${delayed_once:-0}" = 0 ]; then delayed_once=1; "$sleep_bin" 0.08; fi');
const r=run(source);assert.equal(r.status,0,r.stderr);
});
test(`${name}: a launcher that never creates a session is refused`,()=>{
const r=run(body(name),'/bin/false');assert.equal(r.status,85,r.stderr);
});
test(`${name}: a stopped launcher without a new session is never accepted`,()=>{
const r=run(body(name),'/usr/bin/env');assert.equal(r.status,81,r.stderr);
});
test(`${name}: persistently unreadable initial stat retains session refusal 85`,()=>{
const source=replaceOnce(body(name),'stat_fields() {','stat_fields() {\n return 1');
const r=run(source);assert.equal(r.status,85,r.stderr);
});
test(`${name}: command status and streams survive the launcher`,()=>{
const r=run(body(name),'/usr/bin/setsid',['/bin/bash','-c','printf out; printf err >&2; exit 7']);
assert.equal(r.status,87,r.stderr);assert.equal(r.stdout,'out');assert.equal(r.stderr,'err');
});
}
// A snapshot of the parent tree would miss a child reparented to namespace init.
// A new session is a separately documented escape, not a session member.
import {mkdtempSync,rmSync} from 'node:fs';
import {tmpdir} from 'node:os';
import {join} from 'node:path';
import {fileURLToPath} from 'node:url';
for(const name of names)for(const mode of ['reparent','escape','fork','unreadable','unkillable','anchor-death'])test(`${name}: namespace cleanup ${mode}`,t=>{
const dir=mkdtempSync(join(tmpdir(),'sc-'));t.after(()=>rmSync(dir,{recursive:true,force:true}));
const r=spawnSync('/usr/bin/unshare',['--user','--map-root-user','--pid','--fork','--kill-child=KILL','--mount-proc',process.execPath,fileURLToPath(new URL('./case-supervisor-session-fixture.mjs',import.meta.url)),fileURLToPath(new URL(`../../../scripts/${name}`,import.meta.url)),dir,mode],{encoding:'utf8',timeout:12000,killSignal:'SIGKILL'});
assert.equal(r.status,0,r.stderr);assert.equal(r.stderr,'');
const result=JSON.parse(r.stdout);assert.equal(result.error,undefined);assert.equal(result.signal,null);
assert.equal(result.status,['unreadable','unkillable','anchor-death'].includes(mode)?86:81,JSON.stringify(result));
assert.equal(result.stdout,'');
if(mode==='anchor-death')assert.match(result.stderr,/Killed/);else assert.equal(result.stderr,'');
if(!['unreadable','unkillable','anchor-death'].includes(mode))assert.deepEqual(result.sessionLive,[],'no unrecorded live session member may survive');
if(mode==='fork'){assert.ok(result.pairs.length>10);assert.deepEqual(result.live,[],'fast forks must not survive confirmation scans');return;}
assert.equal(result.pairs.length,2);
const [leader,descendant]=result.pairs;
assert.notEqual(leader[0],leader[1]);
assert.equal(result.anchor,leader[1],'the trusted command parent reserves the SID');
if(mode==='escape'){
assert.equal(descendant[0],descendant[1]);assert.notEqual(descendant[1],leader[1]);
assert.deepEqual(result.live,[descendant[0]],'recorded gap: a new session survives; namespace fixture removes it');
}else{
assert.equal(descendant[1],leader[1]);
if(mode==='anchor-death'){assert.ok(result.live.includes(leader[0]),'lost anchor refuses before signalling a bare SID');}
else if(mode==='reparent'){assert.equal(result.parent,1,'the intermediate parent exited before cleanup');assert.deepEqual(result.live,[],'reparenting must not hide a session member');}
else assert.ok(result.live.includes(descendant[0]),'the refusal control leaves a live member before fixture cleanup');
}
});
// Unlike the private-inventory timing test, this sees the real host /proc.
// Restoring the old per-entry bash read/parse scan exceeds this bound on a
// representative host. It uses cleanup-time's stamps, excluding the inner wait.
import {writeFileSync} from 'node:fs';
import {instrumentCleanupTimer,readProbeSeconds} from './case-inner-timing-fixture.mjs';
for(const name of names)test(`${name}: host inventory cleanup stays within 0.5 seconds`,t=>{
const dir=mkdtempSync(join(tmpdir(),'hc-'));t.after(()=>rmSync(dir,{recursive:true,force:true}));
const clock=join(dir,'clock');writeFileSync(clock,'');
const r=run(instrumentCleanupTimer(body(name)+'# row253-supervisor-end\n',clock),'/usr/bin/setsid',['/bin/bash','-c','/usr/bin/sleep 30 & wait']);
assert.equal(r.status,81,r.stderr);assert.equal(r.stderr,'');assert.equal(r.stdout,'');
const seconds=readProbeSeconds(readFileSync(clock,'utf8'));t.diagnostic(`${name}: host cleanup ${seconds}s`);
assert.ok(seconds<0.5,`host cleanup ${seconds}s must be < 0.5s`);
});
@@ -0,0 +1,58 @@
// Namespace-local evidence and cleanup. The JSON observation precedes teardown.
import {readFileSync,writeFileSync,readdirSync,openSync,closeSync} from 'node:fs';
import {spawnSync} from 'node:child_process';
const [script,dir,mode]=process.argv.slice(2),records=dir+'/pids',worker=dir+'/worker.mjs';
const source=readFileSync(script,'utf8');
let body=source.slice(source.indexOf('# row253-supervisor-begin'),source.indexOf('# row253-supervisor-end'));
// Adversarial kernel boundary controls keep the real cleanup loop and budget.
if(mode==='unreadable')body=body.replace('def fields(pid):','def fields(pid):\n raise PermissionError("injected unreadable stat")');
if(mode==='unkillable')body=body.replace('signal.pidfd_send_signal(fd, signal.SIGKILL)','None # injected successful kill with a surviving member');
if(mode==='fork')writeFileSync(worker,`
import {spawn} from 'node:child_process';
import {appendFileSync,readFileSync} from 'node:fs';
const record=${JSON.stringify(records)};
const sid=p=>Number(readFileSync('/proc/'+p+'/stat','utf8').split(') ').at(-1).split(' ')[3]);
appendFileSync(record,JSON.stringify([process.pid,sid(process.pid)])+'\\n');
setInterval(()=>{
const child=spawn('/usr/bin/sleep',['30'],{stdio:'ignore'});
appendFileSync(record,JSON.stringify([child.pid,sid(child.pid)])+'\\n');
},4);
`);
else writeFileSync(worker,`
import {spawn} from 'node:child_process';
import {readFileSync,writeFileSync} from 'node:fs';
const record=${JSON.stringify(records)};
const child=spawn('/bin/bash',['-c',${JSON.stringify(mode==='escape'?'exec /usr/bin/setsid /usr/bin/sleep 30':'/usr/bin/sleep 30 & echo $! > "$1"')},'--',record+'.child'],{stdio:'ignore'});
setTimeout(()=>{
const pid=${mode==='escape'?'child.pid':"Number(readFileSync(record+'.child','utf8').trim())"};
const sid=p=>Number(readFileSync('/proc/'+p+'/stat','utf8').split(') ').at(-1).split(' ')[3]);
writeFileSync(record,JSON.stringify([[process.pid,sid(process.pid)],[pid,sid(pid)]]));
writeFileSync(record+'.anchor',String(process.ppid));
writeFileSync(record+'.parent',readFileSync('/proc/'+pid+'/stat','utf8').split(') ').at(-1).split(' ')[1]);
if(${JSON.stringify(mode)} === "anchor-death")process.kill(process.ppid,"SIGKILL");
},100);
setInterval(()=>{},1000);
`);
const out=openSync(dir+'/out','w'),err=openSync(dir+'/err','w');
const result=spawnSync('/bin/bash',['-c',body+'\nexec /bin/bash -c "$TESTER_SUPERVISOR" -- 1 /usr/bin/setsid /usr/bin/sleep "$@"','--','/bin/bash','-c','exec "$@" >/dev/null 2>&1','--',process.execPath,worker],{encoding:'utf8',stdio:['ignore',out,err],timeout:8000,killSignal:'SIGKILL'});
closeSync(out);closeSync(err);
result.stdout=readFileSync(dir+'/out','utf8');result.stderr=readFileSync(dir+'/err','utf8');
let pairs=[];
try{const text=readFileSync(records,'utf8');pairs=mode==='fork'?text.trim().split('\n').map(JSON.parse):JSON.parse(text);}catch{}
const live=[];
for(const [pid] of pairs){try{const state=readFileSync('/proc/'+pid+'/stat','utf8').split(') ').at(-1).split(' ')[0];if(!['Z','X'].includes(state))live.push(pid);}catch{}}
// Inventory the entire private namespace too: a fork between spawn and its
// record write must not escape the assertion just because it was unrecorded.
const sessionLive=[];
for(const pid of readdirSync('/proc').filter(p=>/^[0-9]+$/.test(p))){
try{
const words=readFileSync('/proc/'+pid+'/stat','utf8').split(') ').at(-1).split(' ');
if(Number(words[3])===pairs[0]?.[1]&&!['Z','X'].includes(words[0]))sessionLive.push(Number(pid));
}catch{}
}
let parent,anchor;
try{anchor=Number(readFileSync(records+".anchor","utf8"));}catch{}
try{parent=Number(readFileSync(records+'.parent','utf8'));}catch{}
process.stdout.write(JSON.stringify({sessionLive,parent,anchor,status:result.status,signal:result.signal,error:result.error?.message,stdout:result.stdout,stderr:result.stderr,pairs,live}));
// These PIDs exist only in the private namespace. Never signal them on the host.
for(const [pid] of pairs)try{process.kill(pid,'SIGKILL');}catch{}
+60 -11
View File
@@ -13,6 +13,7 @@ import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { tmp } from "./helpers.mjs";
import { instrumentP10Probe, removeP10Observers, readProbeSeconds, P10_OBSERVERS } from "./case-p10-timing-fixture.mjs";
import { instrumentInnerTimer } from "./case-inner-timing-fixture.mjs";
import { CASE_SUDO_SESSION, recordedLivePids, killRecordedPids, fixtureGuard, cleanupFixture, pollFixtureGone, blockingQuerySource, LEGACY_CASES } from "./case-sudo-fixture.mjs";
const REPO = fileURLToPath(new URL("../../../", import.meta.url));
@@ -378,6 +379,9 @@ test('row299 P10 timing copy preserves shipped bytes and isolates observer varia
assert.deepEqual([...block.matchAll(/\b([A-Za-z_][A-Za-z0-9_]*)=/g)],[],'no other assignments in observer blocks');
assert.doesNotMatch(block,/\$ROOT|\$\{ROOT/,'fixture root cannot supply the clock');
}
// The fake sudo keys its faults on these argument markers; observers must not add or remove any.
for(const marker of['SO_PEERCRED','MOSAIC_TESTER_SOCKET_V1','MOSAIC_LEGACY_STATUS'])
assert.equal(copy.split(marker).length,source.split(marker).length,`observers keep the ${marker} count`);
const changedTimer=source.replace('tester_capture 2 4 128','tester_capture 1 4 128');
assert.equal(removeP10Observers(instrumentP10Probe(changedTimer)),changedTimer,'copy keeps current timer mutants');
});
@@ -402,6 +406,34 @@ capture`;
assert.match(read(clock),/^begin [0-9]+\.[0-9]{2}\nend [0-9]+\.[0-9]{2}\n$/);
});
test('row299 P10 timer observers keep supervisor status and record one P10 pair',t=>{
const dir=tmp(t,'p10-timer-'),clock=join(dir,'clock'),trapFile=join(dir,'trap');
put(clock,'',0o600);
// Same shape as TESTER_SUPERVISOR: positional arguments are the setsid command, finish() has none.
const program=`set +m
finish() {
local rc="$?"
${P10_OBSERVERS.timerEnd} printf trap > "$ROW299_TRAP_FILE"
exit "$rc"
}
trap finish EXIT
${P10_OBSERVERS.timerBegin}printf stdout-without-newline
printf stderr-without-newline >&2
exit 81`;
const supervise=(...args)=>spawnSync('/bin/bash',['-c',program,'--',...args],{encoding:'utf8',env:{...process.env,ROW299_TIMER_FILE:clock,ROW299_TRAP_FILE:trapFile}});
let r=supervise('node','-e','query');
assert.equal(r.status,81);assert.equal(read(clock),'','a supervisor without the P10 peer records nothing');
r=supervise('python','-c','getsockopt(SO_PEERCRED)');
assert.equal(r.status,81);
assert.equal(r.stdout,'stdout-without-newline');
assert.equal(r.stderr,'stderr-without-newline');
assert.equal(read(trapFile),'trap');
assert.match(read(clock),/^begin [0-9]+\.[0-9]{2}\nend [0-9]+\.[0-9]{2}\n$/);
const pair=read(clock);
r=supervise('node','-e','later');
assert.equal(r.status,81);assert.equal(read(clock),pair,'a later supervisor cannot add a second end');
});
test('row299 P10 observer records are complete and ordered',()=>{
assert.equal(readProbeSeconds('begin 100.01\nend 102.03\n'),2.02);
const invalid={
@@ -433,23 +465,33 @@ for(const fault of ['inner','outer','overflow'])test(`row253 C4 P10 ${fault} rea
if(fault==='inner')put(join(h.hc,'peer-hang'),'');
if(fault==='outer'){put(join(h.hc,'stall-probe'),'');put(join(h.hc,'stall-output'),'200 997 997\n');}
if(fault==='overflow'){put(join(h.hc,'peer-output'),'200 997 997\n'+'X'.repeat(256));put(join(h.hc,'peer-status'),'0');}
let observedScript=SCRIPT,timingFile;
let observedScript=SCRIPT,timingFile,timerFile;
if(fault==='inner'||fault==='outer'){
observedScript=join(h.dir,'case1-p10-observed.sh');timingFile=join(h.hc,'probe-uptime');
put(timingFile,'',0o600);put(observedScript,instrumentP10Probe(read(SCRIPT)),0o700);
// The outer fault stalls before the supervisor starts, so only the inner fault has a timer pair.
if(fault==='inner'){timerFile=join(h.hc,'timer-uptime');put(timerFile,'',0o600);}
put(join(h.dir,'selector.sh'),read(join(REPO,'scripts','selector.sh')),0o644);
assert.equal(removeP10Observers(read(observedScript)),read(SCRIPT),'observer reversal equals a fresh shipped script read');
}
const stop=fixtureGuard(t,files,7000,timingFile);
const start=performance.now(),r=run(h,timingFile?{ROW299_TIMING_FILE:timingFile}:{},undefined,observedScript),seconds=(performance.now()-start)/1000;capture();
const observe={...(timingFile?{ROW299_TIMING_FILE:timingFile}:{}),...(timerFile?{ROW299_TIMER_FILE:timerFile}:{})};
const start=performance.now(),r=run(h,observe,undefined,observedScript),seconds=(performance.now()-start)/1000;capture();
assert.equal(r.status,0,r.out+r.err);
const reason=fault==='inner'?'inner-timeout':fault==='outer'?'outer-timeout':'output-limit';
assert.match(r.out,new RegExp('P10 tester-socket-squat unnamed boundary=failed reason=probe-'+reason+' '));
assert.doesNotMatch(r.out,/boundary=observed/);
let probeSeconds=seconds;
let probeSeconds=seconds,timerSeconds;
if(timingFile)assert.doesNotThrow(()=>{probeSeconds=readProbeSeconds(read(timingFile));},'P10 observer pair is required');
if(fault==='inner'||fault==='outer')t.diagnostic(JSON.stringify({fault,mode,baselineSeconds,faultSeconds:seconds,probeSeconds}));
if(fault==='inner')assert.ok(probeSeconds>=1.25&&probeSeconds<2.75,`${mode}: direct probe ${probeSeconds}s; baseline ${baselineSeconds}s is diagnostic only`);
if(timerFile)assert.doesNotThrow(()=>{timerSeconds=readProbeSeconds(read(timerFile));},'P10 timer observer pair is required');
if(fault==='inner'||fault==='outer')t.diagnostic(JSON.stringify({fault,mode,baselineSeconds,faultSeconds:seconds,probeSeconds,timerSeconds}));
// org-base-reds: the band applies to the inner timer itself. The root pair adds supervisor start-up and
// clean_session's whole-host /proc scan, which grows with the host's process count, so it must contain
// the timer pair but has no band of its own; probe-inner-timeout above already places it before the outer bound.
if(fault==='inner'){
assert.ok(timerSeconds>=1.25&&timerSeconds<2.75,`${mode}: inner timer ${timerSeconds}s (root probe ${probeSeconds}s); baseline ${baselineSeconds}s is diagnostic only`);
assert.ok(probeSeconds>=timerSeconds,`${mode}: root probe ${probeSeconds}s must contain the inner timer ${timerSeconds}s`);
}
if(fault==='outer'){
assert.ok(probeSeconds>=3.25&&probeSeconds<4.75,`${mode}: direct probe ${probeSeconds}s; baseline ${baselineSeconds}s is diagnostic only`);
if(mode==='hop')assert.ok(recordedLivePids(files[0]).length>0,'stalled supervisor is R3 residual');
@@ -896,7 +938,7 @@ test("row 195 accept: every in-instance call goes through sudo -u mosaic -H env
// mos-1326: assert every argv element, not flattened/substr command detection.
const source=read(SCRIPT);
const supervisor=source.split("<<'MOSAIC_TESTER_SUPERVISOR'")[1].split('\n').slice(1).join('\n').split('\nMOSAIC_TESTER_SUPERVISOR')[0]+'\n';
assert.equal(createHash('sha256').update(supervisor).digest('hex'),'34063098d7edb9067c11456165dc7535bc0e779d053c43599884d6b614528639');
assert.equal(createHash('sha256').update(supervisor).digest('hex'),'52509773fa2bb0701402b8d2454816153e18323cfaa75cb72418b46c4dbb2d34');
const runner=/LEGACY_COMMAND_RUNNER='([\s\S]*?)'\nlegacy_display_rc/.exec(source)[1];
const f7=/F7_JS='([\s\S]*?)'\ncheck_f7/.exec(source)[1];
const raw=read(join(h.hc,'sudo-argv.jsonl')).trim().split('\n').map(line=>JSON.parse(line));
@@ -927,8 +969,8 @@ test("row 195 accept: the host check runs with --check and never --enforce", (t)
// Row276 exercises the real legacy launcher with fixture-only commands, never sudo.
function legacyBody() {
const source=read(SCRIPT),start=source.indexOf('# row253-supervisor-begin'),end=source.indexOf('# row276-legacy-end');
function legacyBody(source=read(SCRIPT)) {
const start=source.indexOf('# row253-supervisor-begin'),end=source.indexOf('# row276-legacy-end');
assert.ok(start>=0&&end>start,'legacy supervisor block exists');
return source.slice(start,end);
}
@@ -950,11 +992,18 @@ for(const {tag} of LEGACY_CASES)test(`row276 ${tag} legacy inner timeout cleans
const h=host(t),records=join(h.hc,'legacy-pids'),files=[join(h.hc,'session-pids'),records];
put(join(h.hc,'session-mode'),mode);
const file=join(h.dir,'blocked.mjs');put(file,blockingQuerySource(records,'signals')+'\nloadSystem();');
const capture=cleanupFixture(t,files),stop=fixtureGuard(t,files,4500);
const start=performance.now(),r=legacyCall(h,tag,[NODE,file]);capture();
const clock=join(h.dir,'inner-clock');put(clock,'',0o600);
const body=legacyBody(instrumentInnerTimer(read(SCRIPT),clock));
const capture=cleanupFixture(t,files),stop=fixtureGuard(t,files,4500,clock);
// The outer watchdog includes startup and cleanup; only the supervisor pair
// measures the inner budget. Seven seconds also lets the 4.5s guard expose a missing timer.
const start=performance.now(),r=legacyCall(h,tag,[NODE,file],{outer:7,body});capture();
assert.equal(r.status,81,r.stdout+r.stderr);
assert.equal(r.stderr,`case1-accept: ${tag} inner-timeout\n`);
assert.ok((performance.now()-start)/1000<2,mode);
const seconds=readProbeSeconds(read(clock)),wall=(performance.now()-start)/1000;
assert.ok(seconds>=0.25&&seconds<1.25,`${tag}/${mode}: inner timer ${seconds}s (wall ${wall}s)`);
assert.ok(wall>=seconds,`${mode}: the call contains its timer interval`);
t.diagnostic(`${tag}/${mode}: inner ${seconds}s; wall ${wall}s`);
assert.deepEqual(await pollFixtureGone(files),[],mode);await stop();
}
});
+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"`);
+53 -22
View File
@@ -14,6 +14,7 @@ import { fileURLToPath } from "node:url";
import { businessDoc, systemConfig, writeJson } from "../../business/tests/helpers.mjs";
import { binds, prepareRun, sandboxArgv, sandboxProblem } from "../../seat/src/sandbox.mjs";
import { tmp } from "./helpers.mjs";
import { instrumentInnerTimer, readProbeSeconds } from "./case-inner-timing-fixture.mjs";
import { CASE_SUDO_SESSION, recordedLivePids, killRecordedPids, fixtureGuard, cleanupFixture, pollFixtureGone, blockingQuerySource } from "./case-sudo-fixture.mjs";
const REPO = fileURLToPath(new URL("../../../", import.meta.url));
@@ -107,13 +108,13 @@ const libraryFlags = (h) => ["--port", "443", "--business", "acme", "--public-ho
"--allow-user", "melanie", "--proof-header", "X-Proxy-Proof", "--proof-file", h.proof];
const factoryFlags = () => ["--port", "443", "--business", "acme", "--tester-socket"];
function unit(h, { profile = "library", node = USR_NODE, copy = COPY_A, extra = [], flags, env = {}, outer = ["--business", "acme"], argv } = {}) {
function unit(h, { profile = "library", node = USR_NODE, copy = COPY_A, extra = [], flags, env = {}, outer = ["--business", "acme"], argv, script: unitScript = UNIT } = {}) {
const serve = flags ?? (profile === "factory" ? factoryFlags() : libraryFlags(h));
const args = argv ?? ["--user", "mosaic", "--home", h.home, ...outer, "--copy", copy, "--node", node, "--profile", profile, ...extra, "--", ...serve];
const r = spawnSync(UNIT, args, {
const r = spawnSync(unitScript, args, {
encoding: "utf8",
timeout: 30000,
env: { PATH: `${h.bin}:${process.env.PATH}`, CASE2_UNIT_DIR: h.units, CASE2_SYSTEMCTL: join(h.bin, "systemctl"), CASE2_GETENT: join(h.bin, "getent"), ...env },
env: { HOME: process.env.HOME, TMPDIR: process.env.TMPDIR, LANG: process.env.LANG, PATH: `${h.bin}:${process.env.PATH}`, CASE2_UNIT_DIR: h.units, CASE2_SYSTEMCTL: join(h.bin, "systemctl"), CASE2_GETENT: join(h.bin, "getent"), ...env },
});
return { code: r.status, out: r.stdout, err: r.stderr };
}
@@ -197,12 +198,27 @@ for(const kind of ['sleep','signals','member']) test(`row253 C4 ${kind} query ha
const member=join(h.root,'q ) '+String.fromCharCode(10)+'mem'),proof=join(h.root,'comm-proof');
if(kind==='member')symlinkSync('/usr/bin/sleep',member);
writeFileSync(join(copyDir(h),'packages/cli/src/config.mjs'),blockingQuerySource(queries,kind,member,proof));
const capture=cleanupFixture(t,files);const stopGuard=fixtureGuard(t,files);
const start=performance.now(),r=unit(h,{profile:'factory'}),seconds=(performance.now()-start)/1000;capture();
const clock=join(h.root,'inner-clock'),observed=join(h.root,'observed/scripts/case2-unit.sh');
mkdirSync(dirname(observed),{recursive:true});
symlinkSync(join(REPO,'scripts/selector.sh'),join(dirname(observed),'selector.sh'));
writeFileSync(clock,'',{mode:0o600});
// The 7s outer watchdog spans startup and cleanup too. Give the private copy
// cleanup room while retaining a separate band on the unchanged 5s inner timer.
const source=read(UNIT),call='query="$(tester_capture 5 7 4096';
assert.equal(source.split(call).length,2,'tester query budget anchor is unique');
script(observed,instrumentInnerTimer(source.replace(call,call.replace(' 5 7 ',' 5 15 ')),clock));
const capture=cleanupFixture(t,files);const stopGuard=fixtureGuard(t,files,9500,clock);
const start=performance.now(),r=unit(h,{profile:'factory',script:observed,env:{
CASE2_BUS_TEMPLATE:BUS_TEMPLATE,CASE2_WEBUI_TEMPLATE:WEBUI_TEMPLATE,
CASE2_PROXY_TEMPLATE:join(REPO,'packages/cli/systemd/mosaic-proxy.service.in'),
}}),wall=(performance.now()-start)/1000;capture();
refuses(r,/^case2-unit: tester config query inner-timeout\n$/);
const seconds=readProbeSeconds(read(clock));
const [queryPid,querySid]=read(queries).trim().split('\n')[0].trim().split(/\s+/).map(Number);
assert.equal(queryPid,querySid,`${kind}/${mode}: direct query PID must equal its kernel SID`);
assert.ok(seconds<7,`${kind}/${mode}: ${seconds}s exceeds outer bound`);
assert.notEqual(queryPid,querySid,`${kind}/${mode}: query runs below the trusted session leader`);
assert.ok(seconds>=4.25&&seconds<5.75,`${kind}/${mode}: inner timer ${seconds}s (wall ${wall}s)`);
assert.ok(wall>=seconds,`${mode}: the call contains its timer interval`);
t.diagnostic(`${kind}/${mode}: inner ${seconds}s; wall ${wall}s`);
assert.deepEqual(await pollFixtureGone(files),[],mode);
if(kind==='member')assert.ok(read(proof).includes('('+member.split('/').at(-1)+') '),'member comm with newline and closing parenthesis was observed');
assert.equal(existsSync(h.log),false,mode);await stopGuard();
@@ -240,24 +256,39 @@ test('row253 C4 inline supervisor is byte-identical in both host scripts',()=>{
});
test('row253 C4 cleanup budget starts after inventory pass one',()=>{
// A deterministic clock models a slow inventory without host-load timing.
// All process reads and signals are stand-ins; no real PID is signalled.
// Run the shipped cleanup with a deterministic slow inventory/clock. Every
// kernel operation is replaced before execution; no real PID is signalled.
const source=read(UNIT);
const body=source.slice(source.indexOf('stat_fields() {'),source.indexOf('finish() {'));
const program=/\/usr\/bin\/python3 -I -S -c '\n([\s\S]*?)\n' "\$child"/.exec(source)?.[1];
assert.ok(program,'fixed cleanup program exists');
const anchor='\ntry:\n';
assert.equal(program.split(anchor).length,2,'cleanup loop anchor is unique');
const standins=`
child=424242; sleep_bin=:; seen=0; clocks=0; kills=0
stat_fields() {
seen=$((seen+1)); ST_STATE=R; ST_SESSION=0
[ "$seen" != 1 ] || ST_SESSION="$child"
return 0
}
clock_cs() { clocks=$((clocks+1)); CLOCK_CS=$((clocks*1000)); }
kill() { kills=$((kills+1)); return 0; }
clean_session; rc=$?
printf '%s' "$kills"
exit "$rc"
from types import SimpleNamespace
alive = True
kills = 0
clock = time.monotonic()
def inventory(path):
global clock
clock += 1000
return ["999"]
def fake_fields(pid):
return (b"T", session) if pid == "self/fd/42" else ((b"R", session) if alive else None)
def send(fd, sig):
global alive, kills
if sig == 9:
alive = False
kills += 1
fields = fake_fields
os = SimpleNamespace(listdir=inventory, pidfd_open=lambda pid: 17, close=lambda fd: None)
signal = SimpleNamespace(SIGKILL=9, SIGCONT=18, pidfd_send_signal=send)
time = SimpleNamespace(monotonic=lambda: clock, sleep=lambda seconds: None)
`;
const r=spawnSync('/bin/bash',['-c',body+standins],{encoding:'utf8'});
const r=spawnSync('/usr/bin/python3',['-I','-S','-c',program.replace(anchor,standins+'\n'+anchor)+'\nprint(kills,end="")','424242','42'],{encoding:'utf8'});
assert.equal(r.status,0,r.stderr);assert.equal(r.stdout,'1');
});
+366
View File
@@ -0,0 +1,366 @@
// E2 (#1873, P1-3, org ruling 6): the WebUI's task board, hire form and autonomy panel from a
// terminal. Each test sends the CLI verb and the page's own request for the same input, and checks
// that both reach the same bus verb with the same arguments and get the same answer, a refusal
// included. Hire and autonomy run the real scripts/mosaic against an in-process dev-owner Console
// whose organization transports are fakes, so no bus host starts; tasks run against a real broker
// bound to the person, as the human transport binds it.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawn, spawnSync } from 'node:child_process';
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { request } from 'node:http';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { reserveOrg, activateOrg } from '../../organization/src/org-registry.mjs';
import { processState } from '../../seat/src/process-state.mjs';
import { startServer } from '../../webui/src/serve.mjs';
import { createDevOwner } from '../../webui/src/dev-owner.mjs';
import { BusReadError } from '../../webui/src/bus.mjs';
import { parseTaskWrite } from '../../webui/src/workspace-tasks.mjs';
import { close } from '../../webui/tests/fixture.mjs';
import { main } from '../src/cli.mjs';
import { CliError } from '../src/errors.mjs';
import { orgCommand } from '../src/org.mjs';
import { broker, humanEnv, io } from './helpers.mjs';
const REPO = new URL('../../../', import.meta.url).pathname;
const MOSAIC = join(REPO, 'scripts/mosaic');
const FORBIDDEN = [7332, 7340, 7341, 33223, 17450, 17443, 44307, 7350];
const SUBJECT = { provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner' };
const SHA = 'a'.repeat(64);
async function instance(t) {
const home = mkdtempSync(join(tmpdir(), 'e2-'));
const env = humanEnv({ HOME: home, MOSAIC_CONFIG: join(home, '.config/mosaic-e2test/config.json') });
const setup = spawnSync(join(REPO, 'scripts/bootstrap.sh'), ['--instance', 'e2test'], { env, encoding: 'utf8' });
assert.equal(setup.status, 0, setup.stderr);
const ctx = { configDir: join(home, '.config/mosaic-e2test'), dataRoot: join(home, '.mosaic-e2test'), instance: 'e2test', env };
await reserveOrg(ctx, 'alpha'); await activateOrg(ctx, 'alpha');
t.after(() => rmSync(home, { recursive: true, force: true }));
return { ctx, env };
}
function mosaic(env, args, timeout = 60000) {
return new Promise((resolve, reject) => {
const child = spawn(MOSAIC, args, { env, stdio: ['ignore', 'pipe', 'pipe'] });
let stdout = '', stderr = '';
child.stdout.setEncoding('utf8').on('data', c => { stdout += c; });
child.stderr.setEncoding('utf8').on('data', c => { stderr += c; });
const timer = setTimeout(() => child.kill('SIGKILL'), timeout);
child.on('error', reject);
child.on('close', status => { clearTimeout(timer); resolve({ status, stdout, stderr }); });
});
}
function recordConsole(ctx, url) {
const dir = join(ctx.dataRoot, 'console');
mkdirSync(dir, { recursive: true, mode: 0o700 }); chmodSync(dir, 0o700);
const file = join(dir, 'console.json');
writeFileSync(file, JSON.stringify({ pid: process.pid, startTime: processState(process.pid).startTime, url }), { mode: 0o600 }); chmodSync(file, 0o600);
}
// The page's own request: a same-origin fetch from the Console's page.
function page(port, method, path, body) {
return new Promise((resolve, reject) => {
const payload = body === undefined ? undefined : JSON.stringify(body);
const headers = { host: `127.0.0.1:${port}`, ...(payload === undefined ? {} : { origin: `http://127.0.0.1:${port}`, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload) }) };
request({ host: '127.0.0.1', port, method, path, headers }, res => {
let text = ''; res.setEncoding('utf8'); res.on('data', c => { text += c; });
res.on('end', () => resolve({ status: res.statusCode, body: JSON.parse(text) }));
}).on('error', reject).end(payload);
});
}
// The bus's revision is a 64-hex digest; REV(n) stands in for the nth.
const REV = n => n.toString(16).padStart(64, '0');
const LOOP = { 'cross-role': 'in the loop', merge: 'in the loop', policy: 'in the loop', authority: 'in the loop' };
// One organization, alpha, whose bus transports record each call. `send` false is a Console
// started without --send.
function organizations({ send = true } = {}) {
const calls = [];
let state = { revision: REV(3), levels: { ...LOOP }, autonomy: { notices: false } };
const org = {
busHealthy: true,
send: send ? async () => ({}) : null,
decisionWrite: async (verb, args, opts) => {
calls.push({ verb, args, opts });
if (verb === 'hire.templates.console') return { templates: [{ name: 'worker', sha256: SHA }] };
if (args.name === 'blocked') throw new BusReadError('definition-not-hireable');
return { id: `hire-${calls.length}`, op: 'resolved', choice: 'yes' };
},
requestRead: async (verb, args, opts) => { calls.push({ verb, args, opts }); return structuredClone(state); },
autonomyWrite: async (verb, args, opts) => {
calls.push({ verb, args, opts });
if (verb === 'autonomy.review.console') return { reviewed: args.decisionClass };
if (args.revision !== state.revision) throw new BusReadError('revision-conflict');
const { notices, reverseWindowHours, authorityResolver, ...levels } = args.autonomy;
state = { revision: REV(parseInt(state.revision, 16) + 1), levels, autonomy: { notices, reverseWindowHours, ...(authorityResolver ? { authorityResolver } : {}) } };
return { revision: state.revision, transaction: `tx-${parseInt(state.revision, 16)}` };
},
};
return { calls, organizations: { list: async () => [{ id: 'alpha' }], resolve: async id => (id === 'alpha' ? org : null) } };
}
async function consoleFor(t, ctx, options) {
const f = organizations(options);
const server = await startServer({ port: 0, board: 'http://127.0.0.1:9', sso: createDevOwner(), send: async () => ({}), organizations: f.organizations, log: () => {} });
t.after(() => close(server));
const port = server.address().port;
assert.ok(!FORBIDDEN.includes(port));
recordConsole(ctx, `http://127.0.0.1:${port}/`);
return { ...f, port, server };
}
const HIRE = ['--name', 'ada', '--template', 'worker', '--harness', 'pi', '--model', 'glm', '--reason', 'more hands'];
const GUARDED = [
['org', 'role-templates', 'alpha'],
['org', 'hire', 'alpha', ...HIRE],
['org', 'change', 'alpha', ...HIRE],
['org', 'fire', 'alpha', '--name', 'ada', '--reason', 'done'],
['org', 'autonomy', 'alpha'],
['org', 'autonomy', 'alpha', 'set', '--revision', REV(3), '--merge', 'near'],
['org', 'autonomy', 'alpha', 'review', 'merge'],
];
test('hire and autonomy verbs: usage errors first (exit 4), then refused inside an agent run before any config is read', async () => {
// The config path is not an instance config, so a read would refuse with another message.
const env = { ...humanEnv({ MOSAIC_CONFIG: '/nonexistent/e2/config.json' }), CLAUDECODE: '1' };
for (const args of GUARDED) {
const r = await mosaic(env, args);
assert.equal(r.status, 3, `${args.join(' ')}: ${r.stderr}`);
assert.match(r.stderr, /outside any agent run \(found CLAUDECODE/, args.join(' '));
assert.equal(r.stdout, '');
}
for (const args of [
['org', 'hire', 'alpha', '--name', 'ada'],
['org', 'fire', 'alpha', '--name', 'ada', '--reason', 'done', '--model', 'glm'],
['org', 'hire', 'alpha', ...HIRE, '--level', 'project'],
['org', 'autonomy', 'alpha', 'set', '--merge', 'near'],
['org', 'autonomy', 'alpha', 'set', '--revision', REV(3), '--merge', 'sideways'],
['org', 'autonomy', 'alpha', 'set', '--revision', '3', '--merge', 'near'],
['org', 'autonomy', 'alpha', 'set', '--revision', REV(3), '--reverse-window-hours', '200'],
['org', 'autonomy', 'alpha', 'review', 'everything'],
['org', 'role-templates', 'Not An Id'],
]) {
const r = await mosaic(env, args);
assert.equal(r.status, 4, `${args.join(' ')}: ${r.stderr}`);
assert.doesNotMatch(r.stderr, /agent run/);
}
// This process's own environment counts too, as for org archive|delete: the marker is only in
// process.env here, never in io.env.
const saved = process.env.CLAUDECODE;
process.env.CLAUDECODE = '1';
try {
for (const args of GUARDED) {
const x = io(humanEnv({ MOSAIC_CONFIG: '/nonexistent/e2/config.json' }));
delete x.env.CLAUDECODE;
await assert.rejects(orgCommand(args.slice(1), x, {}), e => e instanceof CliError && e.exitCode === 3 && /outside any agent run/.test(e.message), args.join(' '));
}
} finally { if (saved === undefined) delete process.env.CLAUDECODE; else process.env.CLAUDECODE = saved; }
// The default instance is a one-line refusal (exit 3), not an uncaught error.
const d = io(humanEnv());
delete d.env.MOSAIC_CONFIG;
await assert.rejects(orgCommand(['autonomy', 'alpha'], d, {}), e => e instanceof CliError && e.exitCode === 3 && /requires a named instance.*--dev-local-owner/.test(e.message));
});
test('role-templates, hire, change and fire send the hire form\'s request and get its answer, refusals included', { timeout: 120000 }, async t => {
const { ctx, env } = await instance(t);
const c = await consoleFor(t, ctx);
const opts = { subject: SUBJECT, business: 'alpha' };
let r = await mosaic(env, ['org', 'role-templates', 'alpha', '--json']);
assert.equal(r.status, 0, r.stderr);
const pageTemplates = await page(c.port, 'POST', '/api/orgs/alpha/decisions/hire/templates', { business: 'alpha' });
assert.deepEqual(JSON.parse(r.stdout), pageTemplates.body);
assert.deepEqual(c.calls.splice(0), [{ verb: 'hire.templates.console', args: {}, opts }, { verb: 'hire.templates.console', args: {}, opts }]);
r = await mosaic(env, ['org', 'role-templates', 'alpha']);
assert.equal(r.stdout, `worker ${SHA.slice(0, 12)}\n`);
c.calls.splice(0);
// The form's body for this input (bus.js hireSubmit): the template it read, by name and sha256.
const form = (op, name, extra = {}) => ({ op, level: 'org', name, reason: 'more hands', ...extra, ...(op === 'fire' ? {} : { role: { template: 'worker', sha256: SHA }, route: { harness: 'pi', model: 'glm' } }) });
for (const op of ['hire', 'change']) {
r = await mosaic(env, ['org', op, 'alpha', ...HIRE, '--json']);
assert.equal(r.status, 0, r.stderr);
const cli = c.calls.splice(0);
assert.deepEqual(cli.map(x => x.verb), ['hire.templates.console', 'hire.start.console']);
const pageAnswer = await page(c.port, 'POST', '/api/orgs/alpha/decisions/hire/start', { business: 'alpha', hire: form(op, 'ada') });
const pageCall = c.calls.splice(0);
assert.deepEqual(cli[1], pageCall[0]);
assert.deepEqual(cli[1].args, form(op, 'ada'));
assert.equal(pageAnswer.status, 200);
// Each decision gets its own id; the rest of the answer is the page's.
assert.deepEqual({ ...JSON.parse(r.stdout), id: 'x' }, { ...pageAnswer.body, id: 'x' });
}
r = await mosaic(env, ['org', 'fire', 'alpha', '--name', 'ada', '--reason', 'more hands', '--level', 'project', '--project', 'site']);
assert.equal(r.status, 0, r.stderr);
assert.match(r.stdout, /^recorded decision hire-1: fire ada \(resolved yes\)\n$/);
assert.deepEqual(c.calls.splice(0), [{ verb: 'hire.start.console', args: { op: 'fire', level: 'project', name: 'ada', reason: 'more hands', project: 'site' }, opts }]);
// A bus refusal reaches the terminal with the Console's code and message, as the form shows it.
r = await mosaic(env, ['org', 'hire', 'alpha', ...HIRE.map(a => (a === 'ada' ? 'blocked' : a))]);
const pageRefusal = await page(c.port, 'POST', '/api/orgs/alpha/decisions/hire/start', { business: 'alpha', hire: form('hire', 'blocked') });
assert.equal(pageRefusal.status, 409);
assert.equal(r.status, 3, r.stderr);
assert.ok(r.stderr.includes(`hire refused (definition-not-hireable): ${pageRefusal.body.message}`), r.stderr);
c.calls.splice(0);
// A template the Console did not offer is refused before anything is raised.
r = await mosaic(env, ['org', 'hire', 'alpha', ...HIRE.map(a => (a === 'worker' ? 'ghost' : a))]);
assert.equal(r.status, 2, r.stderr);
assert.match(r.stderr, /no role template "ghost"/);
assert.deepEqual(c.calls.splice(0).map(x => x.verb), ['hire.templates.console']);
// A Console without --send: the page's refusal, and nothing reaches the bus.
await close(c.server);
const ro = await consoleFor(t, ctx, { send: false });
r = await mosaic(env, ['org', 'hire', 'alpha', ...HIRE]);
const pageRo = await page(ro.port, 'POST', '/api/orgs/alpha/decisions/hire/templates', { business: 'alpha' });
assert.equal(r.status, 3, r.stderr);
assert.match(r.stderr, new RegExp(`hire refused \\(${pageRo.body.error}\\)`));
assert.equal(pageRo.body.error, 'read-only');
assert.deepEqual(ro.calls, []);
// An organization the Console does not know is refused as the page is.
r = await mosaic(env, ['org', 'role-templates', 'beta']);
assert.equal(r.status, 3, r.stderr);
assert.match(r.stderr, /role-templates refused \(org-unavailable\)/);
});
test('autonomy reads, saves with the panel\'s revision check and records a review through the panel\'s route', { timeout: 120000 }, async t => {
const { ctx, env } = await instance(t);
const c = await consoleFor(t, ctx);
const opts = { subject: SUBJECT };
let r = await mosaic(env, ['org', 'autonomy', 'alpha', '--json']);
assert.equal(r.status, 0, r.stderr);
assert.deepEqual(JSON.parse(r.stdout), (await page(c.port, 'GET', '/api/orgs/alpha/autonomy')).body);
r = await mosaic(env, ['org', 'autonomy', 'alpha']);
assert.equal(r.stdout, `revision ${REV(3)}\ncross-role: in the loop\nmerge: in the loop\npolicy: in the loop\nauthority: in the loop\nnotices: off\nreverse window: not set (the page offers 24 hours)\nauthority resolver: none\n`);
c.calls.splice(0);
// The panel's save for "merge near the loop" (autonomy.js): every class as shown, notices, the
// window it offers (24) and no resolver.
const panel = { ...LOOP, merge: 'near the loop', notices: false, reverseWindowHours: 24 };
// Imported here so that the other tests still run, and fail on their own, on a base without it.
const { autonomyDocument } = await import('../src/org-owner.mjs');
assert.deepEqual(autonomyDocument({ revision: REV(3), levels: LOOP, autonomy: { notices: false } }, { merge: 'near the loop' }), panel);
// An emptied resolver field: the panel leaves it out of the save, and so does the terminal.
assert.deepEqual(autonomyDocument({ revision: REV(3), levels: LOOP, autonomy: { notices: false, authorityResolver: 'ops' } }, { authorityResolver: '' }), { ...LOOP, notices: false, reverseWindowHours: 24 });
assert.equal(autonomyDocument({ revision: REV(3), levels: LOOP, autonomy: { notices: false, authorityResolver: 'ops' } }, {}).authorityResolver, 'ops');
r = await mosaic(env, ['org', 'autonomy', 'alpha', 'set', '--revision', REV(3), '--merge', 'near', '--json']);
assert.equal(r.status, 0, r.stderr);
assert.deepEqual(JSON.parse(r.stdout), { revision: REV(4), transaction: 'tx-4', writable: true });
assert.deepEqual(c.calls.splice(0), [{ verb: 'autonomy.get.console', args: {}, opts }, { verb: 'autonomy.set.console', args: { revision: REV(3), autonomy: panel }, opts }]);
// The page's save of the same document at revision 4 lands the same way.
const pageSave = await page(c.port, 'POST', '/api/orgs/alpha/autonomy', { revision: REV(4), autonomy: panel });
assert.deepEqual(pageSave.body, { revision: REV(5), transaction: 'tx-5', writable: true });
assert.deepEqual(c.calls.splice(0), [{ verb: 'autonomy.set.console', args: { revision: REV(4), autonomy: panel }, opts }]);
// A stale revision is the panel's revision-conflict, and nothing changes.
r = await mosaic(env, ['org', 'autonomy', 'alpha', 'set', '--revision', REV(3), '--policy', 'near']);
const pageStale = await page(c.port, 'POST', '/api/orgs/alpha/autonomy', { revision: REV(3), autonomy: panel });
assert.deepEqual(pageStale, { status: 409, body: { error: 'revision-conflict' } });
assert.equal(r.status, 3, r.stderr);
assert.match(r.stderr, /autonomy set refused \(revision-conflict\): the organization changed; read it again with mosaic org autonomy ID/);
assert.equal((await page(c.port, 'GET', '/api/orgs/alpha/autonomy')).body.revision, REV(5));
c.calls.splice(0);
r = await mosaic(env, ['org', 'autonomy', 'alpha', 'review', 'merge']);
assert.equal(r.status, 0, r.stderr);
assert.equal(r.stdout, 'recorded your trail review for merge on alpha\n');
await page(c.port, 'POST', '/api/orgs/alpha/autonomy/review', { decisionClass: 'merge' });
const [cli, pageReview] = c.calls.splice(0);
assert.deepEqual(cli, pageReview);
assert.deepEqual(cli, { verb: 'autonomy.review.console', args: { decisionClass: 'merge' }, opts });
// Without --send the read still answers and both writes are the page's read-only refusal.
await close(c.server);
const ro = await consoleFor(t, ctx, { send: false });
r = await mosaic(env, ['org', 'autonomy', 'alpha', '--json']);
assert.equal(r.status, 0, r.stderr);
assert.equal(JSON.parse(r.stdout).writable, false);
for (const args of [['set', '--revision', REV(3), '--merge', 'near'], ['review', 'merge']]) {
r = await mosaic(env, ['org', 'autonomy', 'alpha', ...args]);
assert.equal(r.status, 3, r.stderr);
assert.match(r.stderr, /refused \(read-only\): start the Console with --send/);
}
assert.deepEqual((await page(ro.port, 'POST', '/api/orgs/alpha/autonomy/review', { decisionClass: 'merge' })).body, { error: 'read-only' });
assert.deepEqual(ro.calls.map(x => x.verb), ['autonomy.get.console', 'autonomy.get.console']);
});
// mosaic tasks create|edit|archive with the person's transport over a real broker.
async function run(argv, s) {
const x = io();
try { await main(argv, x, { system: { dataRoot: s.dataRoot }, transport: s.transport }); return { code: 0, out: x.out.text, err: '' }; }
catch (e) { if (!(e instanceof CliError)) throw e; return { code: e.exitCode, out: x.out.text, err: e.message }; }
}
test('tasks create, edit and archive send the board\'s writes to the bus as the person', async t => {
const b = broker(t);
const s = { ...b, dataRoot: mkdtempSync(join(tmpdir(), 'e2t-')) };
t.after(() => rmSync(s.dataRoot, { recursive: true, force: true }));
// The board's request for the same input, through the board's own check.
const board = body => { const w = parseTaskWrite({ org: 'demo', ...body }); return { verb: w.verb, args: w.args }; };
let r = await run(['tasks', 'create', '--title', 'Plan the store', '--requirement', 'REQ-TASK-1', '--assignees', 'coder', '--priority', '2', '--business', 'demo', '--json'], s);
assert.equal(r.code, 0, r.err);
const created = JSON.parse(r.out);
assert.deepEqual(s.calls.splice(0).map(({ verb, args }) => ({ verb, args })), [board({ op: 'create', title: 'Plan the store', requirement: 'REQ-TASK-1', assignees: ['coder'], priority: 2 })]);
assert.deepEqual({ ...created, digest: 'd' }, { ok: true, org: 'demo', op: 'create', task_ref: 'task:1', digest: 'd' });
const row = () => s.read('tasks').find(x => x.task_ref === 'task:1');
assert.equal(created.digest, row().digest);
assert.equal(row().fields.title, 'Plan the store');
assert.deepEqual(row().fields.assignees, ['coder']);
r = await run(['tasks', 'edit', 'task:1', '--expect', created.digest, '--state', 'in-progress', '--due', 'none', '--business', 'demo'], s);
assert.equal(r.code, 0, r.err);
assert.equal(r.out, `changed task:1, digest ${row().digest}\n`);
assert.deepEqual(s.calls.splice(0).map(({ verb, args }) => ({ verb, args })), [board({ op: 'edit', task_ref: 'task:1', expect: created.digest, state: 'in-progress', due_date: null })]);
assert.equal(row().fields.state, 'in-progress');
// A stale digest is the board's task-conflict, and nothing is written.
const before = row();
r = await run(['tasks', 'archive', 'task:1', '--expect', created.digest, '--business', 'demo'], s);
assert.equal(r.code, 2, r.err);
assert.match(r.err, /^tasks archive refused \(task-conflict\): the task changed after you read it/);
assert.deepEqual(row(), before);
s.calls.splice(0);
r = await run(['tasks', 'archive', 'task:1', '--expect', before.digest, '--business', 'demo'], s);
assert.equal(r.code, 0, r.err);
assert.deepEqual(s.calls.splice(0).map(({ verb, args }) => ({ verb, args })), [board({ op: 'archive', task_ref: 'task:1', expect: before.digest })]);
assert.match(r.out, /^archived task:1, digest [0-9a-f]{64}\n$/);
// What the board refuses with 400 the terminal refuses as usage (exit 4), with the board's words,
// before anything is sent.
for (const [argv, body] of [
[['tasks', 'create', '--title', 'x', '--business', 'demo'], { op: 'create', title: 'x' }],
[['tasks', 'edit', 'task:1', '--expect', 'nope', '--title', 'y', '--business', 'demo'], { op: 'edit', task_ref: 'task:1', expect: 'nope', title: 'y' }],
[['tasks', 'archive', 'task:1', '--business', 'demo'], { op: 'archive', task_ref: 'task:1' }],
]) {
let boardError;
try { parseTaskWrite({ org: 'demo', ...body }); } catch (e) { boardError = e; }
assert.equal(boardError?.status, 400, JSON.stringify(body));
r = await run(argv, s);
assert.equal(r.code, 4, argv.join(' '));
assert.ok(r.err.includes(boardError.message), `${r.err} / ${boardError.message}`);
}
assert.deepEqual(s.calls, []);
// Terminal-only slips the board cannot make are usage too: an option-looking word is not a value,
// and the organization is named (exit 4 even inside an agent run).
for (const [argv, env] of [
[['tasks', 'create', '--title', '--json', '--requirement', 'REQ-TASK-1', '--business', 'demo'], humanEnv()],
[['tasks', 'create', '--title', 'x', '--requirement', 'REQ-TASK-1'], { ...humanEnv(), CLAUDECODE: '1' }],
]) {
const y = io(env);
await assert.rejects(main(argv, y, { system: { dataRoot: s.dataRoot }, transport: s.transport }), e => e instanceof CliError && e.exitCode === 4, argv.join(' '));
}
assert.deepEqual(s.calls, []);
// A marker only in this process's own environment counts too.
const savedMarker = process.env.CLAUDECODE;
process.env.CLAUDECODE = '1';
try {
const y = io(humanEnv());
delete y.env.CLAUDECODE;
await assert.rejects(main(['tasks', 'create', '--title', 'x', '--requirement', 'REQ-TASK-1', '--business', 'demo'], y, { system: { dataRoot: s.dataRoot }, transport: s.transport }), e => e instanceof CliError && e.exitCode === 3 && /outside any agent run/.test(e.message));
} finally { if (savedMarker === undefined) delete process.env.CLAUDECODE; else process.env.CLAUDECODE = savedMarker; }
assert.deepEqual(s.calls, []);
// Inside an agent run the write is refused before anything is sent.
const x = io({ ...humanEnv(), CLAUDECODE: '1' });
await assert.rejects(main(['tasks', 'create', '--title', 'x', '--requirement', 'REQ-TASK-1', '--business', 'demo'], x, { system: { dataRoot: s.dataRoot }, transport: s.transport }), e => e instanceof CliError && e.exitCode === 3);
assert.deepEqual(s.calls, []);
});
+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);
});
+15 -1
View File
@@ -804,13 +804,27 @@ test("land: a failed gate is refused gate-failed with its step, told to the role
assert.equal(ticket, TICKET);
assert.equal(a.code, "gate-failed");
assert.equal(a.notify, true);
assert.match(a.detail, /^pkg-bus failed, see \/.*\/bus-host\/land\/land-[0-9a-f]{12}\/pkg-bus\.log$/);
assert.match(a.detail, /^pkg-bus failed, see land\/land-[0-9a-f]{12}\/pkg-bus\.log in the bus host directory$/);
assert.equal(w.git("rev-parse", "refs/heads/main"), w.main);
assert.equal(w.git("cat-file", "-t", r.commit), "commit", "the squash was made, and only main didn't move");
assert.deepEqual(w.of("recordLand"), []);
assert.equal(w.trees(), 0);
});
// PR #1916 (org-redact5): the detail reaches the role, so it never carries the log's host
// path; a log file name outside the id pattern is dropped too.
test("land: a failed gate's detail names the land directory, never the log's host path", async (t) => {
const SENTINEL = "sk-test-NOT-A-SECRET-1234";
let log = null;
const w = await landSetup(t, { gate: { run: async ({ logs }) => ({ ok: false, step: "pkg-bus", log: (log = join(logs, SENTINEL, `${SENTINEL}.log`)) }) } });
await w.land();
await w.ended();
const [[, a]] = w.of("landRefused");
assert.equal(a.code, "gate-failed");
assert.match(a.detail, /^pkg-bus failed, see land\/land-[0-9a-f]{12} in the bus host directory$/);
assert.ok(!a.detail.includes(SENTINEL) && !a.detail.includes(log) && !a.detail.includes(w.f.dataRoot), a.detail);
});
test("land: a record the broker refuses moves main back, and the landing is refused with the broker's code", async (t) => {
const w = await landSetup(t);
w.state.recordFails = "decision-consumed";
+8 -2
View File
@@ -315,7 +315,7 @@ test("a host.lock that goes between the check and the read is tried again; one t
assert.deepEqual(readdirSync(hostDir(dataRoot)), ["host.lock"]);
writeFileSync(hostLock(dataRoot), "{not json", { mode: 0o600 });
assert.throws(() => claimHostLock(dataRoot, mine), (e) => e.exitCode === 3 && e.message === `bus host state is unreadable: ${hostLock(dataRoot)}`);
assert.throws(() => claimHostLock(dataRoot, mine), (e) => e.exitCode === 3 && e.message === "bus host state is unreadable");
assert.equal(readFileSync(hostLock(dataRoot), "utf8"), "{not json");
assert.deepEqual(readdirSync(hostDir(dataRoot)), ["host.lock"]);
});
@@ -749,8 +749,14 @@ test("a stale organization writer lock refuses start and status without removing
});
test("organization bus start refuses without a notifier config", async t => {
// PR #1916 (org-redact5): the refusal names the log inside the organization's
// runtime directory, never by host path. (A sentinel directory in the root would
// push the bus socket path past the socket length limit, so the check is on the
// fixture's own roots.)
const f = await orgFixture(t, { notify: false });
await assert.rejects(busStart(f.ctx, "acme", { detach: true }), /failed to start/i);
await assert.rejects(busStart(f.ctx, "acme", { detach: true }), (e) => e.exitCode === 3
&& e.message === "bus failed to start; see bus-host/host.log in the runtime directory of organization acme"
&& !e.message.includes(f.dataRoot) && !e.message.includes(f.instanceRoot));
assert.match(readFileSync(join(f.dataRoot, "bus-host", "host.log"), "utf8"), /no notifier config/);
assert.equal(existsSync(join(f.dataRoot, "bus", "writer.lock")), false);
});
Loaded 100 of 256 files, more files were not shown because too many files have changed in this diff. Show more