Compare 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
122 changed files with 10130 additions and 276 deletions

No files matched your search

+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);
}
+362 -43
View File
@@ -1,3 +1,5 @@
import { autonomyEvent, decisionResolution } from './autonomy-state.mjs';
import { autonomyClass, authorityBudgetCovered, checkAutonomy, decisionAutonomy, resolverSubject } from '../../organization/src/autonomy.mjs';
import { ownRails } from '../../organization/src/rails.mjs';
import { refusalGuidance } from './refusals.mjs';
import { PROXY_PAGE_VERBS, PROXY_PAGE_CODES } from './proxy-page-vocabulary.mjs';
@@ -54,6 +56,7 @@ export const ACTIONS = Object.freeze([
'prd.approve',
'decision.resolve.technical',
'decision.resolve.delivery',
'decision.resolve.authority',
// Row 285 (C2-08b, #1790): routed under PRD 1.16 REQ-WAIT-3 and REQ-LIB-6, below.
'library.write',
]);
@@ -83,6 +86,7 @@ const ROUTED = new Set([
'prd.approve',
'decision.resolve.technical',
'decision.resolve.delivery',
'decision.resolve.authority',
// PRD 1.7 REQ-COS-3 (row 194, #1692): the chief's hire and fire, within the bounds the broker checks.
'role.hire',
'role.fire',
@@ -174,7 +178,7 @@ export const USER_VERBS = Object.freeze(['project.list', 'thread.create', 'threa
'thread.share', 'thread.unshare', 'thread.shares']);
// Row 252 (#1753, design sections 2-3): the only verbs a Console owner cap reaches. Every other verb
// refuses it human-required before its own checks, and these refuse every other cap.
export const CONSOLE_VERBS = Object.freeze(['system-types.console', 'project.remote.get.console', 'project.remote.set.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'hire.preview.console', 'hire.resolve.console', 'hire.start.console', 'hire.templates.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
export const CONSOLE_VERBS = Object.freeze(['system-types.console', 'project.remote.get.console', 'project.remote.set.console', 'autonomy.get.console', 'autonomy.set.console', 'autonomy.review.console', 'decision.confirm.console', 'decision.reverse.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'hire.preview.console', 'hire.resolve.console', 'hire.start.console', 'hire.templates.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
// Task 3 (#1839): and the owner's intake controls. The reads and writes are the same lifecycle methods the PM's
// unsuffixed verbs reach, with the owner's verified (provider, iss, sub) as the only caller; the PM-only
// request.question and the PM's own reads keep their unsuffixed names and refuse a console cap.
@@ -525,6 +529,7 @@ const name = (x) => text(x, 200) && !/[\r\n]/.test(x);
// The checks a business document passes at boot and at every reload.
function checkBusiness(name, b) {
checkAutonomy(b.autonomy);
// Row 177: a human named `tester` would share the tester's threads.
if (!id(name) || b.id !== name || !id(b.human) || b.human === 'tester' || !object(b.roles) || !object(b.arbiters))
fail('invalid-business');
@@ -597,9 +602,11 @@ export class Broker {
#builtinBinding;
#organization;
#hires;
#autonomyHost;
#pluginService;
#credentialVerbs;
#proxyInit;
#proxyFenceClear;
#proxyPage;
#project;
#library;
@@ -635,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
@@ -660,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, hires = null, project = null, library = null, usage = null, limits = null, harnessOn = null, credentialVerbs = null, proxyInit = null, proxyPage = null, hostGrant = null, hostKeys = null, processing = null, testerOn = null, grantScope = null, roleFile = null, giteaGrant = null, giteaKeys = null, access = null, accessStamp = null, compactions = null, requestLifecycle = null, requirementsHost = null, workspaceRead = null, taskTracker = null }) {
constructor({ store, businesses, pluginService = null, secretCheck = () => {}, credentialStatus = () => [], organization = null, hires = null, autonomy = null, project = null, library = null, usage = null, limits = null, harnessOn = null, credentialVerbs = null, proxyInit = null, proxyFenceClear = null, proxyPage = null, hostGrant = null, hostKeys = null, processing = null, testerOn = null, grantScope = null, roleFile = null, giteaGrant = null, copyGrant = null, giteaKeys = null, access = null, accessStamp = null, compactions = null, requestLifecycle = null, requirementsHost = null, workspaceRead = null, taskTracker = null }) {
if (!object(businesses) || !Object.keys(businesses).length) fail('invalid-business');
// Row 194 b15 (sage-2501): the function-argument checks come before anything reads the store.
if (harnessOn !== null && typeof harnessOn !== 'function') throw new TypeError('harnessOn must be a function');
@@ -684,14 +692,17 @@ export class Broker {
this.#credentialStatus = credentialStatus;
this.#organization = organization;
this.#hires = hires;
this.#autonomyHost = autonomy;
this.#pluginService = pluginService;
// Row 199: the runtime's credential lifecycle (credential-verbs.mjs); it holds the store, not the broker.
this.#credentialVerbs = credentialVerbs;
this.#proxyInit = proxyInit;
this.#proxyFenceClear = proxyFenceClear;
this.#proxyPage = proxyPage;
this.#hostGrant = hostGrant;
this.#hostKeys = hostKeys;
this.#giteaGrant = giteaGrant;
this.#copyGrant = copyGrant;
this.#giteaKeys = giteaKeys;
this.#access = access;
this.#accessStamp = accessStamp;
@@ -3341,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';
@@ -3370,6 +3383,7 @@ export class Broker {
else this.#recordAuthority(sc, 'role.fire', authority, { reason: args.reason, transaction: result.transaction ?? null });
this.#event(sc, 'action.allowed', { action: 'fire.done', transaction: result.transaction ?? null, job, grants: out.grants, grantsLeft: out.grantsLeft }, name);
});
return result;
} catch (e) {
const code = e instanceof BusError ? e.code : 'outcome-unknown';
const list = out.grants.map((g) => g.grant).join(', ') || 'none';
@@ -3382,6 +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.
@@ -4799,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
@@ -5023,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(?,?,?,?,?,?,?,?,?,?,?)',
@@ -5038,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
@@ -5122,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.
@@ -5147,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}`);
@@ -5165,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,
);
@@ -5199,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;
});
@@ -5711,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);
});
@@ -5917,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;
@@ -5937,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') {
@@ -6096,7 +6172,19 @@ export class Broker {
return this.#withLast(cap, s, request);
}
if (CREDENTIAL_VERBS.includes(request.verb)) return this.#requestCredential(s, request.verb, structuredClone(args));
if(['autonomy.get.console','autonomy.set.console','autonomy.review.console','decision.reverse.console'].includes(request.verb)) {
this.#autonomyOwner(s);
return this.#autonomyConsole(s,request.verb,structuredClone(args)).catch(e=>{throw this.#refused(s,e instanceof BusError?e:new BusError(e.code??'outcome-unknown'),request);});
}
if (s.hireMembership && !['hire.preview.console','hire.resolve.console','hire.start.console','hire.templates.console'].includes(request.verb)) fail('human-required');
if(request.verb==='decision.resolve' && !s.human) {
if(s.tester)fail('tester-refused');
keys(args,['id','choice','note'],['id','choice']);identifier(args.id);
}
if(request.verb==='decision.resolve' && !s.human && ['role.hire','role.fire','role.change'].includes(this.#decision(s,args.id).action)) {
const d=this.#decision(s,args.id);
if(d.route_to!=='human')return this.#resolveAuthority(s,args).catch(e=>{throw this.#refused(s,e instanceof BusError?e:new BusError(e.code??'outcome-unknown'),request);});
}
if(request.verb==='hire.templates.console') {
keys(args,[]);const caller=this.#hireCaller(s);
if(!caller.rights.includes('org.hire'))fail('right-required');
@@ -6283,6 +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))
@@ -6313,6 +6406,122 @@ export class Broker {
}
return result;
}
// #1873 CB2: owner only, as every binding decision (design Q1): not the tester, a reader or an
// org.hire member. The recheck runs after the remote work and again under the writer's locks.
#copyOwner(s) {
if (s.tester || s.reader || !s.human) fail(s.tester ? 'tester-refused' : 'human-required');
if (s.hireMembership ? s.hireMembership.owner !== true : s.human !== this.#business(s.business).human) fail('human-required');
}
#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;
}, (e) => {
if (passed(e)) throw e;
if (['revision-conflict', 'decision-stale'].includes(e?.code) || e?.code?.startsWith('organization-')) fail(e.code);
if (e?.name === 'BusinessError') fail('invalid-request');
throw new BusError('outcome-unknown');
});
}
async #autonomyConsole(s,verb,a) {
if(verb==='autonomy.get.console') {
keys(a,[]);
if(!this.#autonomyHost?.get)fail('not-configured');
const current=await this.#autonomyHost.get(s.business);
return {...current,levels:Object.fromEntries(['cross-role','merge','policy','authority'].map(k=>[k,decisionAutonomy(k,current.autonomy)]))};
}
if(verb==='autonomy.review.console') {
keys(a,['decisionClass'],['decisionClass']);decisionAutonomy(a.decisionClass);
const latest=this.#store.get("SELECT seq FROM events WHERE business=? AND kind='action.allowed' AND json_extract(body,'$.operation')='autonomy.set' ORDER BY seq DESC LIMIT 1",s.business);
this.#store.transaction(()=>this.#event(s,'action.allowed',{operation:'autonomy.review',decisionClass:a.decisionClass,settingSeq:latest?.seq??null,subject:s.console},null));
return {reviewed:true};
}
if(verb==='autonomy.set.console') {
keys(a,['revision','autonomy'],['revision','autonomy']);
const AUTONOMY_CLASSES=['cross-role','merge','policy','authority'];
checkAutonomy(a.autonomy);
if(!this.#autonomyHost?.get || !this.#autonomyHost?.set)fail('not-configured');
const old=await this.#autonomyHost.get(s.business),b=this.#business(s.business);
if(a.revision!==old.revision)fail('revision-conflict');
if(AUTONOMY_CLASSES.some(cls=>decisionAutonomy(cls,a.autonomy)==='near the loop') && a.autonomy.notices!==true)fail('autonomy-precondition');
for(const cls of AUTONOMY_CLASSES) {
const before=decisionAutonomy(cls,old.autonomy),after=decisionAutonomy(cls,a.autonomy);
// Pulling back is unconditional. Only a move away from the loop has prerequisites.
// Lead ruling: policy has no effect executor. Its existing out and in levels
// remain usable; near refuses in checkAutonomy until an inverse exists.
if(cls==='policy')continue;
if(cls==='authority' && after==='out of the loop' && before!==after)fail('authority-limits-unavailable');
if(['in the loop','near the loop','out of the loop'].indexOf(after)<=['in the loop','near the loop','out of the loop'].indexOf(before))continue;
if(a.autonomy.notices!==true || !this.#autonomyHost.reversible?.(cls,b))fail('autonomy-precondition');
const resolvers=cls==='authority'?[a.autonomy.authorityResolver]:cls==='merge'?[b.arbiters.technical]:cls==='policy'?[b.arbiters.delivery]:Object.values(b.arbiters);
for(const name of resolvers) {
const role=b.roles[name],right=cls==='authority'?'decision.resolve.authority':`decision.resolve.${cls==='merge'?'technical':cls==='policy'?'delivery':name===b.arbiters.technical?'technical':'delivery'}`;
if(!role?.authority?.withinRole.includes(right) || (cls==='authority' && name===b.launch?.by))fail('autonomy-precondition');
}
if(['merge','policy'].includes(cls) && !Object.values(b.roles).some(r=>r.authority?.withinRole.includes('review.verdict')))fail('autonomy-precondition');
if(cls==='authority' && !this.#budgetCovered(b))fail('autonomy-precondition');
if(after==='out of the loop') {
if(before!=='near the loop')fail('autonomy-precondition');
const review=this.#store.get("SELECT seq,body FROM events WHERE business=? AND kind='action.allowed' AND json_extract(body,'$.operation')='autonomy.review' AND json_extract(body,'$.decisionClass')=? ORDER BY seq DESC LIMIT 1",s.business,cls);
const latest=this.#store.get("SELECT seq FROM events WHERE business=? AND kind='action.allowed' AND json_extract(body,'$.operation')='autonomy.set' ORDER BY seq DESC LIMIT 1",s.business);
if(!review || JSON.parse(review.body).settingSeq!==latest?.seq)fail('autonomy-precondition');
const notice=this.#store.get("SELECT seq FROM events WHERE business=? AND kind='action.allowed' AND json_extract(body,'$.operation')='autonomy.notice' AND json_extract(body,'$.decisionClass')=? AND seq>? ORDER BY seq DESC LIMIT 1",s.business,cls,latest?.seq??0);
const reversal=this.#store.get("SELECT max(e.seq) seq FROM events e WHERE e.business=? AND e.kind='action.allowed' AND json_extract(e.body,'$.operation') LIKE 'autonomy.reverse%' AND EXISTS(SELECT 1 FROM events n WHERE n.business=e.business AND n.kind='action.allowed' AND json_extract(n.body,'$.operation')='autonomy.notice' AND json_extract(n.body,'$.decisionClass')=? AND json_extract(n.body,'$.decision')=json_extract(e.body,'$.decision'))",s.business,cls);
if(!notice || review.seq<=Math.max(notice.seq,reversal?.seq??0))fail('autonomy-precondition');
}
}
this.#store.transaction(()=>this.#event(s,'action.allowed',{operation:'autonomy.set.requested',autonomy:a.autonomy,subject:s.console},null));
const result=await this.#autonomyHost.set(s.business,a,s.human);
this.#autonomyOwner(s);
this.#store.transaction(()=>this.#event(s,'action.allowed',{operation:'autonomy.set',autonomy:a.autonomy,subject:s.console,transaction:result.transaction},null));
return result;
}
keys(a,['id'],['id']);identifier(a.id);
const d=this.#decision(s,a.id),notice=autonomyEvent(this.#store,s.business,d.id,['autonomy.notice']);
const windowOpen=()=>{
if(!notice || Date.parse(notice.body.reverseUntil)<=Math.max(Date.now(),this.#clock))throw new BusError('reverse-window-closed','The reverse window has closed; raise an ordinary new decision for this change.');
};
windowOpen();
if(autonomyEvent(this.#store,s.business,d.id,['autonomy.reverse.consume']))fail('decision-reversed');
const before=autonomyEvent(this.#store,s.business,d.id,['autonomy.before']),after=autonomyEvent(this.#store,s.business,d.id,['autonomy.after']);
if(!before && !this.#autonomyHost?.reverse)fail('reversal-unavailable');
this.#store.transaction(()=>{
windowOpen();
this.#event(s,'action.allowed',{operation:'autonomy.reverse.consume',decision:d.id,subject:s.console},d.id);
});
let committedResult=null;
try {
if(before && after) return this.#store.transaction(()=>{
windowOpen();
const current=this.#storeCurrent(s.business,before.body.target);
if(current.digest!==after.body.digest)fail('decision-stale');
const result=this.#storeWrite(s,{current,op:'change',verb:'tasks.edit',fields:before.body.fields,by:{role:null,run:null,human:s.human},kind:'human.input',body:{kind:'autonomy.reverse',decision:d.id}});
this.#event(s,'action.allowed',{operation:'autonomy.reverse',decision:d.id,revision:result.revision},d.id);
return {id:d.id,reversed:true,task_ref:result.task_ref};
});
const effects=this.#store.all("SELECT body FROM events WHERE business=? AND kind='action.allowed' AND json_extract(body,'$.decision')=? ORDER BY seq",s.business,d.id).map(e=>JSON.parse(e.body));
let recorded=false;
const result=await this.#autonomyHost.reverse({business:s.business,decision:d.id,decisionClass:autonomyClass(d.action),action:d.action,raised:this.#raised(s.business,d.id),effects,actor:s.human,
beforeWrite:()=>{this.#autonomyOwner(s);windowOpen();},beforePublish:()=>{this.#autonomyOwner(s);windowOpen();},fire:proposal=>this.#fireApproved(s,proposal,()=>{this.#autonomyOwner(s);windowOpen();},true),stop:proposal=>this.#stopCopyApproved(s,proposal),complete:(proposal,result)=>this.#store.transaction(()=>this.#event(s,'action.allowed',{action:'fire.done',decision:d.id,transaction:result.transaction??null},proposal.level==='project'?`${proposal.name}.${proposal.project}`:proposal.name)),relaunch:proposal=>{
this.#autonomyOwner(s);
const instance=proposal.level==='project'?`${proposal.name}.${proposal.project}`:proposal.name;
this.#mayHuman(s,'instance.launch',instance);this.#launchable(s,instance,'not-thread-launch');
return this.#store.transaction(()=>({request:this.#lifecycleRecord(s,'lifecycle.launch',instance,{decision:d.id})}));
},record:result=>this.#store.transaction(()=>{if(!(autonomyClass(d.action)==='authority' && result.committed===true))windowOpen();recorded=true;this.#event(s,'action.allowed',{operation:'autonomy.reverse',decision:d.id,result},d.id);})});
if(autonomyClass(d.action)==='authority' && result.committed===true)committedResult=result;
if(!recorded)this.#store.transaction(()=>{if(!committedResult)windowOpen();this.#event(s,'action.allowed',{operation:'autonomy.reverse',decision:d.id,result},d.id);});
return {id:d.id,reversed:true,...result};
} catch(error) {
if(committedResult)return {id:d.id,reversed:true,...committedResult,trailPending:true};
this.#store.transaction(()=>this.#event(s,'action.allowed',{operation:'autonomy.reverse.failed',decision:d.id,code:error.code??'outcome-unknown'},d.id));
throw error;
}
}
#hireCaller(s) {
if (!s.console || !s.human || s.reader || s.tester) fail('human-required');
if(s.hireMembership) return s.hireMembership;
@@ -6328,7 +6537,9 @@ export class Broker {
if(!['hire','change','fire'].includes(input?.op))fail('invalid-request');
if(this.#classification(s,input.op==='fire'?'role.fire':'role.hire')!=='within-role')fail('not-authorized');
};
if(immediate) { const c=this.#hireCaller(s); if(!c.rights.includes('org.hire'))fail('right-required'); }
// #1873 CB1: a copy credential binding is owner-only. A person raising one in one step must be
// the organization's owner; a member with org.hire is not.
if(immediate) { const c=this.#hireCaller(s); if(!c.rights.includes('org.hire'))fail('right-required'); if(input?.binding!==undefined && c.owner!==true)fail('human-required'); }
else chiefAllowed();
if(!this.#hires)fail('not-configured');
const did=randomUUID(), at=this.#now(), expiresAt=new Date(Date.parse(at)+7*86400000).toISOString();
@@ -6337,18 +6548,82 @@ export class Broker {
if(!immediate)chiefAllowed();
else {
const caller=this.#hireCaller(s);
if(((p.flagged.length || p.credentialLines.length) && !caller.owner) || (p.level==='root' && !caller.instanceOwner))fail('hire-owner-required');
if(p.binding && caller.owner!==true)fail('human-required');
if(!p.binding && (((p.flagged.length || p.credentialLines.length) && !caller.owner) || (p.level==='root' && !caller.instanceOwner)))fail('hire-owner-required');
}
const action=`role.${p.op}`;
const level=decisionAutonomy('authority',b.autonomy);
let route='human';
// A binding always goes to the owner; no autonomy level moves it (design section 4).
if(!immediate && !p.binding && level!=='in the loop' && p.level!=='root' && !p.flagged.length && !p.credentialLines.length) {
if(level==='out of the loop')fail('authority-limits-unavailable');
if(!this.#budgetCovered(this.#business(s.business)))fail('autonomy-precondition');
route=b.autonomy?.authorityResolver;
if(!b.roles[route]?.authority?.withinRole.includes('decision.resolve.authority'))fail('autonomy-precondition');
if(route===s.role || resolverSubject(b,route,p.name))route='human';
}
this.#store.transaction(()=>{
const raiser=immediate?'human':s.role;
this.#store.run('INSERT INTO decisions(id,at,business,project,raised_by_role,raised_by_run,class,action,route_to,question,options,recommendation,blocking) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)',
did,at,s.business,p.project??null,raiser,immediate?'human-console':s.run,'gated',action,'human',`${p.op} ${p.name} at ${p.level} level?`,JSON.stringify([{key:'yes',text:'Approve'},{key:'no',text:'Deny'}]),'no',0);
this.#event(s,'action.allowed',{operation:'decision.raise',decision:did,action,approvalChoice:'yes',target:p.name,hire:p},did);
did,at,s.business,p.project??null,raiser,immediate?'human-console':s.run,'gated',action,route,p.binding?`${p.binding.after===null?'unbind':'bind'} credentials for copy ${p.binding.instance}?`:`${p.op} ${p.name} at ${p.level} level?`,JSON.stringify([{key:'yes',text:'Approve'},{key:'no',text:'Deny'}]),'no',0);
this.#event(s,'action.allowed',{operation:'decision.raise',decision:did,action,approvalChoice:'yes',target:p.name,hire:p,autonomyLevel:level},did);
});
if(immediate) return this.#hireConsole(s,'hire.resolve.console',{id:did,choice:'yes',digest:p.digest});
return {...this.#decisionView(this.#decision(s,did)),hire:p};
}
// The harness fact comes from the host's loaded modules; no host, no account coverage.
#budgetCovered(b) { return authorityBudgetCovered(b,{countsAccount:this.#autonomyHost?.countsAccount}); }
// Trusted runtime inverse only; never a socket verb.
hireReverse(...args) {
if(!this.#hires?.reverse)fail('reversal-unavailable');
return this.#hires.reverse(...args);
}
async #resolveAuthority(s,a) {
keys(a,['id','choice'],['id','choice']);identifier(a.id);
const d=this.#decision(s,a.id),p=this.#raised(s.business,d.id)?.hire;
const validate=()=>{
this.#agent(s);
if(d.route_to!==s.role || d.raised_by_role===s.role || resolverSubject(this.#business(s.business),s.role,p?.name))fail('not-resolver');
if(p?.binding)fail('human-required');
if(!p || p.level==='root' || p.flagged.length || p.credentialLines.length)fail('hire-owner-required');
if(this.#closed(d.id))fail('decision-closed');
if(this.#expireHire(s,d,p))fail('decision-expired');
if(this.#autonomyLevel(d)==='in the loop')fail('hire-resolution-required');
if(this.#autonomyLevel(d)==='out of the loop')fail('authority-limits-unavailable');
if(!this.#budgetCovered(this.#business(s.business)))fail('autonomy-precondition');
if(!this.#business(s.business).roles[s.role]?.authority?.withinRole.includes('decision.resolve.authority'))fail('not-resolver');
};
validate();
if(!['yes','no'].includes(a.choice))fail('invalid-choice');
if(!this.#hires)fail('not-configured');
const caller={agent:true,authorityDecision:d.id,resolver:s.role,actor:s.role,rights:['decision.resolve.authority'],digest:p.digest,now:()=>Math.max(Date.now(),this.#clock),beforeWrite:validate};
if(a.choice==='yes')await this.#hires.check(s.business,d.id,caller);
validate();
this.#store.transaction(()=>{
validate();
this.#store.run('INSERT INTO decision_events(decision,at,op,by,choice,via) VALUES(?,?,?,?,?,?)',d.id,this.#now(),'resolved',s.run,a.choice,'broker');
if(a.choice==='yes')this.#event(s,'action.allowed',{operation:'hire.consume',decision:d.id,digest:p.digest,hire:p},d.id);
this.#autonomyNotice(s,d,this.#closed(d.id));
});
if(a.choice==='no')return {id:d.id,op:'resolved',choice:a.choice};
// No caller-provided human identity: the trusted writer receives the resolving instance.
const recheck=()=>{
this.#agent(s);
if(this.#autonomyLevel(d)==='out of the loop')fail('authority-limits-unavailable');
if(!this.#budgetCovered(this.#business(s.business)))fail('autonomy-precondition');
if(this.#autonomyLevel(d)==='in the loop' || !this.#business(s.business).roles[s.role]?.authority?.withinRole.includes('decision.resolve.authority') || resolverSubject(this.#business(s.business),s.role,p.name))fail('not-resolver');
};
recheck();
try {
if(p.op==='fire' && p.level!=='org')await this.#stopCopyApproved(s,p);
recheck();
const result=p.op==='fire' && p.level==='org'?await this.#fireApproved(s,p,recheck):await this.#hires.apply(s.business,d.id,{...caller,beforeWrite:recheck,beforePublish:recheck});
this.#store.transaction(()=>this.#event(s,'action.allowed',{operation:'hire.apply',decision:d.id,transaction:result.transaction??null,revision:result.revision??null},d.id));
return {...result,id:d.id,op:'resolved',choice:a.choice};
} catch(error) {
this.#store.transaction(()=>this.#event(s,'action.allowed',{operation:'hire.failed',decision:d.id,code:error.code??'outcome-unknown'},d.id));throw error;
}
}
async #hireConsole(s,verb,a) {
const caller={...this.#hireCaller(s),now:()=>Math.max(Date.now(),this.#clock)};
keys(a,verb==='hire.preview.console'?['id','adjust']:['id','choice','digest','adjust'],verb==='hire.preview.console'?['id']:['id','choice','digest']);
@@ -6360,6 +6635,7 @@ export class Broker {
if(closed)fail(closed.by==='expiry'?'decision-expired':'decision-closed');
if(this.#expireHire(s,d,p))fail('decision-expired');
if(!caller.rights.includes('org.hire'))fail('right-required');
if(p.binding && caller.owner!==true)fail('human-required');
if(a.adjust!==undefined) {
if(!this.#hires?.preview)fail('not-configured');
p=await this.#hires.preview(s.business,d.id,{...caller,adjust:a.adjust});
@@ -6408,25 +6684,29 @@ export class Broker {
const b=this.#business(s.business);
if(!b.roles[instance])return;
if(this.#revoking.has(s.business+'/'+instance))fail('fire-in-progress');
let running=false;
const set=this.#store.transaction(()=>{
const held=this.#threaded(s.business,instance)?this.#threadHolders(s.business,instance):[this.#holder(s.business,instance)].filter(h=>h?.op==='claim');
for(const h of held)this.#claimEnd(s,h,'revoke',p.id);
const runs=[...this.#runs.values()].filter(r=>r.business===s.business&&r.role===instance).map(r=>r.run);
running=runs.length>0;
const request=runs.length?randomUUID():null;
if(request)this.#event(s,'action.allowed',{action:'lifecycle.end',request,via:'human',by:s.human,runs,reason:'Approved fire decision',kill:true},instance);
this.#event(s,'action.allowed',{action:'fire.stopped',request,decision:p.id},instance);
return this.#grantSet(s.business,instance);
});
if(set.length)await this.#stepG(s.business,instance,set,{job:p.id,reason:'Approved fire decision',by:s.human,flag:{fire:true}});
return {running};
}
// The existing row-281 fire executor, now entered only after a consumed human decision.
#fireApproved(s,p) {
#fireApproved(s,p,beforeWrite=()=>{},waitForCommit=false) {
const args={name:p.name,reason:'Approved fire decision'},verb='instance.fire',action='role.fire';
const b=this.#business(s.business),authority={target:p.name};
const refused=e=>{if(['reverse-window-closed','autonomy-precondition','not-resolver'].includes(e.code))throw e;return this.#organizationFail(e,verb);};
const write=op=>{
try {const result=this.#organization(s.business,op,{name:p.name,revision:p.revision},s.human,{decision:p.id,digest:p.digest});
return result?.then?result.catch(e=>this.#organizationFail(e,verb)):result;
} catch(e) {this.#organizationFail(e,verb);}
try {beforeWrite();const result=this.#organization(s.business,op,{name:p.name,revision:p.revision},s.human??s.role,{decision:p.id,digest:p.digest,beforeWrite,beforePublish:beforeWrite});
return result?.then?result.catch(refused):result;
} catch(e) {refused(e);}
};
if (!Object.hasOwn(b.roles, args.name)) fail('unknown-role');
if (END_PROTECTED.includes(b.roles[args.name].definition)) fail('organization-definition-refused');
@@ -6470,7 +6750,8 @@ export class Broker {
if (stopped.job) {
const { job, set, ...rest } = stopped;
const sc = { business: s.business, role: s.role, run: s.run, thread: s.thread, human: s.human };
this.#startGrantJob(s.business, args.name, verb, () => this.#fireJob(sc, args, authority, write, job, set));
const pending=this.#startGrantJob(s.business, args.name, verb, () => this.#fireJob(sc, args, authority, write, job, set,waitForCommit));
if(waitForCommit)return pending;
return { ...rest, job, revoking: set.map((g) => g.label), done: false };
}
// Step B: the delist. An instance already delisted counts as done.
@@ -7103,12 +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, []);
@@ -7255,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');
@@ -7338,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,
@@ -7403,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(?,?,?,?,?,?,?)',
@@ -7422,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)
@@ -7846,19 +8165,19 @@ export class Broker {
keys(a, allowed, verb === 'trail' ? ['subject'] : []);
if (!s.human && !s.reader) this.#agent(s);
if (verb === 'inbox')this.#expireHires(s);
if (verb === 'inbox')
return this.#store
.all(
`SELECT d.* FROM decisions d WHERE business=? AND route_to=? AND NOT EXISTS(SELECT 1 FROM decision_events x WHERE x.decision=d.id AND x.op IN ${CLOSED}) ORDER BY CASE class WHEN 'gated' THEN 0 ELSE 1 END,seq`,
s.business,
s.human || s.reader ? 'human' : s.role,
)
.map((d) => {
// Row 252: a grant request the Console can answer carries its two answers. The Console
// shows buttons from this, and the answer path rechecks it at the bus.
const c = s.human || s.reader ? this.#consoleResolution(d) : null;
return c ? { ...this.#decisionView(d), console: c } : this.#decisionView(d);
});
if (verb === 'inbox') {
const owner=!!(s.human || s.reader);
return this.#store.all('SELECT * FROM decisions WHERE business=? ORDER BY seq',s.business).flatMap(d=>{
const closed=this.#closed(d.id), pending=autonomyEvent(this.#store,s.business,d.id,['autonomy.pending','autonomy.declined','autonomy.confirmed']);
const waiting=pending?.body.operation==='autonomy.pending' && !closed;
const notice=autonomyEvent(this.#store,s.business,d.id,['autonomy.notice']);
const reversed=autonomyEvent(this.#store,s.business,d.id,['autonomy.reverse.consume']);
if(owner && (waiting || (notice && !reversed)))return [{...this.#decisionView(d),autonomy:{pending:waiting,notice:!!notice,reverseUntil:notice?.body.reverseUntil??null}}];
if(closed || waiting || d.route_to!==(owner?'human':s.role))return [];
const c=owner?this.#consoleResolution(d):null;
return [c?{...this.#decisionView(d),console:c}:this.#decisionView(d)];
}).sort((a,b)=>(a.class==='gated'?0:1)-(b.class==='gated'?0:1)||a.seq-b.seq);
}
if (verb === 'agents') {
const claims = this.#store.all(
"SELECT * FROM role_claims c WHERE business=? AND op='claim' AND seq=(SELECT max(seq) FROM role_claims WHERE business=c.business AND role=c.role) ORDER BY role",
+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;
}
+103 -17
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);
};
@@ -90,7 +90,7 @@ export function parseHostKey(text) {
// Credentials.assertClean's test: credential-leak when the value's JSON text holds an entry's token
// or one of its retiring model keys. org-defscan (#1873): issue's check runs it on the value it is
// about to hold.
function assertNotHeld(value, entries) {
export function assertNotHeld(value, entries) {
let encoded;
try {
encoded = JSON.stringify(value);
@@ -479,6 +479,18 @@ export class Credentials {
const value = Credentials.#format(service, token);
check((v) => assertNotHeld(v, [value]));
this.#invalidateModelProof(instance, service);
this.#writeSlot(instance, service, service === 'model' ? JSON.stringify(value.model) + '\n' : value.key ? value.token : value.token + '\n');
const file = this.#storePath(instance, service);
commit();
this.#refs.set(instance + ':' + service, { ref: { service, store: true, file, [service === 'vikunja' ? 'expires' : 'rotateBy']: date }, date });
this.#tokens.set(instance + ':' + service, { ...value, date });
this.#revoked.delete(instance + ':' + service);
this.#held.delete(instance + ':' + service);
return this.status().find((e) => e.instance === instance && e.service === service);
}
// The store slot write issue and stageCopy share: tmp (O_EXCL, O_NOFOLLOW, 0600), fsync, rename
// over the slot, fsync the dir. Returns the identity of the file it wrote.
#writeSlot(instance, service, text, { undo = false } = {}) {
const file = this.#storePath(instance, service);
const agentDir = dirname(file);
mkdirSync(this.#storeRoot, { recursive: true, mode: 0o700 });
@@ -493,27 +505,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.
+12 -1
View File
@@ -1,3 +1,6 @@
import { decisionAutonomy, resolverSubject } from '../../organization/src/autonomy.mjs';
import { autonomyEvent } from './autonomy-state.mjs';
import { decisionResolution } from './autonomy-state.mjs';
// The checks a landing makes on its merge decision and the task's review
// (row 137, #1631). Two callers: scripts/land-work/read.mjs, which lands on
// canonical refactor from a read-only copy of the bus, and the broker's
@@ -96,7 +99,8 @@ export function readLanding(q, business, id, { into, arbiter = false }) {
const raise = json(raises[0].body) ?? {};
if (d.action !== MERGE || raise.action !== MERGE) refuse('not-a-merge', `action ${d.action}`);
const closes = queries.closes(q, id);
const resolved=decisionResolution(q,b,id);
const closes=resolved?[resolved]:[];
if (closes.length !== 1 || closes[0].op !== 'resolved')
refuse('not-resolved', closes.length ? closes.map((r) => r.op).join(',') : 'open');
const res = closes[0];
@@ -112,6 +116,13 @@ export function readLanding(q, business, id, { into, arbiter = false }) {
// A cross-role decision routed to an agent closes only by decision.resolve, which takes it from
// the routed role alone (s.role === route_to), so res.by here is the arbiter's run.
const arbitrated = arbiter && d.class === 'cross-role' && d.route_to !== 'human' && res.via === 'broker' && !answers.length;
if(arbitrated && !business.roles?.[d.route_to]?.authority?.withinRole?.includes('decision.resolve.technical'))refuse('not-resolver','The technical resolver lost decision.resolve.technical; resolve afresh under current policy.');
if(arbitrated) {
if(d.route_to===d.raised_by_role || resolverSubject(business,d.route_to,raise.target))refuse('not-resolver','A person decides a resolver subject.');
const confirmed=autonomyEvent(q,b,id,['autonomy.confirmed']);
if((decisionAutonomy('merge',business.autonomy)==='in the loop' || raise.autonomyLevel==='in the loop') && (!confirmed || confirmed.body.resolution.at!==res.at || confirmed.body.resolution.by!==res.by))refuse('confirmation-required','A person must confirm the arbiter resolution.');
if(autonomyEvent(q,b,id,['autonomy.reverse.consume']))refuse('decision-reversed','This decision was reversed.');
}
if (!human && !arbitrated)
refuse('not-human', `resolved by ${res.by} via ${res.via}, ${answers.length} human answer(s)`);
const options = json(d.options) ?? [];
+5 -2
View File
@@ -22,9 +22,12 @@ export function createModelCatalogue({ client, settings, pluginDataRoot = null,
const loader = makeLoader({ log: () => {}, dataRoot:pluginDataRoot });
const result = await loader.load({ 'model-provider': names });
if (result.refused.length || loader.snapshot('model-provider').length !== names.length) throw Error();
const c = client(); if (!c) throw Error();
// A module that needs no model proxy (fixture) answers without one; a module
// that asks the proxy while none is configured gets a refusal and reads unavailable.
const c = client();
const proxy = method => async (...args) => { if (!c) throw Error('model proxy not configured'); return c[method](...args); };
const answers = await Promise.all(loader.snapshot('model-provider').map(async ({ name, module }) => {
const answer = await module.catalogue({ listAccounts: () => c.getCredentials(), listModels: name => c.getCredentialModels(name), listGroups: () => c.getUpstreamGroups(), now: () => new Date(now()).toISOString() });
const answer = await module.catalogue({ listAccounts: proxy('getCredentials'), listModels: proxy('getCredentialModels'), listGroups: proxy('getUpstreamGroups'), now: () => new Date(now()).toISOString() });
if (answer?.status === 'unavailable' && answer.reason === 'model-key-collision') throw Object.assign(Error(), { code: 'model-key-collision' });
if (answer?.status !== 'ready' || !Array.isArray(answer.models) || answer.models.length > 10000 || !Array.isArray(answer.notices) || answer.notices.length > 100) throw Error();
for (const m of answer.models) {
+2 -2
View File
@@ -244,7 +244,7 @@ export class ModelKeys {
this.#receipt({ action: 'proxy.key.revoked', keyIds: [record.keyId, ...record.retiring.map(r => r.keyId)], remoteState: 'revoked', localState: 'kept' });
} catch (error) { if (error.code === 'proxy-uncertain' || (this.#lock && this.#uncertain())) fail('proxy-uncertain'); fail('credential-revoke-failed'); }
}
prepareRevoke(instance) { return this.#exclusive(() => this.#prepareRevoke(instance)); }
prepareRevoke(instance, beforeRevoke = () => {}) { return this.#exclusive(() => { beforeRevoke(); return this.#prepareRevoke(instance); }); }
completeRevoke(instance) {
try { this.#lock?.assertCertain(); } catch { fail('proxy-uncertain'); }
try {
@@ -359,7 +359,7 @@ export function modelOrganization({ modelKeys, writer }) {
pending.set(business, prepare);
const existed = modelKeys.hasRecord(instance);
try {
if (isRevoke) { await modelKeys.prepareRevoke(instance); prepare.remoteRevoked = existed; }
if (isRevoke) { await modelKeys.prepareRevoke(instance, () => context?.beforeWrite?.()); prepare.remoteRevoked = existed; }
return invoke(business, verb, args, actor, context);
} catch (error) {
if (isRevoke && prepare.remoteRevoked && (error.code === 'revision-conflict' || error.code === 'revision-conflict-revoked')) modelKeys.revisionConflict(instance);
+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 } = {}) {
+28
View File
@@ -9,8 +9,24 @@ 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.");
@@ -45,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",
+58 -18
View File
@@ -1,3 +1,4 @@
import { autonomyHost, authorityHost } from './autonomy-host.mjs';
import { hireDecisions } from '../../organization/src/hire-decisions.mjs';
import { organizationPlugins } from '../../plugins/src/organization.mjs';
import { pluginService } from '../../plugins/src/tools.mjs';
@@ -34,6 +35,7 @@ import { createModelCatalogue } from './model-catalogue.mjs';
import { sha256 } from '../../model-proxy/src/common.mjs';
import { initializeProxy } from '../../model-proxy/src/settings.mjs';
import { createProxyLock } from './proxy-lock.mjs';
import { verifyRestart } from '../../model-proxy/src/admin.mjs';
import { ModelKeys, modelOrganization, stopModelRuns, MODEL_STOP_TIMEOUT_MS } from './model-keys.mjs';
import { instanceSettings } from '../../organization/src/instance-settings.mjs';
import { organizationWriter, OrganizationError, setReplyLimits } from '../../organization/src/organization.mjs';
@@ -44,6 +46,7 @@ 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';
@@ -98,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 = [];
@@ -106,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
@@ -315,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;
@@ -364,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
@@ -382,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.
@@ -771,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,
@@ -890,23 +931,13 @@ export async function startBroker({
return INSTANCE_VERBS.has(verb)?{...result,restart:!live}:result;
}):null,
// Row 194 (D2.3): the role files as they stand, for the hire checks.
autonomy: organization ? autonomyHost({writer:organizationWriter({dir:organization.dir,rolesDir:organization.rolesDir,dataRoot:instanceRoot??dataRoot}),dataRoot,business:id=>businesses[id],countsAccount:levels.countsAccount,hires:{reverse:(...args)=>broker.hireReverse(...args)},reload,processing}) : null,
hires: organization ? (()=>{
const service=hireDecisions({writer:organizationWriter({dir:organization.dir,rolesDir:organization.rolesDir,dataRoot:instanceRoot??dataRoot}),rolesDir:organization.rolesDir,configDir:organization.dir,dataRoot,
system:organization.system,levels,catalogueFor:async(id,doc)=>doc.plugins?.['model-provider']?.length?catalogueFor(id,{fresh:true}):null,assertClean:value=>credentials.assertClean(value),scanDefinition:(name,entry)=>scanDefinitions({[name]:entry},value=>credentials.assertClean(value)),registryFor:doc=>organizationPlugins(instanceRoot??dataRoot,doc.plugins).registry});
return {...service,apply:async(id,decision,caller)=>{
const proposal=service.read(decision);
const publish=async()=>{
let approved=caller;
if(modelKeys && proposal.op==='fire' && proposal.level!=='org') {
const instance=`${id}/${proposal.level==='project'?`${proposal.name}.${proposal.project}`:proposal.name}`;
await modelKeys.prepareRevoke(instance);
approved={...caller,beforeWrite:()=>modelKeys.completeRevoke(instance)};
}
const result=await service.apply(id,decision,approved);reload(id);return result;
};
if(proxyOrganization && proposal.level==='org' && proposal.op==='hire')return proxyOrganization.hire(id,proposal.name,()=>service.check(id,decision,caller),publish);
return publish();
}};
system:organization.system,levels,catalogueFor:async(id,doc)=>doc.plugins?.['model-provider']?.length?catalogueFor(id,{fresh:true}):null,assertClean:value=>credentials.assertClean(value),scanDefinition:(name,entry)=>scanDefinitions({[name]:entry},value=>credentials.assertClean(value)),registryFor:doc=>organizationPlugins(instanceRoot??dataRoot,doc.plugins).registry,
// #1873 CB1: a copy binding names a copy this bus booted with; no snapshot, no binding.
copies:id=>copies?.org===id?copies:null});
return authorityHost(service,{modelKeys,reload,proxyOrganization});
})() : null,
roleFile: organization ? (name) => loadRole(organization.rolesDir, name) : null,
secretCheck: (value) => credentials.assertClean(value),
@@ -920,9 +951,18 @@ export async function startBroker({
proxyInit: organization ? async args => {
const settings = await initializeProxy({ configDir: organization.dir, dataRoot: instanceRoot ?? dataRoot, ...args });
modelProxyClient = await loadManagementClient({ configDir: organization.dir, proxy: settings, receipt: proxyReceipt });
proxyPort = settings.port;
// #1873: an org bus that initializes the shared proxy also holds its write lock from now on.
if (org && !proxyLock) proxyLock = createProxyLock({ stateDir: join(instanceDataRoot, 'proxy'), identity: `${instanceConfigDir}:${settings.port}` });
configureProxyPage();
return settings;
} : null,
// #1873: clear the uncertain-write fence only on restart evidence this host re-proves: the
// process on the proxy port is new, alive and started after the fence was set.
proxyFenceClear: organization ? async ({ evidence }) => {
if (!proxyLock || !modelProxyClient) throw new BusError('not-configured');
return proxyLock.clear({ restarted: true, read: () => modelProxyClient.getKeys(), verify: previous => verifyRestart(evidence, { fence: previous, port: proxyPort }) });
} : null,
credentialVerbs: organization
? credentialVerbs({ credentials, writer: organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir, dataRoot:instanceRoot ?? dataRoot }),
organizations: () => Object.keys(businesses), boot: copies?.definitions ? { org: copies.org, definitions: copies.definitions } : null })
@@ -0,0 +1,141 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {mkdtempSync,mkdirSync,readFileSync,rmSync} from 'node:fs';
import {tmpdir} from 'node:os';
import {join} from 'node:path';
import {Broker} from '../src/broker.mjs';
import {Store} from '../src/store.mjs';
import {autonomyHost,authorityHost} from '../src/autonomy-host.mjs';
import {organizationWriter} from '../../organization/src/organization.mjs';
import {instanceSettings} from '../../organization/src/instance-settings.mjs';
import {hireDecisions} from '../../organization/src/hire-decisions.mjs';
import {loadBusiness} from '../../organization/src/organization-config.mjs';
import {businessDoc,rolesCopy,writeJson} from '../../organization/tests/helpers.mjs';
const inline={tools:['read'],network:'none',authority:{withinRole:[],crossRole:[]},credentials:[],instructions:'Changed role'};
const cap={limit:100000,since:'2026-01-01T00:00:00.000Z'};
function world(t,options={}) {
const root=mkdtempSync(join(tmpdir(),'au-authority-')),rolesDir=rolesCopy(root),dataRoot=join(root,'data');mkdirSync(dataRoot,{mode:0o700});
const doc=businessDoc(root,'demo');doc.plugins={harness:['pi'],'limit-check':['token-limit']};
doc.launch.limits.tokens={providers:{zai:cap}};
// #1873: an account quota alone, covered only through the host's declared harness fact.
if(options.account){doc.launch.account={name:'shared',credential:'FIXTURE_TOKEN'};doc.launch.limits.tokens={keys:{shared:cap}};}
doc.definitions={judge:{rev:1,role:{...inline,authority:{withinRole:['decision.resolve.authority'],crossRole:[]}},instructions:'Judge'},scout:{rev:3,role:structuredClone(inline),instructions:'Prior role'}};
// Definition instructions live beside the role composition.
for(const d of Object.values(doc.definitions))delete d.role.instructions;
doc.roles.judge={definition:'org:judge',vars:{harness:'pi',model:'claude-opus-5-5'}};
doc.roles.scout={definition:'org:scout',vars:{harness:'pi',model:'claude-opus-5-5'}};
doc.launch.instances.push('scout');doc.autonomy={authority:'near the loop',authorityResolver:'judge',notices:true,reverseWindowHours:1};
writeJson(join(root,'businesses/demo.json'),doc);
let reversing=false,broker,now=Date.now;
const writer=organizationWriter({dir:root,rolesDir,barrier:point=>{if(reversing && point==='prepared' && options.expirePrepared)Date.now=()=>now()+3600001;if(!reversing && point==='prepared' && options.removeResolverPrepared){const b=structuredClone(loaded());b.roles.judge.authority.withinRole=[];broker.reloadBusiness('demo',b);}}});
const store=new Store(dataRoot),order=[];
const loaded=()=>{const b=loadBusiness('demo',{dir:root,rolesDir});return {...b,roles:Object.fromEntries(Object.entries(b.roles).map(([n,e])=>[n,{...b.definitions[e.definition],...e}]))};};
const reload=id=>{if(reversing && options.crossAfterCommit)Date.now=()=>now()+3600001;if(reversing && options.reloadRefused)return false;broker.reloadBusiness(id,loaded());return true;};
const service=hireDecisions({writer,rolesDir,configDir:root,dataRoot,assertClean:()=>{}});
const hires=authorityHost(service,{reload,modelKeys:{prepareRevoke:async(instance,guard)=>{guard();assert.equal(instance,'demo/scout');order.push('prepare');},completeRevoke:instance=>{assert.equal(instance,'demo/scout');assert.ok(store.get("SELECT 1 FROM events WHERE json_extract(body,'$.action')='fire.stopped'"));assert.equal(writer.read('demo').document.definitions.scout.instructions,'Changed role');order.push('complete');}}});
const host=autonomyHost({writer,dataRoot,business:()=>loaded(),hires,reload,...(options.countsAccount?{countsAccount:options.countsAccount}:{})});
const instances=instanceSettings({dir:root,rolesDir,writer,revoke:()=>{order.push('fire-revoke');}});
broker=new Broker({store,businesses:{demo:loaded()},hires,autonomy:host,organization:(id,verb,args,actor,context)=>{if(verb==='instance.fireCheck'){instances.checkFire(id,args.name);return {};}if(verb==='instance.fire')return instances.fire(id,args,{actor,context});throw Error('unexpected organization verb');}});
const caps={};const call=(who,verb,args={})=>broker.request(caps[who],{verb,args});
for(const role of ['chief','judge','scout']){caps[role]=broker.bindLaunch({business:'demo',role,run:role+'-run',harness:'pi'});call(role,'role.claim');}
caps.owner=broker.bindConsole({business:'demo',human:'jason',subject:{provider:'test',iss:'test',sub:'owner'}});
t.after(()=>{Date.now=now;store.close();rmSync(root,{recursive:true,force:true});});
const raise=(extra={})=>call('chief','hire.raise',{op:'change',level:'org',name:'scout',role:{inline},route:{harness:'pi',model:'claude-opus-5-5'},reason:'Change the task',...extra});
const reverse=id=>{reversing=true;return call('owner','decision.reverse.console',{id});};
return {writer,store,order,broker,call,raise,reverse,loaded};
}
for(const crossAfterCommit of [false,true])test(`AU authority reverse uses runtime key wrapper and relaunches after commit (deadline crossed: ${crossAfterCommit})`,async t=>{
const f=world(t,{crossAfterCommit}),d=await f.raise();assert.deepEqual(d.hire.flagged,[]);assert.equal(d.route_to,'judge');
await f.call('judge','decision.resolve',{id:d.id,choice:'yes'});assert.equal(f.writer.read('demo').document.definitions.scout.rev,4);
const r=await f.reverse(d.id);assert.equal(r.committed,true);assert.equal(r.relaunch,'requested');assert.deepEqual(f.order,['prepare','complete']);
const restored=f.writer.read('demo').document.definitions.scout;assert.equal(restored.rev,5);assert.equal(restored.instructions,'Prior role');
assert.equal(f.store.get("SELECT count(*) n FROM events WHERE json_extract(body,'$.operation')='autonomy.reverse'").n,1);
assert.equal(f.store.get("SELECT count(*) n FROM events WHERE json_extract(body,'$.operation')='autonomy.reverse.failed'").n,0);
assert.ok(f.store.get("SELECT 1 FROM events WHERE json_extract(body,'$.action')='lifecycle.launch' AND subject='scout'"));
});
test('AU a committed authority reverse reports pending recovery when reload refuses',async t=>{
const f=world(t,{reloadRefused:true}),d=await f.raise();await f.call('judge','decision.resolve',{id:d.id,choice:'yes'});
const r=await f.reverse(d.id);assert.equal(r.committed,true);assert.equal(r.restart,true);assert.equal(r.relaunch,'pending');assert.equal(f.writer.read('demo').document.definitions.scout.instructions,'Prior role');
assert.equal(f.store.get("SELECT count(*) n FROM events WHERE json_extract(body,'$.operation')='autonomy.reverse.failed'").n,0);
});
test('AU authority reverse crossing deadline in prepared IO never publishes',async t=>{
const f=world(t,{expirePrepared:true}),d=await f.raise();await f.call('judge','decision.resolve',{id:d.id,choice:'yes'});const prior=f.writer.read('demo').revision;
await assert.rejects(f.reverse(d.id),{code:'reverse-window-closed'});assert.equal(f.writer.read('demo').revision,prior);f.writer.recover('demo');assert.equal(f.writer.read('demo').revision,prior);
});
test('AU authority resolver rechecks budget removal after a proposal was raised',async t=>{
const f=world(t),d=await f.raise(),b=structuredClone(f.loaded());b.plugins['limit-check']=[];f.broker.reloadBusiness('demo',b);
const before=f.writer.read('demo').revision;await assert.rejects(f.call('judge','decision.resolve',{id:d.id,choice:'yes'}),{code:'autonomy-precondition'});
assert.equal(f.writer.read('demo').revision,before);assert.equal(f.store.get('SELECT count(*) n FROM decision_events WHERE decision=?',d.id).n,0);
});
test('AU authority apply rechecks a removed resolve right at publication',async t=>{
const f=world(t,{removeResolverPrepared:true}),d=await f.raise(),before=f.writer.read('demo').revision;
await assert.rejects(f.call('judge','decision.resolve',{id:d.id,choice:'yes'}),{code:'not-resolver'});
assert.equal(f.writer.read('demo').revision,before);f.writer.recover('demo');assert.equal(f.writer.read('demo').revision,before);
});
test('AU authority hire reverse runs normal fire/revoke and records committed delisting',async t=>{
const f=world(t),d=await f.raise({op:'hire',name:'helper'});await f.call('judge','decision.resolve',{id:d.id,choice:'yes'});
assert.ok(f.writer.read('demo').document.launch.instances.includes('helper'));
const r=await f.reverse(d.id);assert.equal(r.committed,true);assert.ok(!f.writer.read('demo').document.launch.instances.includes('helper'));assert.deepEqual(f.order,['fire-revoke']);
assert.equal(f.store.get("SELECT count(*) n FROM events WHERE json_extract(body,'$.operation')='autonomy.reverse'").n,1);
});
test('AU completion-event failure after durable authority inverse records committed recovery, never reverse.failed',async t=>{
const f=world(t),d=await f.raise();await f.call('judge','decision.resolve',{id:d.id,choice:'yes'});
const run=f.store.run.bind(f.store);let injected=false;
f.store.run=(sql,...args)=>{
if(!injected && sql.includes('INSERT INTO events') && args.some(x=>typeof x==='string' && x.startsWith('{') && JSON.parse(x).action==='fire.done')){
injected=true;throw new Error('injected completion event failure');
}
return run(sql,...args);
};
const r=await f.reverse(d.id);assert.equal(injected,true);assert.equal(r.committed,true);assert.equal(r.completion,'failed');assert.equal(r.restart,true);assert.equal(r.relaunch,'pending');
assert.equal(f.writer.read('demo').document.definitions.scout.instructions,'Prior role');
assert.equal(f.writer.read('demo').document.definitions.scout.rev,5);
assert.equal(f.store.get("SELECT count(*) n FROM events WHERE json_extract(body,'$.operation')='autonomy.reverse'").n,1);
assert.equal(f.store.get("SELECT count(*) n FROM events WHERE json_extract(body,'$.operation')='autonomy.reverse.failed'").n,0);
});
test('AU inverse receipt-write failure returns committed with trailPending and never writes reverse.failed',async t=>{
const f=world(t),d=await f.raise();await f.call('judge','decision.resolve',{id:d.id,choice:'yes'});
const run=f.store.run.bind(f.store);let injected=false;
f.store.run=(sql,...args)=>{
if(sql.includes('INSERT INTO events') && args.some(x=>typeof x==='string' && x.startsWith('{') && JSON.parse(x).operation==='autonomy.reverse')){
injected=true;throw new Error('injected receipt write failure');
}
return run(sql,...args);
};
const r=await f.reverse(d.id);assert.equal(injected,true);assert.equal(r.committed,true);assert.equal(r.trailPending,true);assert.equal(r.relaunch,'requested');
assert.equal(f.writer.read('demo').document.definitions.scout.instructions,'Prior role');
assert.equal(f.store.get("SELECT count(*) n FROM events WHERE json_extract(body,'$.operation')='autonomy.reverse.failed'").n,0);
});
test('AU an account-quota authority raise routes to the resolver only when the host says every harness spends the account (#1873)',async t=>{
const covered=world(t,{account:true,countsAccount:name=>name==='pi'}),d=await covered.raise();assert.equal(d.route_to,'judge');
for(const countsAccount of [undefined,()=>false,()=>null]){
const f=world(t,{account:true,countsAccount});
await assert.rejects(f.raise(),{code:'autonomy-precondition'});
assert.equal(f.store.get("SELECT count(*) n FROM decisions").n,0);
}
});
// #1873 review finding 2: the runtime itself carries each harness module's countsAccount into the
// broker's authority budget check. Only harness modules decide; the business names none here but
// through plugins.harness.
for(const [harness,code] of [['claude-code',null],['pi','autonomy-precondition']])test(`AU runtime: an account-quota authority setting with only ${harness} enabled ${code?'refuses':'saves'} (#1873)`,async t=>{
const {startBroker}=await import('../src/runtime.mjs');
const {systemFor}=await import('../../organization/tests/helpers.mjs');
const root=mkdtempSync(join(tmpdir(),'au-runtime-')),rolesDir=rolesCopy(root);t.after(()=>rmSync(root,{recursive:true,force:true}));
const doc=businessDoc(root,'demo');doc.plugins={harness:[harness],'limit-check':['token-limit']};
for(const r of Object.values(doc.roles))if(r.vars?.harness)r.vars.harness=harness;
doc.launch.account={name:'shared',credential:'FIXTURE_TOKEN'};doc.launch.limits.tokens={keys:{shared:cap}};
doc.definitions={judge:{rev:1,role:{...inline,authority:{withinRole:['decision.resolve.authority'],crossRole:[]}},instructions:'Judge'}};
delete doc.definitions.judge.role.instructions;
doc.roles.judge={definition:'org:judge',vars:{harness,model:'claude-opus-5-5'}};
writeJson(join(root,'businesses/demo.json'),doc);mkdirSync(join(root,'data'),{mode:0o700});
const loaded=()=>{const b=loadBusiness('demo',{dir:root,rolesDir});return {...b,roles:Object.fromEntries(Object.entries(b.roles).map(([n,e])=>[n,{...b.definitions[e.definition],...e}]))};};
const bus=await startBroker({dataRoot:join(root,'data'),businesses:{demo:loaded()},organization:{dir:root,rolesDir,system:systemFor(root)}});t.after(()=>bus.close());
const owner=bus.broker.bindConsole({business:'demo',human:'jason',subject:{provider:'test',iss:'test',sub:'owner'}});
const {revision}=await bus.broker.request(owner,{verb:'autonomy.get.console'});
const set=bus.broker.request(owner,{verb:'autonomy.set.console',args:{revision,autonomy:{authority:'near the loop',authorityResolver:'judge',notices:true}}});
if(code)await assert.rejects(set,{code});
else {await set;assert.equal(JSON.parse(readFileSync(join(root,'businesses/demo.json'),'utf8')).autonomy.authority,'near the loop');}
});
+70
View File
@@ -0,0 +1,70 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {mkdtempSync,mkdirSync,writeFileSync,rmSync} from 'node:fs';
import {tmpdir} from 'node:os';
import {join} from 'node:path';
import {execFileSync} from 'node:child_process';
import {Store} from '../src/store.mjs';
import {Broker} from '../src/broker.mjs';
import {autonomyHost} from '../src/autonomy-host.mjs';
import {initializeWorking,workingRepo,push,prepareLand,finishLand,dropLand} from '../../seat/src/working.mjs';
import {gitEnv} from '../../seat/src/checkout.mjs';
const TASK='vikunja:1/2',SUBJECT={provider:'test',iss:'https://issuer.test',sub:'owner'};
const git=(cwd,...args)=>execFileSync('git',['-c','user.name=Test','-c','[email protected]',...args],{cwd,encoding:'utf8',env:gitEnv()}).trim();
async function world(t,level='near the loop') {
const dir=mkdtempSync(join(tmpdir(),'au-')),root=join(dir,'root'),dataRoot=join(dir,'data');mkdirSync(root);mkdirSync(dataRoot,{mode:0o700});
git(root,'init','-q','-b','main');writeFileSync(join(root,'file'),'before\n');git(root,'add','file');git(root,'commit','-qm','Initial');
const before=git(root,'rev-parse','HEAD');
const clone=join(dir,'clone');git(dir,'clone','-q',root,clone);git(clone,'checkout','-qb','feat');writeFileSync(join(clone,'file'),'after\n');git(clone,'commit','-qam','Change');const reviewed=git(clone,'rev-parse','HEAD');
const bundle=join(dir,'bundle');git(clone,'bundle','create',bundle,'refs/heads/feat','--not','--remotes=origin');
await initializeWorking({dataRoot,business:'demo',project:'stack',root});
await push({dataRoot,business:'demo',project:'stack',root,taskRef:TASK,branch:'feat',bundle});
const authority=(withinRole=[],crossRole=[])=>({withinRole,crossRole});
const business={id:'demo',human:'jason',projects:{stack:{root}},autonomy:{merge:level,notices:true,reverseWindowHours:1},arbiters:{technical:'cto',delivery:'cto'},roles:{cto:{definition:'cto',authority:authority(['decision.resolve.technical'])},coder:{definition:'coder',authority:authority(['git.push.working','review.request','message.send'])},reviewer:{definition:'reviewer',authority:authority(['review.verdict','message.send'])},gate:{definition:'merge-gate',authority:authority([],['git.merge.protected'])}}};
let gates=0;
const host=autonomyHost({dataRoot,business:()=>business,processing:{work:fn=>fn({guard(){},signal:undefined})},gate:{classify:async()=>true,run:async()=>{gates++;return {ok:true};}}});
const store=new Store(dataRoot),broker=new Broker({store,businesses:{demo:business},autonomy:host}),caps={};
t.after(()=>{store.close();rmSync(dir,{recursive:true,force:true});});
const call=(who,verb,args={})=>broker.request(caps[who],{verb,args});
for(const role of Object.keys(business.roles)){caps[role]=broker.bindLaunch({business:'demo',role,run:role+'-run',harness:'pi'});call(role,'role.claim');}
caps.human=broker.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});
caps.owner=broker.bindConsole({business:'demo',human:'jason',subject:SUBJECT});
broker.checkPush(caps.coder,TASK);broker.recordPush(caps.coder,{task_ref:TASK,project:'stack',branch:'feat',ref:'work/vikunja-1-2',commit:reviewed,previous:null});
const request=call('coder','review.request',{task_ref:TASK,commit:reviewed,to:'reviewer'});
call('reviewer','review.verdict',{task_ref:TASK,request:request.request,verdict:'approve',body:'Reviewed'});
const d=call('gate','decision.raise',{action:'git.merge.protected',project:'stack',target:TASK,question:'Land work/vikunja-1-2?',options:[{key:'yes',text:'Land change'},{key:'no',text:'Hold'}],recommendation:'yes',blocking:false});
call('cto','decision.resolve',{id:d.id,choice:'yes'});
const land=async()=>{
const facts=broker.checkLand(caps.gate,{task_ref:TASK,decision:d.id});
const logs=join(dir,'land');mkdirSync(logs,{recursive:true});
const prepared=await prepareLand({dataRoot,business:'demo',project:'stack',root,taskRef:TASK,commit:reviewed,dir:logs,name:'original',rootHits:()=>[],message:()=> 'Change'});
try {await finishLand({dataRoot,business:'demo',project:'stack',land:prepared,record:r=>broker.recordLand(facts.ticket,{commit:r.commit,previous:r.previous,reviewed,base:prepared.base,patch_id:prepared.patch_id})});}finally{await dropLand({dataRoot,business:'demo',project:'stack',worktree:prepared.worktree});}
return prepared.commit;
};
return {broker,business,store,caps,call,d,before,land,gates:()=>gates,show:(...args)=>git(dir,`--git-dir=${workingRepo(dataRoot,'demo','stack')}`,...args)};
}
test('AU near merge lands immediately, owner notice reverses through gate and records a real child revert',async t=>{
const f=await world(t),landed=await f.land();
assert.equal(f.show('show','main:file'),'after');assert.equal(f.call('human','inbox').find(x=>x.id===f.d.id).autonomy.notice,true);
const reverse=await f.call('owner','decision.reverse.console',{id:f.d.id});
assert.equal(f.gates(),1);assert.equal(f.show('show','main:file'),'before');assert.equal(f.show('rev-parse','main^'),landed);
assert.match(f.show('log','-1','--format=%B','main'),new RegExp(f.d.id));
assert.equal(f.store.get("SELECT count(*) n FROM events WHERE json_extract(body,'$.operation')='autonomy.reverse'").n,1);
assert.equal(reverse.reverted,landed);
});
test('AU in-loop landing refuses until confirmation; decline requires a new arbiter resolution',async t=>{
const f=await world(t,'in the loop');
await assert.rejects(f.land(),e=>['not-resolved','confirmation-required'].includes(e.code));
f.call('owner','decision.confirm.console',{id:f.d.id,confirm:false,reason:'Review again'});
assert.throws(()=>f.call('owner','decision.confirm.console',{id:f.d.id,confirm:true}),{code:'decision-not-eligible'});
f.call('cto','decision.resolve',{id:f.d.id,choice:'yes'});f.call('owner','decision.confirm.console',{id:f.d.id,confirm:true});
await f.land();assert.equal(f.show('show','main:file'),'after');
assert.throws(()=>f.call('owner','decision.confirm.console',{id:f.d.id,confirm:true}),{code:'decision-not-eligible'});
});
test('AU landing rechecks the resolver right both before land and after the gate',async t=>{
const f=await world(t);const facts=f.broker.checkLand(f.caps.gate,{task_ref:TASK,decision:f.d.id});
f.business.roles.cto.authority.withinRole=[];f.broker.reloadBusiness('demo',f.business);
assert.throws(()=>f.broker.checkLand(f.caps.gate,{task_ref:TASK,decision:f.d.id}),{code:'not-resolver'});
assert.throws(()=>f.broker.recordLand(facts.ticket,{commit:'a'.repeat(40),previous:f.before,reviewed:'b'.repeat(40),base:f.before,patch_id:'c'.repeat(40)}),{code:'not-resolver'});
assert.equal(f.show('rev-parse','main'),f.before);
});
+164
View File
@@ -0,0 +1,164 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { fixture } from './spec-fixtures.mjs';
const SUBJECT = {provider:'test',iss:'https://issuer.test',sub:'owner'};
async function setup(t, autonomy, autonomyHost=null) {
const f=await fixture(t,{autonomyHost,roles:{coder:{definition:'coder',authority:{withinRole:[],crossRole:['git.merge.protected','task.scope.change','policy.change','prd.approve']}}}});
if(autonomy) {f.business.autonomy=autonomy;f.broker.reloadBusiness('demo',f.business);}
f.owner=f.broker.bindConsole({business:'demo',human:'jason',subject:SUBJECT});
return f;
}
test('AU no setting keeps merge with technical arbiter',async t=>{
const f=await setup(t);const d=f.raise({action:'git.merge.protected'});assert.equal(d.route_to,'chief');
});
test('AU in-loop merge keeps the arbiter resolver, waits for person, decline returns to arbiter',async t=>{
const f=await setup(t,{merge:'in the loop'}),d=f.raise({action:'git.merge.protected',target:'vikunja:1/2'});
assert.equal(d.route_to,'chief');
assert.throws(()=>f.call(f.human,'decision.resolve',{id:d.id,choice:'yes'}),{code:'not-resolver'});
f.call(f.chief,'decision.resolve',{id:d.id,choice:'yes'});
assert.throws(()=>f.broker.authorize(f.coder,'git.merge.protected',{decision:d.id,target:'vikunja:1/2'}),{code:'confirmation-required'});
f.call(f.owner,'decision.confirm.console',{id:d.id,confirm:false,reason:'Please revise'});
assert.equal(f.call(f.chief,'inbox').find(x=>x.id===d.id)?.id,d.id);
f.call(f.chief,'decision.resolve',{id:d.id,choice:'yes'});
f.call(f.owner,'decision.confirm.console',{id:d.id,confirm:true});
assert.equal(f.broker.authorize(f.coder,'git.merge.protected',{decision:d.id,target:'vikunja:1/2'}).decision,d.id);
});
test('AU R5 subject resolver or resolver definition routes to person',async t=>{
const f=await setup(t);
for(const target of ['chief','chief-of-staff'])assert.equal(f.raise({action:'policy.change',target}).route_to,'human');
});
test('AU mismatched PRD domain refuses at raise with expected domain',async t=>{
const f=await setup(t);
assert.throws(()=>f.raise({action:'prd.approve',domain:'delivery'}),e=>e.code==='invalid-domain' && /technical/.test(e.reason));
assert.equal(f.store.get("SELECT count(*) n FROM decisions WHERE action='prd.approve'").n,0);
});
test('AU revoked resolver right refuses when an approved decision is consumed',async t=>{
const f=await setup(t),d=f.raise({action:'task.scope.change',target:'vikunja:1/2'});
f.call(f.chief,'decision.resolve',{id:d.id,choice:'yes'});
f.business.roles.chief.authority.withinRole=[];f.broker.reloadBusiness('demo',f.business);
assert.throws(()=>f.broker.authorize(f.coder,'task.scope.change',{decision:d.id,target:'vikunja:1/2'}),{code:'not-resolver'});
});
test('AU member, agent and dev tester cannot change settings',async t=>{
const revision='a'.repeat(64),f=await setup(t,null,{get:()=>({revision,autonomy:{}})});
const member=f.broker.bindHireConsole({business:'demo',actor:'member',subject:{...SUBJECT,sub:'member'},rights:['org.hire']});
for(const cap of [f.coder,member])assert.throws(()=>f.call(cap,'autonomy.set.console',{revision,autonomy:{merge:'near the loop'}}),{code:'human-required'});
assert.throws(()=>f.call(f.human,'autonomy.set.console',{revision,autonomy:{merge:'near the loop'}}),{code:'human-required'});
});
test('AU near-loop merge sends owner a bounded notice; expired reverse names ordinary path',async t=>{
const f=await setup(t,{merge:'near the loop',notices:true,reverseWindowHours:1}),d=f.raise({action:'git.merge.protected'});
f.call(f.chief,'decision.resolve',{id:d.id,choice:'yes'});
const n=f.call(f.human,'inbox').find(x=>x.id===d.id);
assert.equal(n.autonomy.notice,true);assert.ok(Date.parse(n.autonomy.reverseUntil)>Date.now());
const now=Date.now;
try{Date.now=()=>now()+3600001;await assert.rejects(Promise.resolve().then(()=>f.call(f.owner,'decision.reverse.console',{id:d.id})),e=>e.code==='reverse-window-closed' && /ordinary.*decision/.test(e.reason));}finally{Date.now=now;}
});
test('AU no setting can move each hard wait from a person',async t=>{
const f=await setup(t,{'cross-role':'out of the loop',merge:'near the loop',authority:'out of the loop'});
for(const action of ['git.push.protected','host.sudo','credential.mint','spend','deploy','data.delete']){
if(action==='host.sudo')continue; // Its host grant precondition is covered in hard-waits.test.mjs.
assert.equal(f.raise({action}).route_to,'human');
}
});
test('AU owner setting changes have immutable actor trail; bad classes/levels refuse; tester refused',async t=>{
let f,autonomy={},revision='a'.repeat(64);
const host={get:()=>({autonomy,revision}),set:(_b,a)=>{autonomy=a.autonomy;revision='b'.repeat(64);f.business.autonomy=autonomy;f.broker.reloadBusiness('demo',f.business);return {revision,transaction:'transaction-1'};},reversible:()=>true};
f=await setup(t,null,host);
await f.call(f.owner,'autonomy.set.console',{revision,autonomy:{merge:'in the loop'}});
assert.equal(f.store.all("SELECT actor_role,actor_run,body FROM events WHERE json_extract(body,'$.operation')='autonomy.set'").length,1);
const row=f.store.get("SELECT body FROM events WHERE json_extract(body,'$.operation')='autonomy.set'");assert.deepEqual(JSON.parse(row.body).subject,SUBJECT);
for(const value of [{unknown:'near the loop'},{merge:'fast'}])await assert.rejects(f.call(f.owner,'autonomy.set.console',{revision,autonomy:value}),{code:'invalid-autonomy'});
f.business.vars={'bus.tester':true};f.broker.reloadBusiness('demo',f.business);const tester=f.broker.bindTester({business:'demo'});
assert.throws(()=>f.call(tester,'autonomy.set.console',{revision,autonomy:{}}),{code:'human-required'});
assert.throws(()=>f.broker.recordEvent(f.coder,{kind:'action.allowed',body:{operation:'autonomy.confirmed',decision:'forged'}}),{code:'reserved-event'});
});
test('AU reverse consumes once and rechecks the window at the effect boundary',async t=>{
let effects=0;
const host={reverse:async({beforeWrite,record})=>{beforeWrite();effects++;return {commit:'b'.repeat(40)};}};
const f=await setup(t,{merge:'near the loop',notices:true},host),d=f.raise({action:'git.merge.protected'});
f.call(f.chief,'decision.resolve',{id:d.id,choice:'yes'});
const r=await f.call(f.owner,'decision.reverse.console',{id:d.id});assert.equal(r.reversed,true);assert.equal(effects,1);
await assert.rejects(f.call(f.owner,'decision.reverse.console',{id:d.id}),{code:'decision-reversed'});
assert.equal(f.store.get("SELECT count(*) n FROM events WHERE json_extract(body,'$.operation')='autonomy.reverse'").n,1);
});
test('AU a near-loop reverse that crosses the deadline cannot write',async t=>{
let effects=0;const now=Date.now;
const f=await setup(t,{merge:'near the loop',notices:true,reverseWindowHours:1},{reverse:async({beforeWrite})=>{Date.now=()=>now()+3600001;beforeWrite();effects++;return {};}});
const d=f.raise({action:'git.merge.protected'});f.call(f.chief,'decision.resolve',{id:d.id,choice:'yes'});
try{await assert.rejects(f.call(f.owner,'decision.reverse.console',{id:d.id}),{code:'reverse-window-closed'});}finally{Date.now=now;}
assert.equal(effects,0);
});
test('AU policy near refuses with a named next step; in and the existing out level remain usable',async t=>{
let f,autonomy={},revision='a'.repeat(64);
const host={get:()=>({autonomy,revision}),set:(_b,a)=>{autonomy=a.autonomy;revision='b'.repeat(64);f.business.autonomy=autonomy;f.broker.reloadBusiness('demo',f.business);return {revision,transaction:'policy-setting'};},reversible:()=>false};
f=await setup(t,null,host);
await assert.rejects(f.call(f.owner,'autonomy.set.console',{revision,autonomy:{policy:'near the loop'}}),e=>e.code==='policy-reverse-unavailable' && /in the loop or out of the loop/.test(e.nextStep));
assert.deepEqual(autonomy,{});
await f.call(f.owner,'autonomy.set.console',{revision,autonomy:{policy:'in the loop'}});
const d=f.raise({action:'policy.change',target:'policy-1'});f.call(f.chief,'decision.resolve',{id:d.id,choice:'yes'});
assert.throws(()=>f.broker.authorize(f.coder,'policy.change',{decision:d.id,target:'policy-1'}),{code:'confirmation-required'});
f.call(f.owner,'decision.confirm.console',{id:d.id,confirm:true});
assert.equal(f.broker.authorize(f.coder,'policy.change',{decision:d.id,target:'policy-1'}).decision,d.id);
await f.call(f.owner,'autonomy.set.console',{revision,autonomy:{policy:'out of the loop'}});
const out=f.raise({action:'policy.change',target:'policy-2'});f.call(f.chief,'decision.resolve',{id:out.id,choice:'yes'});
assert.equal(f.broker.authorize(f.coder,'policy.change',{decision:out.id,target:'policy-2'}).decision,out.id);
});
test('AU near cross-role authorization without a trusted inverse fails closed before consumption',async t=>{
const f=await setup(t,{'cross-role':'near the loop',notices:true}),d=f.raise({action:'task.scope.change',target:'vikunja:1/2'});
f.call(f.chief,'decision.resolve',{id:d.id,choice:'yes'});
assert.throws(()=>f.broker.authorize(f.coder,'task.scope.change',{decision:d.id,target:'vikunja:1/2'}),{code:'reversal-unavailable'});
assert.equal(f.store.get("SELECT count(*) n FROM events WHERE json_extract(body,'$.decision')=? AND json_extract(body,'$.action')='task.scope.change' AND json_extract(body,'$.operation') IS NOT 'decision.raise'",d.id).n,0);
});
test('AU a withdrawal after decline is terminal for resolver, owner and confirmation',async t=>{
const f=await setup(t,{merge:'in the loop'}),d=f.raise({action:'git.merge.protected'});
f.call(f.chief,'decision.resolve',{id:d.id,choice:'yes'});
f.call(f.owner,'decision.confirm.console',{id:d.id,confirm:false,reason:'Revise'});
f.call(f.coder,'decision.withdraw',{id:d.id});
assert.throws(()=>f.call(f.chief,'decision.resolve',{id:d.id,choice:'yes'}),{code:'decision-closed'});
for(const cap of [f.chief,f.human])assert.ok(!f.call(cap,'inbox').some(x=>x.id===d.id));
assert.throws(()=>f.call(f.owner,'decision.confirm.console',{id:d.id,confirm:true}),e=>['decision-closed','decision-not-eligible'].includes(e.code));
});
async function settingsFixture(t) {
let f,autonomy={},revision='a'.repeat(64),writes=0;
const host={get:()=>({autonomy,revision}),set:(_b,a)=>{writes++;autonomy=a.autonomy;revision='b'.repeat(64);f.business.autonomy=autonomy;f.broker.reloadBusiness('demo',f.business);return {revision,transaction:'setting-test'};},reversible:()=>true};
f=await setup(t,null,host);
f.business.roles.judge={definition:'judge',authority:{withinRole:['decision.resolve.authority'],crossRole:[]}};
f.business.roles.reviewer.authority.withinRole.push('review.verdict');
f.business.launch={by:'chief',models:{model:{provider:'anthropic',model:'test-model'}},limits:{tokens:{}}};
f.business.plugins={'limit-check':['token-limit']};
f.broker.reloadBusiness('demo',f.business);
return {...f,set:value=>f.call(f.owner,'autonomy.set.console',{revision,autonomy:value}),writes:()=>writes};
}
const authorityNear={authority:'near the loop',authorityResolver:'judge',notices:true};
const tokenCap={limit:1000,since:'2026-01-01T00:00:00.000Z'};
test('AU authority near requires an enforced organization budget covering every allowed model, including zero caps',async t=>{
const f=await settingsFixture(t);
for(const tokens of [{},{instances:{coder:tokenCap}},{providers:{unrelated:tokenCap}}]){
f.business.launch.limits.tokens=tokens;f.broker.reloadBusiness('demo',f.business);
await assert.rejects(f.set(authorityNear),{code:'autonomy-precondition'});
}
f.business.launch.limits.tokens={providers:{anthropic:{...tokenCap,limit:0}}};
f.business.plugins={'limit-check':[]};f.broker.reloadBusiness('demo',f.business);
await assert.rejects(f.set(authorityNear),{code:'autonomy-precondition'});
f.business.plugins={'limit-check':['token-limit']};f.broker.reloadBusiness('demo',f.business);
await f.set(authorityNear);assert.equal(f.writes(),1);
});
test('AU authority out refuses until per-definition agent limits exist, even with current agents capped',async t=>{
const f=await settingsFixture(t);
f.business.launch.limits.tokens={providers:{anthropic:tokenCap},instances:Object.fromEntries(Object.keys(f.business.roles).map(n=>[n,tokenCap]))};
f.broker.reloadBusiness('demo',f.business);await f.set(authorityNear);
f.call(f.owner,'autonomy.review.console',{decisionClass:'authority'});
await assert.rejects(f.set({...authorityNear,authority:'out of the loop'}),e=>e.code==='authority-limits-unavailable' && /near the loop/.test(e.nextStep));
assert.equal(f.writes(),1);
});
test('AU moving out requires an actual near resolution and a later owner review',async t=>{
const f=await settingsFixture(t);await f.set({merge:'in the loop'});await f.set({merge:'near the loop',notices:true});
f.call(f.owner,'autonomy.review.console',{decisionClass:'merge'});
await assert.rejects(f.set({merge:'out of the loop',notices:true}),{code:'autonomy-precondition'});
const d=f.raise({action:'git.merge.protected'});f.call(f.chief,'decision.resolve',{id:d.id,choice:'yes'});
await assert.rejects(f.set({merge:'out of the loop',notices:true}),{code:'autonomy-precondition'});
f.call(f.owner,'autonomy.review.console',{decisionClass:'merge'});
await f.set({merge:'out of the loop',notices:true});
});
+3 -2
View File
@@ -89,7 +89,7 @@ function setup(t) {
// #797 / #1839 adds the owner-only artifact read to the existing decision and request controls;
// no unsuffixed verb is ever a console verb.
test('N4: owner Console verbs have an explicit immutable whitelist and exclude decision.resolve', () => {
assert.deepEqual([...CONSOLE_VERBS], ['system-types.console', 'project.remote.get.console', 'project.remote.set.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'hire.preview.console', 'hire.resolve.console', 'hire.start.console', 'hire.templates.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
assert.deepEqual([...CONSOLE_VERBS], ['system-types.console', 'project.remote.get.console', 'project.remote.set.console', 'autonomy.get.console', 'autonomy.set.console', 'autonomy.review.console', 'decision.confirm.console', 'decision.reverse.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'hire.preview.console', 'hire.resolve.console', 'hire.start.console', 'hire.templates.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
'request.get.console', 'request.projects.console', 'request.answer.console', 'request.attach.console', 'request.cancel.console']);
assert.ok(CONSOLE_VERBS.every(verb => verb.endsWith('.console')));
assert.equal(CONSOLE_VERBS.includes('decision.resolve'), false);
@@ -227,7 +227,8 @@ test('ordinary PRD approval and nine non-yes/no choices use the same protocol wi
test('ordinary role routes refuse Console resolution and agent answers retain the actual role/run', (t) => {
const f = setup(t);
for (const route of ['pm', 'chief']) {
const id = grantDecision(f.store, { action: 'policy.change', route, close: null });
const id = grantDecision(f.store, { action: 'policy.change', route, raiser:route==='chief'?'pm':'chief', close: null });
refusesSync(() => f.call('console', 'decision.preview.console', { id }), 'decision-not-eligible');
refusesSync(() => f.call('console', 'decision.resolve.console', { id, choice: 'yes', digest: '0'.repeat(64) }), 'decision-not-eligible');
f.call(route, 'decision.resolve', { id, choice: 'yes' });
+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);
});
+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);
@@ -6,6 +6,7 @@
// keys are real local ssh-keygen pairs in the scratch store, and no ssh connection is made. Decisions a
// test needs outside the human's yes go in by SQL as fixtures.
import test from 'node:test';
import {autonomyHost} from '../src/autonomy-host.mjs';
import { hireServiceFixture, fixtureConsole, approveFixtureFire } from './hire-fixtures.mjs';
import assert from 'node:assert/strict';
import { createHash } from 'node:crypto';
@@ -75,7 +76,7 @@ function makeBusiness(url) {
};
}
async function setup(t) {
async function setup(t,autonomy=null) {
const dir = mkdtempSync(join(tmpdir(), 'g281-'));
t.after(() => rmSync(dir, { recursive: true, force: true }));
const gitea = await fakeGiteaApi(t, join(dir, 'tls'));
@@ -157,7 +158,7 @@ async function setup(t) {
return { revision: REV };
};
const make = () =>
new Broker({hires:hireServiceFixture,
new Broker({hires:hireServiceFixture,autonomy,
store,
businesses: { demo: structuredClone(doc) },
secretCheck: (value) => credentials.assertClean(value),
@@ -997,3 +998,24 @@ test("row 239 T4b: an unmarked step B refusal keeps its code, and the notice say
assert.ok(message.includes('(revision-conflict). The instance is still firing.'), message);
assert.ok(!message.includes('may already be revoked'), message);
});
for(const expired of [false,true])test(`AU hire inverse waits for grant removal and refuses late publication (expired: ${expired})`,async t=>{
const host=autonomyHost({hires:{reverse:async(id,decision,caller)=>caller.fire({id:decision,level:'org',name:'coder',revision:REV,digest:'a'.repeat(64)})},reload:()=>true});
const f=await setup(t,host);await f.approveHost('coder','web1');
const decision=grantDecision(f.store,{action:'role.hire',route:'pm',close:{op:'resolved',by:'pm-run',choice:'yes',via:'broker'}});
const at=new Date().toISOString(),deadline=new Date(Date.now()+3600000).toISOString();
f.store.run("INSERT INTO events(id,at,business,kind,subject,body) VALUES(?,?,?,'action.allowed',?,?)",decision,at,'demo',decision,JSON.stringify({operation:'autonomy.notice',decision,decisionClass:'authority',reverseUntil:deadline}));
f.held.add('web1:remove');
const owner=fixtureConsole(f.broker),pending=f.broker.request(owner,{verb:'decision.reverse.console',args:{id:decision}});
// Attach the rejection handler before letting the asynchronous revoke complete.
const outcome=pending.then(value=>({value}),error=>({error}));
await until(()=>f.sent.some(s=>s.startsWith('remove web1')));
assert.equal(f.store.get("SELECT count(*) n FROM events WHERE json_extract(body,'$.operation')='autonomy.reverse'").n,0);assert.ok(f.doc.launch.instances.includes('coder'));
const clock=Date.now;
try {
if(expired)Date.now=()=>Date.parse(deadline)+1;
f.held.delete('web1:remove');const result=await outcome;await f.broker.grantsIdle();
if(expired){assert.equal(result.error?.code,'reverse-window-closed');assert.ok(f.doc.launch.instances.includes('coder'));}
else{assert.equal(result.error,undefined);assert.equal(result.value.committed,true);assert.match(result.value.transaction,/^t-/);assert.equal(f.store.get("SELECT count(*) n FROM events WHERE json_extract(body,'$.operation')='autonomy.reverse'").n,1);assert.ok(!f.doc.launch.instances.includes('coder'));}
}finally{Date.now=clock;f.held.clear();}
});
@@ -0,0 +1,143 @@
// D3 (P0-4, readiness-1111): an organization on the fixture model provider, with no model proxy
// configured, changes routes and thinking, takes limits.set and starts a hire. The real catalogue
// service runs here (no modelCatalogue stand-in), so a fixture-only organization reads ready
// without a proxy client. A thinking-only change saves while the catalogue is unavailable, a
// change that needs the proxy still refuses, and removing cliproxy (the Routing page's Disable)
// makes the next save apply.
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdirSync, mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { busOrganization } from '../src/organization-context.mjs';
import { startBroker } from '../src/runtime.mjs';
import { createModelCatalogue } from '../src/model-catalogue.mjs';
import { loadBusiness } from '../../organization/src/organization-config.mjs';
import { resolveInstance } from '../../organization/src/resolve.mjs';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../organization/tests/helpers.mjs';
import { FIXTURE_ENTRY, FIXTURE_KEY_NAME } from '../../plugins/model-provider/fixture/index.mjs';
import { approveTemplateHire } from './hire-fixtures.mjs';
function busForm(root, rolesDir, id = 'demo') {
const b = loadBusiness(id, { dir: root, rolesDir });
const resolved = {};
for (const instance of Object.keys(b.roles)) resolved[instance] = resolveInstance({ system: systemFor(root), business: b, instance });
return busOrganization(b, resolved);
}
async function setup(t, providers = ['fixture'], edit = () => {}) {
const root = mkdtempSync(join(tmpdir(), 'd3-fixture-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const rolesDir = rolesCopy(root);
const doc = businessDoc(root, 'demo');
doc.plugins = { harness: ['pi'], 'model-provider': providers };
doc.launch.models[FIXTURE_KEY_NAME] = { ...FIXTURE_ENTRY };
doc.roles.coder.vars = { harness: 'pi', model: FIXTURE_KEY_NAME, thinking: 'off', 'limits.network': 'none' };
// The audit's instance: the assistant on the fixture route.
doc.roles.assistant = { definition: 'assistant', vars: { harness: 'pi', model: FIXTURE_KEY_NAME, thinking: 'off' } };
edit(doc);
const file = writeJson(join(root, 'businesses/demo.json'), doc);
mkdirSync(join(root, 'data'), { mode: 0o700 });
// No proxy option: the runtime has no model proxy client and no proxy lock.
const bus = await startBroker({ dataRoot: join(root, 'data'), businesses: { demo: busForm(root, rolesDir) }, organization: { dir: root, rolesDir, system: systemFor(root) } });
t.after(() => bus.close());
const human = bus.broker.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const ask = (verb, args) => Promise.resolve().then(() => bus.broker.request(human, { verb, args }));
const saved = () => JSON.parse(readFileSync(file, 'utf8'));
return { root, rolesDir, file, bus, ask, saved };
}
const route = (thinking, model = FIXTURE_KEY_NAME) => ({ harness: 'pi', model, thinking, fallback: null });
// readiness-1111 evidence patch-low.json: `mosaic limits set --file patch-low.json`.
const patch = (revision, thinking) => ({ revision, assistants: { assistant: { definition: 'assistant', vars: { harness: 'pi', model: FIXTURE_KEY_NAME, thinking }, tracker: null } } });
test('D3 a fixture-only organization with no model proxy reads a ready catalogue and saves a thinking change and a fixture route', async t => {
const f = await setup(t);
const view = await f.ask('routes');
assert.equal(view.catalogue.status, 'ready');
assert.deepEqual(view.catalogue.models.map(m => m.key), [FIXTURE_KEY_NAME]);
const thinking = await f.ask('routes.set', { revision: view.revision, routes: { coder: route('low') } });
assert.ok(thinking.transaction);
assert.equal(f.saved().roles.coder.vars.thinking, 'low');
// A fixture route change: the reviewer moves onto the fixture model.
const moved = await f.ask('routes.set', { revision: thinking.revision, routes: { reviewer: route('off') } });
assert.ok(moved.transaction);
assert.equal(f.saved().roles.reviewer.vars.model, FIXTURE_KEY_NAME);
});
test('D3 limits.set takes the audit thinking patch on a fixture-only organization with no model proxy', async t => {
const f = await setup(t);
const { revision } = await f.ask('limits');
const result = await f.ask('limits.set', patch(revision, 'low'));
assert.ok(result.transaction);
assert.equal(f.saved().roles.assistant.vars.thinking, 'low');
});
test('D3 hire start on a fixture-only organization with no model proxy writes the hire', async t => {
const f = await setup(t);
const hired = await approveTemplateHire(f.bus.broker, f.rolesDir, { name: 'sec-1', definition: 'chief-information-security-officer', model: FIXTURE_KEY_NAME });
assert.ok(hired.transaction);
assert.equal(f.saved().roles['sec-1'].vars.model, FIXTURE_KEY_NAME);
});
test('D3 with cliproxy enabled and no model proxy, a thinking change saves, a proxy model refuses, and removing cliproxy lets a route change save', async t => {
const f = await setup(t, ['fixture', 'cliproxy']);
let view = await f.ask('routes');
assert.equal(view.catalogue.status, 'unavailable');
// Thinking only: same harness, model key and entry, so no catalogue is asked.
const thinking = await f.ask('routes.set', { revision: view.revision, routes: { coder: route('low') } });
assert.equal(f.saved().roles.coder.vars.thinking, 'low');
const limits = await f.ask('limits');
await f.ask('limits.set', patch(limits.revision, 'medium'));
assert.equal(f.saved().roles.assistant.vars.thinking, 'medium');
// A route change truly needs the catalogue, which the absent proxy makes unavailable.
const before = readFileSync(f.file, 'utf8');
view = await f.ask('routes');
await assert.rejects(f.ask('routes.set', { revision: view.revision, routes: { reviewer: route('off') } }), { code: 'model-catalogue-unavailable' });
await assert.rejects(f.ask('routes.set', { revision: view.revision, routes: { reviewer: route('off', 'zai-not-offered') } }), { code: 'model-catalogue-unavailable' });
assert.equal(readFileSync(f.file, 'utf8'), before);
assert.ok(thinking.transaction);
// Disable model proxy catalogue: plugins.set without cliproxy.
const settings = await f.ask('plugins');
await f.ask('plugins.set', { revision: settings.revision, plugins: { 'model-provider': ['fixture'] } });
view = await f.ask('routes');
assert.equal(view.catalogue.status, 'ready');
await f.ask('routes.set', { revision: view.revision, routes: { reviewer: route('off') } });
assert.equal(f.saved().roles.reviewer.vars.model, FIXTURE_KEY_NAME);
});
test('D3 the catalogue asks a module that needs no proxy even with no proxy client, and a module that asks the proxy reads unavailable', async () => {
let names = ['fixture'];
const service = createModelCatalogue({ client: () => null, settings: () => ({ 'model-provider': names }) });
const ready = await service.read('demo', { fresh: true });
assert.equal(ready.status, 'ready');
assert.deepEqual(ready.models.map(m => [m.key, m.available]), [[FIXTURE_KEY_NAME, true]]);
names = ['cliproxy'];
assert.equal((await service.read('demo', { fresh: true })).status, 'unavailable');
names = ['fixture', 'cliproxy'];
const both = await service.read('demo', { fresh: true });
assert.equal(both.status, 'unavailable');
assert.ok(both.models.every(m => m.available === false));
});
// D3 review (finding 1): Disable can't make a move between two model-proxy models apply, which is
// why the Routing page names Disable only for a move onto a model from another listed module.
test('D3 a move between model-proxy models refuses before and after cliproxy is removed, and writes nothing', async t => {
const proxyEntry = model => ({ provider: 'proxy', model, module: 'cliproxy', upstream: 'claude' });
const f = await setup(t, ['fixture', 'cliproxy'], doc => {
doc.launch.models['claude-a'] = proxyEntry('claude-a');
doc.launch.models['claude-b'] = proxyEntry('claude-b');
doc.roles.reviewer.vars = { ...doc.roles.reviewer.vars, harness: 'pi', model: 'claude-a', thinking: 'off' };
});
const before = readFileSync(f.file, 'utf8');
let view = await f.ask('routes');
await assert.rejects(f.ask('routes.set', { revision: view.revision, routes: { reviewer: route('off', 'claude-b') } }), { code: 'model-catalogue-unavailable' });
assert.equal(readFileSync(f.file, 'utf8'), before);
const settings = await f.ask('plugins');
await f.ask('plugins.set', { revision: settings.revision, plugins: { 'model-provider': ['fixture'] } });
const disabled = readFileSync(f.file, 'utf8');
view = await f.ask('routes');
assert.equal(view.catalogue.status, 'ready');
await assert.rejects(f.ask('routes.set', { revision: view.revision, routes: { reviewer: route('off', 'claude-b') } }), { code: 'model-provider-unavailable' });
assert.equal(readFileSync(f.file, 'utf8'), disabled);
assert.notEqual(disabled, before);
});
+1 -1
View File
@@ -66,7 +66,7 @@ test('REQ-DEC-2: a gated raise citing the current spec goes to the arbiter for i
const raise = { action, ...(action === 'role.revoke' ? { target: 'reviewer' } : {}) };
assert.deepEqual(routed(f, f.raise(raise)), { class: 'gated', route_to: 'human' }, action);
assert.deepEqual(routed(f, await f.raise({ ...raise, ...cite(f), domain: 'technical' })), { class: 'gated', route_to: action === 'policy.change' ? 'chief' : 'cto' }, action);
assert.deepEqual(routed(f, await f.raise({ ...raise, ...cite(f) })), { class: 'gated', route_to: action === 'git.merge.protected' ? 'cto' : 'chief' }, action);
assert.deepEqual(routed(f, await f.raise({ ...raise, ...cite(f) })), { class: 'gated', route_to: ['git.merge.protected','prd.approve'].includes(action) ? 'cto' : 'chief' }, action);
}
});
@@ -205,3 +205,36 @@ test('A4 inbox and decision reads expire unopened hires once without changing co
} finally {Date.now=original;}
assert.deepEqual(readFileSync(f.file),before);assert.equal(f.writes(),0);
});
test('AU a distinct authority resolver applies a plain near-loop hire, while flagged and credential hires stay human',async t=>{
const f=fixture(t),doc=JSON.parse(readFileSync(f.file,'utf8'));
doc.definitions={resolver:{rev:1,role:{tools:['read'],network:'none',authority:{withinRole:['decision.resolve.technical','decision.resolve.authority'],crossRole:[]},credentials:[]},instructions:'Resolve independent decisions.'}};
doc.roles.cto={definition:'org:resolver'};
doc.plugins['limit-check']=['token-limit'];doc.launch.limits={tokens:{providers:{zai:{limit:100000,since:'2026-01-01T00:00:00.000Z'}}}};
doc.autonomy={authority:'near the loop',authorityResolver:'cto',notices:true};writeJson(f.file,doc);
const live=loadBusiness('demo',{dir:f.root,rolesDir:f.rolesDir});
const business={...live,roles:Object.fromEntries(Object.entries(live.roles).map(([n,e])=>[n,{...live.definitions[e.definition],...e}]))};
business.roles.cto.authority={withinRole:['decision.resolve.authority'],crossRole:[]};f.b.reloadBusiness('demo',business);
const resolver=f.b.bindLaunch({business:'demo',role:'cto',run:'cto-run',harness:'pi'});f.call(resolver,'role.claim');
const d=await f.raise();assert.equal(d.route_to,'cto');
await assert.rejects(Promise.resolve().then(()=>f.call(f.caps.chief,'decision.resolve',{id:d.id,choice:'yes'})),{code:'not-resolver'});
await f.call(resolver,'decision.resolve',{id:d.id,choice:'yes'});
assert.equal(f.writes(),1);assert.equal(JSON.parse(readFileSync(f.file)).roles.scout.hireDecision,d.id);
for(const change of [{name:'flagged',role:{inline:{...INLINE,tools:['bash']}}},{name:'credential',role:{inline:{...INLINE,credentials:[{service:'gitea',scopes:['read:repository']}]}}}]) {
const decision=await f.raise(change);assert.equal(decision.route_to,'human');
}
});
test('AU R5 a change naming the authority resolver stays with a person; no agent resolves itself',async t=>{
const f=fixture(t),doc=JSON.parse(readFileSync(f.file,'utf8'));
doc.definitions={judge:{rev:1,role:{tools:['read'],network:'none',authority:{withinRole:['decision.resolve.authority'],crossRole:[]},credentials:[]},instructions:'Resolve independent authority decisions.'}};
doc.roles.judge={definition:'org:judge',vars:{harness:'pi',model:'claude-opus-5-5'}};
doc.plugins['limit-check']=['token-limit'];doc.launch.limits={tokens:{providers:{zai:{limit:100000,since:'2026-01-01T00:00:00.000Z'}}}};
doc.autonomy={authority:'near the loop',authorityResolver:'judge',notices:true};writeJson(f.file,doc);
const loaded=loadBusiness('demo',{dir:f.root,rolesDir:f.rolesDir});
f.b.reloadBusiness('demo',{...loaded,roles:Object.fromEntries(Object.entries(loaded.roles).map(([n,e])=>[n,{...loaded.definitions[e.definition],...e}]))});
const judge=f.b.bindLaunch({business:'demo',role:'judge',run:'judge-run',harness:'pi'});f.call(judge,'role.claim');
const d=await f.raise({op:'change',name:'judge',role:{inline:{...INLINE,authority:{withinRole:['decision.resolve.authority'],crossRole:[]},instructions:'Narrow this resolver'}}});
assert.ok(d.hire.flagged.some(x=>x.value==='decision.resolve.authority'));assert.equal(d.route_to,'human');
await assert.rejects(Promise.resolve().then(()=>f.call(judge,'decision.resolve',{id:d.id,choice:'yes'})));
assert.equal(f.writes(),0);
});
+3 -3
View File
@@ -12,15 +12,15 @@ export const HUMAN_YES = { op: 'resolved', by: 'jason', choice: 'yes', via: 'cli
// A credential.mint decision with `grant` in its raise body. `close` is the decision_events row, or null
// for an open decision.
export function grantDecision(store, { business = 'demo', action = 'credential.mint', route = 'human', domain = 'delivery', grant, close = HUMAN_YES }) {
export function grantDecision(store, { business = 'demo', raiser = 'chief', action = 'credential.mint', route = 'human', domain = 'delivery', grant, close = HUMAN_YES }) {
const id = randomUUID();
store.run(
'INSERT INTO decisions(id,at,business,raised_by_role,raised_by_run,class,action,route_to,question,options,recommendation,blocking) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)',
id, AT, business, 'chief', 'chief-run', 'gated', action, route, 'Grant?', OPTIONS, 'yes', 0,
id, AT, business, raiser, raiser+'-run', 'gated', action, route, 'Grant?', OPTIONS, 'yes', 0,
);
store.run(
"INSERT INTO events(id,at,business,kind,actor_role,actor_run,subject,body) VALUES(?,?,?,'action.allowed',?,?,?,?)",
randomUUID(), AT, business, 'chief', 'chief-run', id,
randomUUID(), AT, business, raiser, raiser+'-run', id,
JSON.stringify({ decision: id, action, operation: 'decision.raise', ...(domain === null ? {} : { domain }), approvalChoice: 'yes', ...(grant ? { grant } : {}) }),
);
if (close)
+7
View File
@@ -346,3 +346,10 @@ for(const level of ['root','project'])test(`A4 ${level} fire removes its implici
const human=b.bus.broker.bindHuman({business:'demo',human:'jason',via:'cli',outsideAgent:true});
assert.throws(()=>b.bus.broker.request(human,{verb:'instance.launch',args:{instance}}));
});
test('AU resolver guard runs inside the model revocation queue before any remote or local effect',async t=>{
const f=await fixture(t);await f.manager.reconcile();await f.manager.ensure('demo/coder');
const before=f.credentials.modelRecord('demo/coder'),calls=f.state.calls.length;
await assert.rejects(f.manager.prepareRevoke('demo/coder',()=>{throw Object.assign(new Error('not-resolver'),{code:'not-resolver'});}),{code:'not-resolver'});
assert.deepEqual(f.credentials.modelRecord('demo/coder'),before);assert.equal(f.state.calls.length,calls);
});
+4 -1
View File
@@ -12,6 +12,8 @@ import { fixture } from './spec-fixtures.mjs';
test('Broker keeps the sandbox client import closure: only the listed imports, no lazy project import', () => {
const source = readFileSync(new URL('../src/broker.mjs', import.meta.url), 'utf8');
assert.deepEqual([...source.matchAll(/^import .*;$/gm)].map(m => m[0]), [
"import { autonomyEvent, decisionResolution } from './autonomy-state.mjs';",
"import { autonomyClass, authorityBudgetCovered, checkAutonomy, decisionAutonomy, resolverSubject } from '../../organization/src/autonomy.mjs';",
// Rows330/331: pure verb/refusal vocabulary, never the host management implementation.
"import { ownRails } from '../../organization/src/rails.mjs';",
"import { refusalGuidance } from './refusals.mjs';",
@@ -45,7 +47,8 @@ test('Broker keeps the sandbox client import closure: only the listed imports, n
]);
assert.doesNotMatch(source, /\bimport\s*\(/);
assert.doesNotMatch(readFileSync(new URL('../src/proxy-page-vocabulary.mjs', import.meta.url), 'utf8'), /\bimport\b/);
assert.doesNotMatch(readFileSync(new URL('../src/landing.mjs', import.meta.url), 'utf8'), /\bimport\b/);
assert.doesNotMatch(readFileSync(new URL('../src/autonomy-state.mjs', import.meta.url), 'utf8'), /\bimport\b/);
assert.doesNotMatch(readFileSync(new URL('../../organization/src/autonomy.mjs', import.meta.url), 'utf8'), /\bimport\b/);
assert.doesNotMatch(readFileSync(new URL('../../organization/src/vocabulary.mjs', import.meta.url), 'utf8'), /\bimport\b/);
assert.doesNotMatch(readFileSync(new URL('../../plugins/src/kinds.mjs', import.meta.url), 'utf8'), /\bimport\b/);
});
+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`);
}
});
+1 -1
View File
@@ -90,7 +90,7 @@ const ask = (x, state, extra = {}) => x.call('pm', 'request.question', { request
options: [{ key: 'web', text: 'Web' }, { key: 'cli', text: 'CLI' }], ...extra });
test('the console verb list includes the artifact read and existing owner controls', () => {
assert.deepEqual([...CONSOLE_VERBS], ['system-types.console', 'project.remote.get.console', 'project.remote.set.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'hire.preview.console', 'hire.resolve.console', 'hire.start.console', 'hire.templates.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
assert.deepEqual([...CONSOLE_VERBS], ['system-types.console', 'project.remote.get.console', 'project.remote.set.console', 'autonomy.get.console', 'autonomy.set.console', 'autonomy.review.console', 'decision.confirm.console', 'decision.reverse.console', 'request.workspace.console', 'request.artifact.console', 'decision.preview.console', 'decision.resolve.console', 'hire.preview.console', 'hire.resolve.console', 'hire.start.console', 'hire.templates.console', 'prd.revoke.console', 'request.mode.console', 'request.draft.console',
'request.get.console', 'request.projects.console', 'request.answer.console', 'request.attach.console', 'request.cancel.console']);
});
+3
View File
@@ -96,6 +96,9 @@ test('row 65 runtime: harnessLevels answers each module in the tree, and null fo
assert.deepEqual(levels('pi'), ['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']);
assert.deepEqual(levels('claude-code'), ['low', 'medium', 'high', 'xhigh', 'max']);
for (const name of ['cursor', '', '../pi']) assert.equal(levels(name), null, name);
// AU (#1873): each module's own countsAccount answer, read once with its levels.
assert.deepEqual([levels.countsAccount('pi'), levels.countsAccount('claude-code')], [false, true]);
for (const name of ['cursor', '', '../pi']) assert.equal(levels.countsAccount(name), null, name);
assert.deepEqual(logged, []);
});
+2 -2
View File
@@ -7,7 +7,7 @@ import { Broker } from '../src/broker.mjs';
import { initProject, readSpec, writeRecord, listProjectFiles, readProjectFile, writeHumanFile, ProjectError } from '../../project/src/index.mjs';
export const git = (repo, ...args) => execFileSync('git', ['-c', 'core.hooksPath=/dev/null', '-c', 'maintenance.auto=false', '-c', 'gc.auto=0', '-C', repo, ...args], { encoding: 'utf8' }).trim();
const options = [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }];
export async function fixture(t, { adapter = true, roles = {}, arbiters = { technical: 'chief', delivery: 'chief' }, access = null } = {}) {
export async function fixture(t, { adapter = true, roles = {}, arbiters = { technical: 'chief', delivery: 'chief' }, access = null, autonomyHost = null } = {}) {
const root = mkdtempSync(join(tmpdir(), 'bus-spec-'));
const store = new Store(root);
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
@@ -20,7 +20,7 @@ export async function fixture(t, { adapter = true, roles = {}, arbiters = { tech
...roles,
} };
const { repo, commit } = await initProject({ business, project: 'stack', dataRoot: root });
const broker = new Broker({ store, businesses: { demo: business }, access, project: adapter ? {
const broker = new Broker({ store, businesses: { demo: business }, access, autonomy: autonomyHost, project: adapter ? {
readSpec: context => readSpec({ ...context, dataRoot: root }),
writeRecord: context => writeRecord({ ...context, dataRoot: root }),
listFiles: context => listProjectFiles({ ...context, dataRoot: root }),
+15
View File
@@ -546,3 +546,18 @@ test('review: saturated and adjacent positions refuse without a write', async (t
assert.equal(syncCode(() => f.person('tasks.edit', {task_ref:'task:1', group:'adjacent', position:10})), 'task-position-full');
assert.equal(f.store.get('SELECT count(*) n FROM store_tasks').n, before + 2);
});
test('AU near cross-role task scope restores prior fields and refuses overwriting a later edit',async t=>{
const f=await org(t);f.broker.reloadBusiness('acme',business('acme',{autonomy:{'cross-role':'near the loop',notices:true}}));
const owner=f.broker.bindConsole({business:'acme',human:'jason',subject:{provider:'test',iss:'https://issuer.test',sub:'owner'}});
const made=f.person('tasks.create',{title:'Before',requirement:'REQ-TASK-1'});
const d=f.approve('coder','task.scope.change',made.task_ref);
await f.call('coder','task.scope.change',{task_ref:made.task_ref,title:'After',decision:d});
const reversed=await f.broker.request(owner,{verb:'decision.reverse.console',args:{id:d}});
assert.equal(reversed.reversed,true);assert.equal(f.read()[0].fields.title,'Before');
const later=f.approve('coder','task.scope.change',made.task_ref);
await f.call('coder','task.scope.change',{task_ref:made.task_ref,title:'Second',decision:later});
f.person('tasks.edit',{task_ref:made.task_ref,title:'Owner edit'});
await assert.rejects(f.broker.request(owner,{verb:'decision.reverse.console',args:{id:later}}),{code:'decision-stale'});
assert.equal(f.read()[0].fields.title,'Owner edit');
});
+2
View File
@@ -0,0 +1,2 @@
// Compatibility path for organization autonomy policy.
export * from "../../organization/src/autonomy.mjs";
+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";
+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
+28 -3
View File
@@ -55,6 +55,7 @@ import { agentsRootCommand, AGENTS_ROOT_USAGE, projectAgentsCommand, PROJECT_AGE
import { ORG_CONFIG, runConfigCommand } from "../../organization/src/config-command.mjs";
import { devCommand, DEV_USAGE, fixtureLaunchEnv, fixtureLauncherOptions } from "./dev-fixture.mjs";
import { runCommand, RUN_USAGE } from "./run-evidence.mjs";
import { tasksWriteCommand, TASKS_WRITE_OPS, TASKS_WRITE_USAGE } from "./tasks-write.mjs";
export const USAGE = `usage:
${ACCOUNTS_USAGE}
@@ -63,7 +64,8 @@ export const USAGE = `usage:
${LIMITS_USAGE}
${PLUGINS_USAGE}
${CREDENTIALS_USAGE}
mosaic proxy fetch | fence status [--json] | fence clear --proxy-restarted | init [--port N] [--business ID] [--rotate-grace-minutes N] [--first-output-seconds N]
mosaic proxy fetch | status [--json] | fence status [--json] | fence clear --proxy-restarted | fence clear --restart [--business ID] | init [--generate-key] [--port N] [--business ID] [--rotate-grace-minutes N] [--first-output-seconds N]
mosaic proxy unit render|install|start|stop|restart|status [--json] | init-lock status|clear
${HOSTS_USAGE}
${GRANTS_USAGE}
${ORG_INSTANCES_USAGE}
@@ -81,6 +83,7 @@ export const USAGE = `usage:
mosaic inbox --read <id>... | --read-all [--business <id>]
mosaic decide <decision> <option> [--note <text>] [--yes] [--business <id>]
mosaic tasks [--business <id>] [--json] [--tester]
${TASKS_WRITE_USAGE}
mosaic agents [--business <id>] [--json] [--tester]
mosaic trail <task|decision> [--business <id>] [--json] [--tester]
mosaic talk <role> <text> [--wait <seconds> | --follow] [--business <id>] [--tester]
@@ -569,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;
}
@@ -585,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;
}
@@ -598,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")) {
@@ -618,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);
@@ -703,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);
@@ -750,6 +773,8 @@ export async function main(argv, io = { env: process.env, stdin: process.stdin,
// A2 (#1873): the owner's root and project agent levels. `mosaic agents` without `root` stays the bus read.
if (verb === "agents" && rest[0] === "root") return agentsRootCommand(rest.slice(1), io);
if (verb === "project" && rest[0] === "agents") return projectAgentsCommand(rest.slice(1), io);
// E2 (#1873, P1-3): the task board's create, edit and archive, as the person (tasks-write.mjs).
if (verb === "tasks" && TASKS_WRITE_OPS.includes(rest[0])) return tasksWriteCommand(rest, io, (p) => callFor(p, io, deps));
if (["inbox", "decide", "tasks", "agents", "trail"].includes(verb)) return human(verb, rest, io, deps);
if (verb === "project" && rest[0] === "north-star") return northStarCommand(rest.slice(1), io, (p) => callFor(p, io, deps));
if (verb === "project" && rest[0] === "interview") return interviewCommand(rest.slice(1), io);
@@ -775,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));
+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);
+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);
+62 -10
View File
@@ -2,7 +2,7 @@
import { randomBytes } from 'node:crypto';
import { processState } from '../../seat/src/process-state.mjs';
import { spawn } from 'node:child_process';
import { constants, closeSync, lstatSync, mkdirSync, openSync, readFileSync, fstatSync, unlinkSync, writeFileSync } from 'node:fs';
import { constants, closeSync, linkSync, lstatSync, mkdirSync, openSync, readFileSync, fstatSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
import { connect } from 'node:net';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
@@ -151,6 +151,27 @@ export async function busRestart(ctx, id) {
return busStart(ctx, id, { detach: true, ...options });
}
// D2 security review: clear only the entries busRecover inspected. Each is renamed aside (atomic)
// and the renamed entry is checked by device and inode; an entry swapped after the inspection is
// put back with everything moved so far, and the recovery refuses before any receipt or deletion.
// The deletion then names only the aside entries, never the live paths.
export function setAside(entries) {
const tag = randomBytes(4).toString('hex'), moved = [];
const restore = () => {
// link() refuses an existing path, so a put-back never replaces whatever now holds the name; an
// entry that cannot go back stays under its .recovering name for a person to inspect.
for (const m of moved.slice().reverse()) { try { linkSync(m.aside, m.path); unlinkSync(m.aside); } catch { /* left aside */ } }
};
for (const entry of entries) {
const aside = `${entry.path}.recovering-${tag}`;
let current;
try { renameSync(entry.path, aside); moved.push({ path: entry.path, aside }); current = lstatSync(aside); }
catch { restore(); fail('bus recovery state changed'); }
if (current.dev !== entry.st.dev || current.ino !== entry.st.ino) { restore(); fail('bus recovery state changed'); }
}
return { restore, remove: () => { for (const m of moved.slice().reverse()) unlinkSync(m.aside); } };
}
// Recover only while the org lifecycle fence excludes new starts and deletion.
export async function busRecover(ctx, id) {
return withOrgLifecycle(ctx, id, async () => {
@@ -177,10 +198,12 @@ export async function busRecover(ctx, id) {
const h = state.value;
if (!Number.isSafeInteger(h?.pid) || h.pid <= 0 || typeof h.startTime !== 'string' || !/^\d+$/.test(h.startTime) || typeof h.startedAt !== 'string' || !Number.isFinite(Date.parse(h.startedAt)) || h.business !== id) fail('invalid or mismatched bus host state');
let p; try { p = processState(h.pid); } catch { fail('bus host identity is uncertain'); }
// A live pid with the recorded start time is the recorded process: never proof of death. Its argv
// only picks the message (D2 security review: a mismatching argv used to read as a dead host).
if (p.alive && p.startTime === h.startTime) {
const argv = cmdlineOf(h.pid); if (!argv || !argv.length) fail('bus host identity is uncertain');
const i = argv.indexOf(CLI);
const argv = cmdlineOf(h.pid), i = argv?.indexOf(CLI) ?? -1;
if (i >= 0 && argv[i + 1] === 'bus' && argv[i + 2] === 'start' && argv[i + 3] === id) fail(`bus host pid ${h.pid} is alive`);
fail('bus host identity is uncertain');
}
if (lock && (lock.value?.pid !== h.pid || lock.value?.startTime !== h.startTime || lock.value?.business !== id || lock.value?.startedAt !== h.startedAt)) fail('bus host lock does not match host state');
if (writer) {
@@ -198,14 +221,43 @@ export async function busRecover(ctx, id) {
if (occupied) fail('bus socket answers or is uncertain');
}
const entries = [state, lock, writer, socket].filter(Boolean);
for (const entry of entries) {
const current = lstatSync(entry.path);
if (current.dev !== entry.st.dev || current.ino !== entry.st.ino) fail('bus recovery state changed');
}
const aside = setAside(entries);
const at = new Date().toISOString();
const receipt = join(org.paths.hostDir, `recovery-${at.replace(/[:.]/g, '-')}-${randomBytes(4).toString('hex')}.json`);
writeFileSync(receipt, JSON.stringify({ action: 'bus.recover', at, org: id, stopped: true, host: h, lock: lock?.value ?? null, writer: writer?.value ?? null, socket: Boolean(socket) }, null, 2) + '\n', { mode: 0o600, flag: 'wx' });
for (const entry of entries.slice().reverse()) unlinkSync(entry.path);
return { org: id, recovered: true, pid: h.pid, receipt };
try { writeFileSync(receipt, JSON.stringify({ action: 'bus.recover', at, org: id, stopped: true, host: h, lock: lock?.value ?? null, writer: writer?.value ?? null, socket: Boolean(socket) }, null, 2) + '\n', { mode: 0o600, flag: 'wx' }); }
catch (e) { aside.restore(); throw e; }
aside.remove();
const result = { org: id, recovered: true, pid: h.pid, receipt };
// The dead host's start options, for busRevive. Not part of the printed receipt.
const o = h.options && typeof h.options === 'object' ? h.options : {};
Object.defineProperty(result, 'options', { value: Object.freeze({ devLocalOwner: o.devLocalOwner === true, pm: o.pm === true, noLaunch: o.noLaunch === true }) });
return result;
});
}
// D2 (P0-3): bring back a bus whose host was killed, as a Console unit stop does to the hosts the
// Console started. busRecover is the proof: it refuses a live or unprovable host, a lock that does
// not match the dead host, a live writer and an answering socket. The host restarts on the options
// the dead host recorded, never wider; `pm: false` also keeps the chief from launching.
export function reviveStartOptions(recorded, { pm = true } = {}) {
const o = recorded && typeof recorded === 'object' ? recorded : {};
return { detach: true, devLocalOwner: o.devLocalOwner === true, pm: pm === true && o.pm === true, noLaunch: o.noLaunch === true };
}
export async function busRevive(ctx, id, { pm = true } = {}) {
await target(ctx, id);
const recovered = await busRecover(ctx, id);
const options = reviveStartOptions(recovered.options, { pm });
const cleared = { pid: recovered.pid, receipt: recovered.receipt };
let started;
try { started = await busStart(ctx, id, options); }
catch (e) {
// The dead host's state is gone by now: the caller must not report "nothing changed".
if (e && typeof e === 'object') { try { Object.defineProperty(e, 'recovered', { value: cleared }); } catch { /* frozen: the caller sees a plain failure */ } }
throw e;
}
const receipt = { ...started, recovered: cleared };
Object.defineProperty(receipt, 'done', { value: started.done });
// The chief was not relaunched although the dead host was started with --pm.
Object.defineProperty(receipt, 'pmDropped', { value: recovered.options.pm === true && !options.pm });
return receipt;
}
+104 -7
View File
@@ -3,13 +3,14 @@ import { randomBytes } from 'node:crypto';
import { processState } from '../../seat/src/process-state.mjs';
import { spawn } from 'node:child_process';
import { request as httpRequest } from 'node:http';
import { constants, closeSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, rmSync, renameSync, writeFileSync } from 'node:fs';
import { constants, closeSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, rmSync, renameSync, writeFileSync, writeSync } from 'node:fs';
import { basename, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { listOrgs, resolveOrg } from '../../organization/src/org-registry.mjs';
import { cmdlineOf, startTimeOf } from './host.mjs';
import { CliError } from './errors.mjs';
import { devLanOptions } from './dev-local-owner.mjs';
import { refuseInsideAgent } from './transport.mjs';
const WEBUI = fileURLToPath(new URL('../../webui/src/cli.mjs', import.meta.url));
const forbidden = new Set([7332, 7340, 7341, 33223, 17450, 17443, 44307]);
@@ -63,8 +64,25 @@ function takeLock(ctx) {
checkDir(ctx);
const path = join(directory(ctx), 'start.lock');
let fd; try { fd = openSync(path, 'wx', 0o600); } catch { fail('console start lock exists'); }
// D1: the lock names its holder, so a detached start that timed out can clear the lock of the
// supervisor it killed (clearDeadLock) and no other.
try { writeSync(fd, JSON.stringify({ pid: process.pid, startTime: startTimeOf(process.pid) })); } catch { /* an unnamed lock is only never cleared */ }
return () => { closeSync(fd); rmSync(path, { force: true }); };
}
// Removes start.lock only when it names this exact process (pid and start time) and that process
// is gone. A lock that is unreadable, unnamed or another holder's stays.
function clearDeadLock(ctx, pid, startTime) {
if (!startTime) return;
const path = join(directory(ctx), 'start.lock');
let owner;
try {
const st = lstatSync(path);
if (!st.isFile() || st.uid !== process.getuid()) return;
owner = JSON.parse(readFileSync(path, 'utf8'));
} catch { return; }
if (owner?.pid !== pid || owner.startTime !== startTime || startTimeOf(pid) === startTime) return;
rmSync(path, { force: true });
}
// The exit code a supervised Console uses to ask for a relaunch (EX_TEMPFAIL). Only the Console's
// own restart route (POST /api/console/restart, a human action) exits with it.
export const RESTART_EXIT = 75;
@@ -153,15 +171,77 @@ function supervise(ctx, first, args, lan, warn) {
})();
return { done, stop };
}
// D1 (#1873): `--dev-local-owner --detach` runs the foreground supervision above in a process of
// its own session (console-supervisor.mjs), so Restart Console works on a detached dev Console. The
// supervisor takes the start lock and writes the console state as a foreground start does. This
// process waits for the supervisor's one-line reply, passes its warnings on and leaves it running.
const SUPERVISOR = fileURLToPath(new URL('./console-supervisor.mjs', import.meta.url));
async function detachedSupervisor(ctx, { port, bind, allowHosts, warn }) {
checkDir(ctx);
lastStamp = Math.max(Date.now(), lastStamp + 1);
const name = `supervisor-${process.pid}-${lastStamp}.log`;
const fd = openSync(join(directory(ctx), name), constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o600);
let child;
try {
child = spawn(process.execPath, [SUPERVISOR, JSON.stringify({ configDir: ctx.configDir, dataRoot: ctx.dataRoot, instance: ctx.instance, port, bind, allowHosts })],
{ env: ctx.env ?? process.env, detached: true, stdio: ['ignore', 'pipe', fd] });
} finally { closeSync(fd); }
const supervisorStart = child.pid ? startTimeOf(child.pid) : null;
const done = new Promise(resolve => { child.once('error', () => resolve(1)); child.once('exit', code => resolve(code ?? 1)); });
// The log is named under the data root, never by host path (PR #1916).
const unclear = `console failed to start; see console/${name} under the data root`;
let text = '', timer;
child.stdout.setEncoding('utf8');
const line = await Promise.race([
new Promise(resolve => {
child.stdout.on('data', c => { text += c; const n = text.indexOf('\n'); if (n >= 0) resolve(text.slice(0, n)); else if (text.length > 65536) resolve(null); });
child.stdout.once('end', () => resolve(null));
}),
new Promise(resolve => { timer = setTimeout(() => resolve(undefined), 60000); }),
]);
clearTimeout(timer);
child.stdout.destroy();
// The supervisor leads its own process group, with the Console it may have started: a timed-out
// start signals that group, then kills it if it is still there after 10 seconds. A supervisor
// killed while it held the start lock never released it, so the lock it named is cleared.
if (line === undefined) {
const group = signal => { try { process.kill(-child.pid, signal); } catch { /* already gone */ } };
group('SIGTERM');
let grace; const exited = await Promise.race([done.then(() => true), new Promise(r => { grace = setTimeout(() => r(false), 10000); })]);
clearTimeout(grace);
if (!exited) { group('SIGKILL'); await done; }
clearDeadLock(ctx, child.pid, supervisorStart);
fail(unclear);
}
let reply = null;
try { reply = JSON.parse(line); } catch { /* no reply */ }
for (const w of Array.isArray(reply?.warnings) ? reply.warnings : []) if (typeof w === 'string') warn(w);
child.unref();
if (typeof reply?.receipt?.url !== 'string' || !Number.isSafeInteger(reply.receipt.pid)) {
const code = [1, 2, 3, 4].includes(reply?.exitCode) ? reply.exitCode : 1;
throw new CliError(typeof reply?.error === 'string' ? reply.error : unclear, code);
}
const result = { ...reply.receipt, supervisor: child.pid };
Object.defineProperty(result, 'done', { value: done });
Object.defineProperty(result, 'stop', { value: () => child.kill('SIGTERM') });
return result;
}
// `bind` and `allowHosts` are --dev-insecure-bind and --dev-allow-host (org-lan-bind): the dev
// owner Console on the LAN with no sign-in. Neither is set for a loopback Console.
// In the foreground the Console runs with --supervised: its restart route exits RESTART_EXIT and
// this process relaunches it in place (F3). A detached Console has no supervisor.
// this process relaunches it in place (F3). A detached dev-local-owner Console runs under a
// detached supervisor (D1); any other detached Console has none.
export async function consoleStart(ctx, { devLocalOwner = false, detach = false, port = 'auto', bind = null, allowHosts = [], warn = s => process.stderr.write(s) } = {}) {
const value = port === 'auto' ? 0 : Number(port);
if (!(port === 'auto' || (typeof port === 'string' && /^\d+$/.test(port) && value >= 1 && value <= 65535 && !forbidden.has(value)))) fail('invalid or forbidden console port');
let lan;
try { lan = devLanOptions({ devOwner: devLocalOwner, bind, names: allowHosts }); } catch (e) { throw new CliError(e.message, 4); }
// D1 fix round (security review P1): a dev-local-owner Console acts as the owner with no sign-in,
// so an agent run never starts one, detached or not. agent-guard (#1873): nor any other Console.
// This process's own environment is checked as well as the one given for the Console (security
// re-check P1). A Restart Console relaunch goes through relaunch(), not here.
refuseInsideAgent(process.env); if (ctx.env) refuseInsideAgent(ctx.env);
if (detach && devLocalOwner) return detachedSupervisor(ctx, { port, bind, allowHosts, warn });
const release = takeLock(ctx);
let run, args;
try {
@@ -238,9 +318,10 @@ function consoleTarget(ctx) {
const address = s.bind && s.bind !== '0.0.0.0' ? s.bind : '127.0.0.1';
return { address, port, host: `${address}:${port}` };
}
function consoleCall(target, { method = 'GET', path, timeoutMs }) {
function consoleCall(target, { method = 'GET', path, timeoutMs, body = {} }) {
return new Promise((resolve, reject) => {
const headers = { host: target.host, ...(method === 'POST' ? { origin: `http://${target.host}`, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json', 'content-length': 2 } : {}) };
const payload = method === 'POST' ? JSON.stringify(body) : undefined;
const headers = { host: target.host, ...(method === 'POST' ? { origin: `http://${target.host}`, 'sec-fetch-site': 'same-origin', 'content-type': 'application/json', 'content-length': Buffer.byteLength(payload) } : {}) };
let sent = false;
const req = httpRequest({ host: target.address, port: target.port, method, path, headers, timeout: timeoutMs }, res => {
let text = '';
@@ -252,14 +333,14 @@ function consoleCall(target, { method = 'GET', path, timeoutMs }) {
req.on('timeout', () => req.destroy(Object.assign(new Error('timed out'), { code: 'timeout' })));
req.on('finish', () => { sent = true; });
req.on('error', e => reject(Object.assign(e, { sent })));
req.end(method === 'POST' ? '{}' : undefined);
req.end(payload);
});
}
// A refused connection never reached the Console. Anything else after the request left may have
// changed something, so it is outcome-unknown and nothing is retried.
async function consolePost(target, path, { timeoutMs, unknown }) {
async function consolePost(target, path, { timeoutMs, unknown, body }) {
let answer;
try { answer = await consoleCall(target, { method: 'POST', path, timeoutMs }); }
try { answer = await consoleCall(target, { method: 'POST', path, timeoutMs, body }); }
catch (e) {
if (e.code === 'ECONNREFUSED' && !e.sent) fail('the Console refused the connection; see mosaic console status');
throw new CliError(`outcome unknown: ${unknown}`, 1);
@@ -294,3 +375,19 @@ export async function consoleBusRestart(ctx, id, { timeoutMs = 120000 } = {}) {
if (answer.status !== 200 || answer.body?.outcome !== 'done' || answer.body.step !== 'bus-restart') throw refusal(answer, 'org reload');
return answer.body;
}
// E2 (#1873, P1-3): the owner's org page actions from a terminal (org-owner.mjs): the hire form and
// the autonomy page, each through the running Console's own org route, sent as the page sends it.
// The route applies its owner, --send, origin, body and live-session checks and asks the org's own
// bus; nothing here decides or writes anything. A GET that fails changed nothing, so it is exit 1
// with no outcome-unknown wording; a POST that fails after it left is outcome-unknown.
export async function consoleOrgRequest(ctx, id, { method = 'GET', path, body, timeoutMs = 30000, unknown }) {
if (typeof id !== 'string' || !ORG_ID.test(id)) throw new CliError('invalid organization id', 4);
const target = consoleTarget(ctx);
const route = `/api/orgs/${id}/${path}`;
if (method === 'POST') return consolePost(target, route, { timeoutMs, unknown, body });
try { return await consoleCall(target, { path: route, timeoutMs }); }
catch (e) {
if (e.code === 'ECONNREFUSED' && !e.sent) fail('the Console refused the connection; see mosaic console status');
throw new CliError('the Console did not answer; see mosaic console status', 1);
}
}
+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';
}
+42 -5
View File
@@ -6,19 +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';
import { definitionsCommand, DEFINITIONS_USAGE } from './org-definitions.mjs';
export const ORG_USAGE = 'mosaic org system-types installed | system-types enabled ID | lock show|clear ID | reload ID | archive ID --if-revision N [--json] | restore ID --if-revision N [--json] | delete ID --confirm ID [--if-revision N] [--json] | template render ID [--fixture local] --output FILE | validate FILE | create ID --from FILE | show ID [--json] | list [--json] | update ID --from FILE --if-revision N | queue init ID (--local-only --accept-local-loss-risk | --remote URL) | queue show ID [--json] | project repo init ID PROJECT (--local-only --accept-local-loss-risk | --remote URL) | project repo show ID PROJECT [--json] | ' + DEFINITIONS_USAGE.slice('mosaic org '.length) + ' | config validate ID | config resolve ID INSTANCE [--project ID]';
function parse(args) { const pos = [], values = {}, flags = new Set(); for (let i = 0; i < args.length; i++) { const a = args[i]; if (['--json', '--local-only', '--accept-local-loss-risk'].includes(a)) { if (flags.has(a)) throw new CliError(`duplicate ${a}`, 4); flags.add(a); } else if (['--fixture', '--output', '--from', '--if-revision', '--remote', '--confirm'].includes(a)) { if (values[a] !== undefined || !args[i+1] || args[i+1].startsWith('--')) throw new CliError(`${a} needs one value`, 4); values[a] = args[++i]; } else if (a.startsWith('--')) throw new CliError(`unknown option ${a}`, 4); else pos.push(a); } return { pos, values, flags }; }
import { orgOwnerCommand, ORG_OWNER_USAGE, ORG_OWNER_VERBS } from './org-owner.mjs';
export const ORG_USAGE = 'mosaic org system-types installed | system-types enabled ID | lock show|clear ID | reload ID | archive ID --if-revision N [--json] | restore ID --if-revision N [--no-bus] [--json] | delete ID --confirm ID [--if-revision N] [--json] | template render ID [--fixture local] --output FILE | validate FILE | create ID --from FILE | show ID [--json] | list [--json] | update ID --from FILE --if-revision N | queue init ID (--local-only --accept-local-loss-risk | --remote URL) | queue show ID [--json] | project repo init ID PROJECT (--local-only --accept-local-loss-risk | --remote URL) | project repo show ID PROJECT [--json] | ' + DEFINITIONS_USAGE.slice('mosaic org '.length) + ' | ' + ORG_OWNER_USAGE.slice('mosaic org '.length) + ' | config validate ID | config resolve ID INSTANCE [--project ID]';
function parse(args) { const pos = [], values = {}, flags = new Set(); for (let i = 0; i < args.length; i++) { const a = args[i]; if (['--json', '--local-only', '--accept-local-loss-risk', '--no-bus'].includes(a)) { if (flags.has(a)) throw new CliError(`duplicate ${a}`, 4); flags.add(a); } else if (['--fixture', '--output', '--from', '--if-revision', '--remote', '--confirm'].includes(a)) { if (values[a] !== undefined || !args[i+1] || args[i+1].startsWith('--')) throw new CliError(`${a} needs one value`, 4); values[a] = args[++i]; } else if (a.startsWith('--')) throw new CliError(`unknown option ${a}`, 4); else pos.push(a); } return { pos, values, flags }; }
// A refusal names the document by label and the error by its code, never by host path or bytes (PR #1916).
const errorCode = e => typeof e?.code === 'string' && /^E[A-Z0-9]+$/.test(e.code) ? e.code : 'unreadable';
async function document(file) { try { return JSON.parse(await readFile(file, 'utf8')); } catch (e) { throw new CliError(`cannot read organization document: ${e instanceof SyntaxError ? 'not valid JSON' : errorCode(e)}`, 2); } }
// agent-guard (#1873): archive and delete stop the organization's bus, so an agent run is refused
// as it is for `mosaic bus stop`: after the usage checks, before any config is read, and checking
// this process's own environment as well as io.env. Restore starts the organization's bus (E1, P1-4),
// so it is refused the same way unless --no-bus is given; `restore --no-bus` starts nothing.
function refuseAgentRun(io) { refuseInsideAgent(process.env); refuseInsideAgent(io.env); }
// E1 (P1-4): the bus a restore starts is the one `mosaic bus start ID --dev-local-owner --detach`
// starts, after that verb's checks. The checks run before the restore, so a refusal changes nothing.
function restoreBusCheck(system, env) {
try { assertBuildStart({ devOwner: true }); assertDevInstance(system, env); }
catch (e) { throw new CliError(`restore starts the bus as the local dev owner, and ${e.message}; restore with --no-bus, then start the bus with mosaic bus start`, 3); }
}
const busStartVerb = id => `mosaic bus start ${id} --dev-local-owner --detach`;
export async function orgCommand(args, io, deps = {}) {
try {
// #1873 T1: the definitions verbs take their own options.
if (args[0] === 'definitions') return await definitionsCommand(args.slice(1), io, deps);
// E2 (#1873, P1-3): the hire form and the autonomy panel, through the running Console (org-owner.mjs).
if (ORG_OWNER_VERBS.includes(args[0])) return await orgOwnerCommand(args, io, deps);
const p = parse(args); const words = p.pos; let result; let accepted = [];
const allowed = (flags, values) => { for (const f of p.flags) if (!flags.includes(f)) throw new CliError(`unexpected ${f}`, 4); for (const f of Object.keys(p.values)) if (!values.includes(f)) throw new CliError(`unexpected ${f}`, 4); };
const need = key => { if (!p.values[key]) throw new CliError(`${key} is required`, 4); return p.values[key]; };
@@ -26,14 +42,35 @@ export async function orgCommand(args, io, deps = {}) {
if (words[0] === 'validate' && words.length === 2) { allowed([], []); result = await validateOrg(await document(words[1])); if (!result.ok) throw new CliError(result.errors.map(e => `${e.path}: ${e.message}`).join('\n'), 2); io.stdout.write('valid\n'); return; }
// U1 (#1873): `org reload` is a human action; an agent run is refused before any config is read.
if (words[0] === 'reload') refuseInsideAgent(io.env);
const revision = (r, required) => { if (r === undefined && !required) return undefined; if (!/^[1-9][0-9]*$/.test(r ?? '')) throw new CliError(r === undefined ? '--if-revision is required' : 'invalid revision', 4); return Number(r); };
let lifecycle = null;
if (['archive', 'restore'].includes(words[0]) && words.length === 2) { allowed(['--json', ...(words[0] === 'restore' ? ['--no-bus'] : [])], ['--if-revision']); lifecycle = { ifRevision: revision(p.values['--if-revision'], true) }; if (words[0] === 'archive' || !p.flags.has('--no-bus')) refuseAgentRun(io); }
else if (words[0] === 'delete' && words.length === 2) { allowed(['--json'], ['--confirm', '--if-revision']); const ifRevision = revision(p.values['--if-revision'], false); lifecycle = { confirm: need('--confirm'), ifRevision }; refuseAgentRun(io); }
if (words[0] === 'system-types' && words[1] === 'installed' && words.length === 2) { allowed(['--json'], []); io.stdout.write(JSON.stringify(await installedSystemTypes(), null, 2) + '\n'); return; }
const instance = devOwnerInstance(io.env); const system = deps.system ?? loadSystem({ env: io.env, instance }); const ctx = { configDir: configDir(io.env), dataRoot: system.dataRoot, instance, principal: DEV_OWNER };
if (words[0] === 'system-types' && words[1] === 'enabled' && words.length === 3) { allowed(['--json'], []); const org = await showOrg(ctx, words[2]); result = { organization: words[2], enabled: org.doc.plugins?.['system-type'] ?? [] }; }
else if (words[0] === 'lock' && ['show', 'clear'].includes(words[1]) && words.length === 3) { allowed([], []); result = await orgLock(ctx, words[2], { clear: words[1] === 'clear' }); }
else if (words[0] === 'create' && words.length === 2) { allowed([], ['--from']); result = await createOrg(ctx, words[1], await document(need('--from'))); }
else if (words[0] === 'update' && words.length === 2) { allowed([], ['--from', '--if-revision']); const r = need('--if-revision'); if (!/^[1-9][0-9]*$/.test(r)) throw new CliError('invalid revision', 4); result = await updateOrg(ctx, words[1], await document(need('--from')), { ifRevision: Number(r) }); }
else if (['archive', 'restore'].includes(words[0]) && words.length === 2) { allowed(['--json'], ['--if-revision']); const r = need('--if-revision'); if (!/^[1-9][0-9]*$/.test(r)) throw new CliError('invalid revision', 4); const opts = { ifRevision: Number(r) }; result = words[0] === 'archive' ? await archiveOrg(ctx, words[1], { ...opts, busStop: busStopForDelete }) : await restoreOrg(ctx, words[1], opts); }
else if (words[0] === 'delete' && words.length === 2) { allowed(['--json'], ['--confirm', '--if-revision']); const r = p.values['--if-revision']; if (r !== undefined && !/^[1-9][0-9]*$/.test(r)) throw new CliError('invalid revision', 4); result = await deleteOrg(ctx, words[1], { busStop: busStopForDelete, confirm: need('--confirm'), ifRevision: r === undefined ? undefined : Number(r) }); }
else if (words[0] === 'archive' && words.length === 2) result = await archiveOrg(ctx, words[1], { ...lifecycle, busStop: busStopForDelete });
else if (words[0] === 'restore' && words.length === 2) {
const startBus = !p.flags.has('--no-bus');
if (startBus) restoreBusCheck(system, io.env);
result = await restoreOrg(ctx, words[1], lifecycle);
// The restore stands whatever the bus does. A bus that does not start is said so, with the verb
// that starts it, and the command exits 3 after printing the restore receipt.
if (startBus) {
try { const started = await (deps.busStart ?? busStart)({ ...ctx, env: io.env }, words[1], { detach: true, devLocalOwner: true, pm: false }); result = { ...result, bus: { outcome: 'started', pid: started.pid } }; }
catch (e) {
// A host that died left its lock behind; `bus start` refuses it until `bus recover` clears it.
const message = e instanceof CliError ? e.message : 'the bus did not start', dead = /^stale /.test(message);
result = { ...result, bus: { outcome: 'failed', message, next: dead ? `mosaic bus recover ${words[1]}; ${busStartVerb(words[1])}` : busStartVerb(words[1]) } };
io.stdout.write(JSON.stringify(result, null, 2) + '\n');
throw new CliError(`${words[1]} was restored, but its bus did not start: ${message}; ${dead ? `clear the dead host with mosaic bus recover ${words[1]}, then start it with` : 'start it with'} ${busStartVerb(words[1])}`, 3);
}
}
}
else if (words[0] === 'delete' && words.length === 2) result = await deleteOrg(ctx, words[1], { busStop: busStopForDelete, ...lifecycle });
// U1 (#1873): the F3 bus control from a terminal, through the running Console's own route, which
// checks the owner and writes the action trail (org-console.mjs consoleBusRestart).
else if (words[0] === 'reload' && words.length === 2) { allowed([], []); result = await consoleBusRestart(ctx, words[1], deps.consoleRequest ?? {}); }
+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; }
}
+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`);
}
+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);
});
+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"`);
+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.
+214 -7
View File
@@ -1,15 +1,22 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { spawn, spawnSync } from 'node:child_process';
import { mkdtempSync, mkdirSync, writeFileSync, readdirSync, readFileSync, rmSync, existsSync, statSync } from 'node:fs';
import { get, request } from 'node:http';
import { join } from 'node:path';
import { dirname, join } from 'node:path';
import { pathToFileURL } from 'node:url';
import { tmpdir } from 'node:os';
import { reserveOrg, activateOrg } from '../../business/src/org-registry.mjs';
import { humanEnv } from './helpers.mjs';
import { consoleStart, consoleStatus, consoleStop } from '../src/org-console.mjs';
import { consoleStart, consoleStatus, consoleStop, consoleRestart } from '../src/org-console.mjs';
const REPO = new URL('../../../', import.meta.url).pathname;
// A detached supervisor's handle is unref'd, so its exit is awaited with a timer that keeps the loop alive.
async function exitOf(done, ms = 20000) {
let code; done.then(c => { code = c; });
for (const deadline = Date.now() + ms; code === undefined; await new Promise(r => setTimeout(r, 100))) if (Date.now() > deadline) assert.fail('the supervisor did not exit');
return code;
}
test('detached console reports its live loopback URL and stops', { timeout: 60000 }, async t => {
const home = mkdtempSync(join(tmpdir(), 's1-'));
const env = humanEnv({ HOME: home, MOSAIC_CONFIG: join(home, '.config/mosaic-s1test/config.json') });
@@ -31,11 +38,13 @@ test('detached console reports its live loopback URL and stops', { timeout: 6000
assert.ok(Number.isSafeInteger(result.pid));
// A selector producer (rename r2): the Console child gets the active organization as --business.
const argv = readFileSync(`/proc/${result.pid}/cmdline`, 'utf8').split('\0').slice(0, -1);
assert.deepEqual(argv, [process.execPath, join(REPO, 'packages/webui/src/cli.mjs'), 'serve', '--port', '0', '--business', 'alpha', '--dev-local-owner']);
// D1 (#1873): a detached dev-owner Console runs under a detached supervisor, so it is --supervised.
assert.deepEqual(argv, [process.execPath, join(REPO, 'packages/webui/src/cli.mjs'), 'serve', '--port', '0', '--business', 'alpha', '--dev-local-owner', '--supervised']);
assert.ok(![7332, 7340, 7341, 33223, 17450, 17443, 44307, 7350].includes(Number(url.port)));
const status = await new Promise((resolve, reject) => get(url, res => { res.resume(); resolve(res.statusCode); }).on('error', reject));
assert.equal(status, 200);
assert.equal((await consoleStop(ctx)).stopped, true);
assert.equal(await exitOf(result.done), 0);
});
test('mosaic console start and stop dispatch through the CLI', { timeout: 60000 }, async t => {
@@ -198,15 +207,213 @@ test('a foreground console relaunches on the same address when the owner restart
assert.equal(consoleStatus(ctx).running, false);
});
test('a detached console has no supervisor, so it refuses a restart and stays up', { timeout: 60000 }, async t => {
// D1 (#1873, readiness audit P1-1): Restart Console on a detached dev-owner Console. The start runs
// the foreground supervision in a process of its own session; the Console is that process's child.
const SUPERVISOR = join(REPO, 'packages/cli/src/console-supervisor.mjs');
const statOf = pid => readFileSync(`/proc/${pid}/stat`, 'utf8').replace(/^.*\) /, '').split(' ');
test('a detached dev-owner console runs under a detached supervisor, so the owner restarts it in place', { timeout: 120000 }, async t => {
const { ctx, mosaic } = await instance(t);
const warnings = [];
const result = await consoleStart(ctx, { devLocalOwner: true, detach: true, port: 'auto', warn: s => warnings.push(s) });
t.after(() => { try { process.kill(result.supervisor, 'SIGTERM'); } catch {} });
assert.deepEqual(warnings, []);
assert.deepEqual(Object.keys(result), ['url', 'pid', 'supervisor']);
const port = Number(new URL(result.url).port);
assert.ok(![7332, 7340, 7341, 33223, 17450, 17443, 44307, 7350].includes(port));
const sup = argvOf(result.supervisor);
assert.deepEqual(sup.slice(0, 2), [process.execPath, SUPERVISOR]);
assert.deepEqual(JSON.parse(sup[2]), { configDir: ctx.configDir, dataRoot: ctx.dataRoot, instance: 's1test', port: 'auto', bind: null, allowHosts: [] });
// The supervisor leads its own session; the Console is its child, started as in the foreground.
assert.equal(Number(statOf(result.supervisor)[3]), result.supervisor);
assert.equal(Number(statOf(result.pid)[1]), result.supervisor);
assert.deepEqual(argvOf(result.pid), [process.execPath, WEBUI, 'serve', '--port', '0', '--business', 'alpha', '--dev-local-owner', '--supervised']);
const before = (await call(port, '/api/config')).body;
assert.equal(before.consoleRestart, true);
const stateFile = join(ctx.dataRoot, 'console/console.json'), trailFile = join(ctx.dataRoot, 'console/actions.jsonl');
// Another origin or site restarts nothing.
for (const headers of [{ origin: 'http://evil.example' }, { origin: null }, { 'sec-fetch-site': 'cross-site' }, { host: `evil.example:${port}` }]) {
assert.equal((await call(port, '/api/console/restart', { method: 'POST', body: '{}', headers })).status, 403, JSON.stringify(headers));
}
assert.equal(existsSync(trailFile), false);
// The page's request, as `mosaic console restart` sends it: 202, then a new boot on the same address.
const restarted = await consoleRestart(ctx);
assert.equal(restarted.outcome, 'restarted'); assert.equal(restarted.from, before.boot); assert.notEqual(restarted.boot, before.boot);
// The relaunched Console answers before the supervisor writes its state (relaunch), so wait for it.
const saved = await relaunchedState(ctx, result.pid);
assert.notEqual(saved.pid, result.pid);
assert.equal(saved.url, result.url);
assert.equal(Number(statOf(saved.pid)[1]), result.supervisor);
assert.deepEqual(argvOf(saved.pid), [process.execPath, WEBUI, 'serve', '--port', String(port), '--business', 'alpha', '--dev-local-owner', '--supervised']);
const after = (await call(port, '/api/config')).body;
assert.equal(after.boot, restarted.boot); assert.equal(after.consoleRestart, true);
const [line, ...more] = readFileSync(trailFile, 'utf8').split('\n').filter(Boolean).map(l => JSON.parse(l));
assert.deepEqual(more, []);
assert.deepEqual({ ...line, at: undefined }, { at: undefined, by: DEV, action: 'console.restart', outcome: 'restarting', boot: before.boot });
// The supervisor's later warnings go to its log under the data root.
const logs = readdirSync(join(ctx.dataRoot, 'console')).filter(x => /^supervisor-\d+-\d+\.log$/.test(x));
assert.equal(logs.length, 1);
assert.equal(statSync(join(ctx.dataRoot, 'console', logs[0])).mode & 0o777, 0o600);
assert.equal(readFileSync(join(ctx.dataRoot, 'console', logs[0]), 'utf8'), 'mosaic: console asked for a restart; relaunching on the same arguments\n');
// A second start is refused by the supervisor's start and says so here; nothing else starts.
await assert.rejects(consoleStart(ctx, { devLocalOwner: true, detach: true }), e => e.exitCode === 3 && /console state exists/.test(e.message));
const cli = mosaic(['console', 'start', '--dev-local-owner', '--detach', '--port', 'auto']);
assert.equal(cli.status, 3); assert.match(cli.stderr, /console state exists/); assert.equal(cli.stdout, '');
assert.deepEqual(consoleStatus(ctx), { running: true, pid: saved.pid, url: result.url, bind: '127.0.0.1' });
// A stop ends the Console and then the supervisor, cleanly.
assert.deepEqual(await consoleStop(ctx), { stopped: true, pid: saved.pid });
assert.equal(await exitOf(result.done), 0);
assert.deepEqual(consoleStatus(ctx), { running: false });
});
test('a supervisor stopped by a signal stops its detached dev-owner console', { timeout: 60000 }, async t => {
const { ctx } = await instance(t);
const result = await consoleStart(ctx, { devLocalOwner: true, detach: true, port: 'auto', warn: () => {} });
t.after(() => { try { process.kill(result.supervisor, 'SIGTERM'); } catch {} });
result.stop();
assert.equal(await exitOf(result.done), 0);
assert.equal(consoleStatus(ctx).running, false);
});
// relaunch() writes console.json after the new Console answers; read it once it names a new pid.
async function relaunchedState(ctx, oldPid) {
const file = join(ctx.dataRoot, 'console/console.json');
for (const deadline = Date.now() + 15000; ; await new Promise(r => setTimeout(r, 50))) {
const saved = JSON.parse(readFileSync(file, 'utf8'));
if (saved.pid !== oldPid || Date.now() > deadline) return saved;
}
}
// D1 fix round (build review P2): the starting CLI can die before it reads the supervisor's reply
// (Ctrl-C, a wrapper's timeout). The reply is dropped, and the supervisor keeps supervising.
test('a supervisor whose starting CLI is gone keeps supervising, so Restart Console still relaunches', { timeout: 60000 }, async t => {
const { ctx } = await instance(t);
const child = spawn(process.execPath, [SUPERVISOR, JSON.stringify({ configDir: ctx.configDir, dataRoot: ctx.dataRoot, instance: 's1test', port: 'auto', bind: null, allowHosts: [] })],
{ env: ctx.env, detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
t.after(() => { try { process.kill(child.pid, 'SIGTERM'); } catch {} });
const exit = new Promise(resolve => child.once('exit', code => resolve(code)));
let stderr = ''; child.stderr.setEncoding('utf8').on('data', c => { stderr += c; });
child.stdout.destroy();
let status = consoleStatus(ctx);
for (const deadline = Date.now() + 30000; !status.running; status = consoleStatus(ctx)) {
if (Date.now() > deadline) assert.fail(`the console did not start: ${stderr}`);
await new Promise(r => setTimeout(r, 100));
}
// The reply follows the state write; give it time to meet the closed pipe.
await new Promise(r => setTimeout(r, 1000));
assert.equal(child.exitCode, null, stderr);
assert.equal(Number(statOf(status.pid)[1]), child.pid);
const restarted = await consoleRestart(ctx);
assert.equal(restarted.outcome, 'restarted');
const after = await relaunchedState(ctx, status.pid);
assert.notEqual(after.pid, status.pid); assert.equal(after.url, status.url);
assert.equal(Number(statOf(after.pid)[1]), child.pid);
assert.deepEqual(await consoleStop(ctx), { stopped: true, pid: after.pid });
assert.equal(await exit, 0);
assert.doesNotMatch(stderr, /EPIPE|Unhandled/);
});
// D1 fix round (security review P2): a stop signal while the supervisor holds the start lock is kept
// until the start settles; then the Console stops, the lock is released and the supervisor exits.
test('a supervisor signalled during its start stops the console it started and releases the start lock', { timeout: 60000 }, async t => {
const { ctx } = await instance(t);
const child = spawn(process.execPath, [SUPERVISOR, JSON.stringify({ configDir: ctx.configDir, dataRoot: ctx.dataRoot, instance: 's1test', port: 'auto', bind: null, allowHosts: [] })],
{ env: ctx.env, detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
t.after(() => { try { process.kill(-child.pid, 'SIGKILL'); } catch {} });
const exit = new Promise(resolve => child.once('exit', (code, signal) => resolve({ code, signal })));
let stdout = '', stderr = '';
child.stdout.setEncoding('utf8').on('data', c => { stdout += c; });
child.stderr.setEncoding('utf8').on('data', c => { stderr += c; });
const lock = join(ctx.dataRoot, 'console/start.lock');
for (const deadline = Date.now() + 20000; !existsSync(lock); await new Promise(r => setTimeout(r, 2))) {
if (Date.now() > deadline) assert.fail(`the supervisor never took the start lock: ${stderr}`);
}
process.kill(child.pid, 'SIGTERM');
assert.deepEqual(await exit, { code: 0, signal: null }, stderr);
assert.equal(existsSync(lock), false);
// As after a foreground stop by signal, the state file stays and reads stale, not running.
assert.equal(consoleStatus(ctx).running, false);
// The started Console was this supervisor's child, and it is gone.
const reply = JSON.parse(stdout.trim().split('\n')[0]);
assert.equal(existsSync(`/proc/${reply.receipt.pid}`), false);
});
// D1 fix round (security review P1): an agent run never starts a dev-local-owner Console.
// agent-guard (#1873): nor a Console without it; the human start is unchanged.
test('an agent run cannot start a console, dev-local-owner or not; a human start is unchanged', { timeout: 60000 }, async t => {
const { ctx, mosaic } = await instance(t);
const agent = { ...ctx, env: { ...ctx.env, MOSAIC_RUN_ID: 'run-1' } };
for (const detach of [true, false]) {
await assert.rejects(consoleStart(agent, { devLocalOwner: true, detach, port: 'auto', warn: () => {} }), e => e.exitCode === 3 && /only from a human shell/.test(e.message));
}
// A clean environment given for the Console does not hide this process's own marker (re-check P1).
process.env.MOSAIC_RUN_ID = 'run-2';
try {
for (const detach of [true, false]) {
await assert.rejects(consoleStart(ctx, { devLocalOwner: true, detach, port: 'auto', warn: () => {} }), e => e.exitCode === 3 && /found MOSAIC_RUN_ID/.test(e.message));
}
} finally { delete process.env.MOSAIC_RUN_ID; }
const agentCli = args => spawnSync(join(REPO, 'scripts/mosaic'), args, { env: { ...ctx.env, MOSAIC_AGENT_RUN: '1' }, encoding: 'utf8', timeout: 30000 });
const refused = agentCli(['console', 'start', '--dev-local-owner', '--detach', '--port', 'auto']);
assert.equal(refused.status, 3); assert.equal(refused.stdout, ''); assert.match(refused.stderr, /only from a human shell/);
const consoleDir = join(ctx.dataRoot, 'console');
assert.deepEqual(existsSync(consoleDir) ? readdirSync(consoleDir) : [], []);
assert.deepEqual(consoleStatus(ctx), { running: false });
const plain = agentCli(['console', 'start', '--detach', '--port', 'auto']);
assert.equal(plain.status, 3); assert.equal(plain.stdout, ''); assert.match(plain.stderr, /only from a human shell/);
await assert.rejects(consoleStart(agent, { detach: true, port: 'auto', warn: () => {} }), e => e.exitCode === 3 && /found MOSAIC_RUN_ID/.test(e.message));
assert.deepEqual(existsSync(consoleDir) ? readdirSync(consoleDir) : [], []);
const human = mosaic(['console', 'start', '--detach', '--port', 'auto']);
assert.equal(human.status, 0, human.stderr);
assert.deepEqual(Object.keys(JSON.parse(human.stdout)), ['url', 'pid']);
assert.equal((await consoleStop(ctx)).stopped, true);
});
// D1 security re-check P2: a supervisor that hangs holding the start lock is killed with its group
// after the 60 s start timeout and 10 s grace. Its lock names it, so the start clears it and the
// next start proceeds. The hook blocks the supervisor's event loop once it holds the lock (at its
// console state read), so its stop handler never runs and only the group SIGKILL ends it.
test('a detached start that times out clears the start lock of the supervisor it killed', { timeout: 150000 }, async t => {
const { ctx } = await instance(t);
const hook = join(dirname(ctx.dataRoot), 'hang-after-lock.mjs');
writeFileSync(hook, `import fs from 'node:fs'; import { syncBuiltinESMExports } from 'node:module';
if (process.argv[1]?.endsWith('console-supervisor.mjs')) { const real = fs.openSync;
fs.openSync = (...args) => { if (String(args[0]).endsWith('/console/console.json')) Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0); return real(...args); };
syncBuiltinESMExports(); }\n`);
const hung = { ...ctx, env: { ...ctx.env, NODE_OPTIONS: `--import=${pathToFileURL(hook).href}` } };
const lock = join(ctx.dataRoot, 'console/start.lock');
const began = Date.now();
await assert.rejects(consoleStart(hung, { devLocalOwner: true, detach: true, port: 'auto', warn: () => {} }),
e => e.exitCode === 3 && /^console failed to start; see console\/supervisor-\d+-\d+\.log under the data root$/.test(e.message));
assert.ok(Date.now() - began >= 69000, 'the start waited out its timeout and grace');
assert.equal(existsSync(lock), false);
assert.deepEqual(consoleStatus(ctx), { running: false });
// The next start is not blocked by the killed supervisor's lock.
const result = await consoleStart(ctx, { devLocalOwner: true, detach: true, port: 'auto', warn: () => {} });
assert.equal((await consoleStop(ctx)).stopped, true);
assert.equal(await exitOf(result.done), 0);
});
// Without --dev-local-owner a detached start is what it was before D1: no supervisor, restart refused.
test('a detached console without the dev owner has no supervisor, so it refuses a restart and stays up', { timeout: 60000 }, async t => {
const { ctx, mosaic } = await instance(t);
const warnings = [];
const result = await consoleStart(ctx, { detach: true, port: 'auto', warn: s => warnings.push(s) });
assert.deepEqual(warnings, []);
assert.deepEqual(Object.keys(result), ['url', 'pid']);
const port = Number(new URL(result.url).port);
assert.ok(!argvOf(result.pid).includes('--supervised'));
assert.deepEqual(argvOf(result.pid), [process.execPath, WEBUI, 'serve', '--port', '0', '--business', 'alpha']);
assert.equal(readdirSync(join(ctx.dataRoot, 'console')).filter(x => x.startsWith('supervisor-')).length, 0);
assert.equal((await call(port, '/api/config')).body.consoleRestart, false);
const r = await call(port, '/api/console/restart', { method: 'POST', body: '{}' });
assert.equal(r.status, 409); assert.equal(r.body.error, 'not-supervised');
// Without a dev owner there is no owner session to restart as: refused before the supervisor check.
assert.equal(r.status, 403, JSON.stringify(r.body)); assert.deepEqual(r.body, { error: 'owner-only' });
assert.equal((await call(port, '/api/config')).status, 200);
assert.equal(existsSync(join(ctx.dataRoot, 'console/actions.jsonl')), false);
assert.equal((await consoleStop(ctx)).stopped, true);
// The CLI's receipt keeps its two fields.
const start = mosaic(['console', 'start', '--detach', '--port', 'auto']);
assert.equal(start.status, 0, start.stderr);
assert.deepEqual(Object.keys(JSON.parse(start.stdout)), ['url', 'pid']);
assert.equal(start.stderr, '');
assert.equal((await consoleStop(ctx)).stopped, true);
});
+25 -3
View File
@@ -21,8 +21,9 @@ async function fixture() {
const env = humanEnv({ HOME: root, MOSAIC_CONFIG: join(configDir, 'config.json') });
const moduleUrl = new URL('../src/cli.mjs', import.meta.url).href;
const code = `import { run } from ${JSON.stringify(moduleUrl)}; process.exitCode = await run(JSON.parse(process.argv[1]), undefined, {system: {dataRoot: process.argv[2]}});`;
const call = async (...args) => { const x = await exec(process.execPath, ['--input-type=module', '-e', code, JSON.stringify(args), dataRoot], { env }); return { out: { text: x.stdout }, err: { text: x.stderr } }; };
return { root, ctx, org, call };
const callWith = async (extra, ...args) => { const x = await exec(process.execPath, ['--input-type=module', '-e', code, JSON.stringify(args), dataRoot], { env: { ...env, ...extra } }); return { out: { text: x.stdout }, err: { text: x.stderr } }; };
const call = (...args) => callWith({}, ...args);
return { root, ctx, org, call, callWith };
}
const dead = 2147483647;
const json = (path, value) => writeFileSync(path, JSON.stringify(value), { mode: 0o600 });
@@ -121,8 +122,9 @@ test('org lock show and clear require a provably dead holder', async () => {
test('bus recover preserves evidence for dead and wrong-process hosts without signals', async () => {
const f = await fixture(), dir = join(f.org.root, 'bus-host'); mkdirSync(dir, { mode: 0o700 });
// A wrong process is a live pid whose start time is not the recorded one (the pid was reused).
for (const pid of [dead, process.pid]) {
const state = { pid, startTime: startTimeOf(pid) ?? '1', business: 'alpha', startedAt: '2026-01-01T00:00:00Z' };
const state = { pid, startTime: '1', business: 'alpha', startedAt: '2026-01-01T00:00:00Z' };
json(join(dir, 'host.json'), state); json(join(dir, 'host.lock'), state);
const x = await f.call('bus', 'recover', 'alpha');
const result = JSON.parse(x.out.text);
@@ -138,6 +140,26 @@ test('bus recover preserves evidence for dead and wrong-process hosts without si
assert.equal(process.pid > 0, true);
});
test('D2: bus recover refuses a live pid with the recorded start time, whatever its argv', async () => {
const f = await fixture(), dir = join(f.org.root, 'bus-host'); mkdirSync(dir, { mode: 0o700 });
const state = { pid: process.pid, startTime: startTimeOf(process.pid), business: 'alpha', startedAt: '2026-01-01T00:00:00Z' };
assert.match(state.startTime, /^\d+$/);
json(join(dir, 'host.json'), state); json(join(dir, 'host.lock'), state);
await assert.rejects(f.call('bus', 'recover', 'alpha'), /bus host identity is uncertain/);
assert.deepEqual(JSON.parse(readFileSync(join(dir, 'host.json'), 'utf8')), state);
assert.equal(existsSync(join(dir, 'host.lock')), true);
assert.ok(!readdirSync(dir).some(x => x.startsWith('recovery-')));
});
test('D2: bus recover refuses inside an agent run before it reads anything', async () => {
const f = await fixture(), dir = join(f.org.root, 'bus-host'); mkdirSync(dir, { mode: 0o700 });
const state = { pid: dead, startTime: '1', business: 'alpha', startedAt: '2026-01-01T00:00:00Z' };
json(join(dir, 'host.json'), state); json(join(dir, 'host.lock'), state);
await assert.rejects(f.callWith({ MOSAIC_AGENT_RUN: '1' }, 'bus', 'recover', 'alpha'), e => e.code === 3 && /outside any agent run/.test(e.stderr));
assert.equal(existsSync(join(dir, 'host.json')), true);
assert.ok(!readdirSync(dir).some(x => x.startsWith('recovery-')));
});
test('lock recovery refuses malformed or unreadable holders', async () => {
const f = await fixture(), path = join(f.ctx.configDir, 'org-lifecycle-alpha.lock');
for (const value of [{ pid: 0, at: '2026-01-01T00:00:00Z' }, { pid: dead }, null]) {
@@ -0,0 +1,94 @@
// E1 (P1-4): `mosaic org restore` brings the organization back running. It starts the bus as
// `mosaic bus start ID --dev-local-owner --detach` would; a bus that does not start is said so with
// that verb, and the restore stands. On an instance where that start is refused, the restore is
// refused first and changes nothing. `--no-bus` keeps the old restore. The bus start itself is a
// stand-in here; agent-guard.test.mjs and org.test.mjs start a real one.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import * as service from '../../organization/src/org-service.mjs';
import { listOrgs } from '../../organization/src/org-registry.mjs';
import { DEV_OWNER } from '../src/dev-local-owner.mjs';
import { orgCommand } from '../src/org.mjs';
import { io, humanEnv } from './helpers.mjs';
// A named instance under a temporary HOME (the dev-owner checks read the instance from HOME), with
// one archived organization.
async function archived(t) {
const home = mkdtempSync(join(tmpdir(), 'e1-restore-')), saved = process.env.HOME;
process.env.HOME = home;
t.after(() => { process.env.HOME = saved; rmSync(home, { recursive: true, force: true }); });
const configDir = join(home, '.config', 'mosaic-e1test'), dataRoot = join(home, 'data');
mkdirSync(configDir, { recursive: true, mode: 0o700 }); mkdirSync(dataRoot, { mode: 0o700 });
const ctx = { configDir, dataRoot, instance: 'e1test', principal: DEV_OWNER };
const created = await service.createOrg(ctx, 'alpha', await service.templateRender('alpha'));
const gone = await service.archiveOrg(ctx, 'alpha', { ifRevision: created.revision, busStop: async () => ({ stopped: false }) });
const env = humanEnv({ HOME: home, MOSAIC_CONFIG: join(configDir, 'config.json') });
const state = async () => (await listOrgs(ctx)).find(e => e.id === 'alpha').state;
return { ctx, env, revision: gone.revision, state, system: { dataRoot } };
}
const restore = (f, deps, ...extra) => { const x = io(f.env); return orgCommand(['restore', 'alpha', '--if-revision', String(f.revision), '--json', ...extra], x, { system: f.system, ...deps }).then(() => x, e => { e.io = x; throw e; }); };
test('E1: restore starts the bus as the dev owner and its receipt names the bus', async t => {
const f = await archived(t), calls = [];
const x = await restore(f, { busStart: async (ctx, id, opts) => { calls.push([ctx.dataRoot, ctx.env === f.env, id, opts]); return { org: id, pid: 4242, socket: '/s', root: '/r' }; } });
assert.deepEqual(calls, [[f.ctx.dataRoot, true, 'alpha', { detach: true, devLocalOwner: true, pm: false }]]);
const receipt = JSON.parse(x.out.text);
assert.equal(receipt.revision, f.revision + 1);
assert.deepEqual(receipt.bus, { outcome: 'started', pid: 4242 });
assert.equal(await f.state(), 'active');
});
test('E1: a bus that does not start after a good restore is said so, with the verb, and the restore stands', async t => {
const f = await archived(t);
const { CliError } = await import('../src/errors.mjs');
const busStart = async () => { throw new CliError('bus failed to start; see the bus log', 3); };
await assert.rejects(restore(f, { busStart }), e => {
assert.equal(e.exitCode, 3);
assert.equal(e.message, 'alpha was restored, but its bus did not start: bus failed to start; see the bus log; start it with mosaic bus start alpha --dev-local-owner --detach');
// The restore receipt is printed before the refusal.
const receipt = JSON.parse(e.io.out.text);
assert.equal(receipt.revision, f.revision + 1);
assert.deepEqual(receipt.bus, { outcome: 'failed', message: 'bus failed to start; see the bus log', next: 'mosaic bus start alpha --dev-local-owner --detach' });
return true;
});
assert.equal(await f.state(), 'active');
});
test('E1: a bus host that died leaves a stale lock; the receipt names bus recover before the start', async t => {
const f = await archived(t);
const { CliError } = await import('../src/errors.mjs');
const busStart = async () => { throw new CliError('stale bus host lock', 3); };
await assert.rejects(restore(f, { busStart }), e => {
assert.equal(e.exitCode, 3);
assert.equal(e.message, 'alpha was restored, but its bus did not start: stale bus host lock; clear the dead host with mosaic bus recover alpha, then start it with mosaic bus start alpha --dev-local-owner --detach');
assert.deepEqual(JSON.parse(e.io.out.text).bus, { outcome: 'failed', message: 'stale bus host lock', next: 'mosaic bus recover alpha; mosaic bus start alpha --dev-local-owner --detach' });
return true;
});
assert.equal(await f.state(), 'active');
});
test('E1: where a dev-owner bus start is refused, restore is refused before it changes anything, and --no-bus restores', async t => {
const f = await archived(t); let starts = 0;
const busStart = async () => { starts++; return { pid: 1 }; };
writeFileSync(join(f.ctx.dataRoot, '.deployed'), '{}\n');
await assert.rejects(restore(f, { busStart }), e => e.exitCode === 3 && /^restore starts the bus as the local dev owner, and --dev-local-owner refuses \.deployed; restore with --no-bus, then start the bus with mosaic bus start$/.test(e.message) && e.io.out.text === '');
assert.equal(await f.state(), 'archived');
const x = await restore(f, { busStart }, '--no-bus');
assert.equal(JSON.parse(x.out.text).bus, null);
assert.equal(starts, 0);
assert.equal(await f.state(), 'active');
});
test('E1: an agent run may restore only with --no-bus', async t => {
const f = await archived(t); let starts = 0;
const busStart = async () => { starts++; return { pid: 1 }; };
f.env = { ...f.env, MOSAIC_AGENT_RUN: '1' };
await assert.rejects(restore(f, { busStart }), e => e.exitCode === 3 && /MOSAIC_AGENT_RUN/.test(e.message));
assert.equal(await f.state(), 'archived');
await restore(f, { busStart }, '--no-bus');
assert.equal(starts, 0);
assert.equal(await f.state(), 'active');
});
+162
View File
@@ -0,0 +1,162 @@
// D2 (P0-3): a Console unit stop (KillMode=mixed) SIGKILLs the bus hosts the Console started. These
// tests start real bus hosts, kill them the same way, and check that busRevive and the Console-start
// revive bring them back, and that a live host or a live writer lock is never taken over.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawn, spawnSync } from 'node:child_process';
import { existsSync, lstatSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { reserveOrg, activateOrg, listOrgs } from '../../organization/src/org-registry.mjs';
import { businessDoc, rolesCopy, writeJson } from '../../organization/tests/helpers.mjs';
import { humanEnv, notifyConfig } from './helpers.mjs';
import { busRevive, busStart, busStatus, busStop, setAside } from '../src/org-bus.mjs';
import { reviveBuses } from '../../webui/src/bus-revive.mjs';
const REPO = new URL('../../../', import.meta.url).pathname;
const IDS = ['alpha', 'beta'];
async function instance(t) {
const home = mkdtempSync(join(tmpdir(), 'rv-'));
const env = humanEnv({ HOME: home, MOSAIC_CONFIG: join(home, '.config/mosaic-rvtest/config.json') });
const setup = spawnSync(join(REPO, 'scripts/bootstrap.sh'), ['--instance', 'rvtest'], { env, encoding: 'utf8' });
assert.equal(setup.status, 0, setup.stderr);
const ctx = { configDir: join(home, '.config/mosaic-rvtest'), dataRoot: join(home, '.mosaic-rvtest'), instance: 'rvtest', env };
env.MOSAIC_ROLES_DIR = rolesCopy(home);
const roots = {};
for (const id of IDS) {
const org = await reserveOrg(ctx, id);
await activateOrg(ctx, id);
writeJson(join(ctx.configDir, 'businesses', `${id}.json`), businessDoc(home, id));
notifyConfig(org.root, id, null);
roots[id] = org.root;
}
const extra = [];
t.after(async () => {
for (const child of extra) { try { child.kill('SIGKILL'); } catch {} }
for (const id of IDS) { try { await busStop(ctx, id); } catch {} }
rmSync(home, { recursive: true, force: true });
});
return { ctx, roots, extra };
}
// What a Console unit stop does to a host it started: SIGKILL to the host and every process under it.
// The host is a session leader (detached), so its process group is the test's own; `done` resolves
// once the host is reaped, and the loop waits for the rest of the group to go.
async function kill(receipt) {
process.kill(-receipt.pid, 'SIGKILL');
await receipt.done;
for (let i = 0; i < 100; i++) {
try { process.kill(-receipt.pid, 0); } catch (e) { if (e.code === 'ESRCH') return; throw e; }
await new Promise(r => setTimeout(r, 50));
}
throw new Error(`process group ${receipt.pid} did not go`);
}
const host = root => JSON.parse(readFileSync(join(root, 'bus-host/host.json'), 'utf8'));
const recoveries = root => readdirSync(join(root, 'bus-host')).filter(name => /^recovery-.*\.json$/.test(name));
test('D2: a SIGKILLed bus host comes back from one revive, on the options it was started with', { timeout: 120000 }, async t => {
const { ctx, roots } = await instance(t);
const first = await busStart(ctx, 'alpha', { detach: true, devLocalOwner: true, noLaunch: true });
await kill(first);
// What a Console unit stop leaves behind: the dead host's state, lock and socket.
assert.equal(existsSync(join(roots.alpha, 'bus-host/host.json')), true);
await assert.rejects(busStatus(ctx, 'alpha'), e => e.name === 'CliError' && /stale/.test(e.message));
const revived = await busRevive(ctx, 'alpha');
assert.notEqual(revived.pid, first.pid);
assert.equal(revived.recovered.pid, first.pid);
assert.equal((await busStatus(ctx, 'alpha')).healthy, true);
assert.deepEqual(host(roots.alpha).options, { devLocalOwner: true, pm: false, noLaunch: true });
assert.equal(recoveries(roots.alpha).length, 1);
const receipt = JSON.parse(readFileSync(revived.recovered.receipt, 'utf8'));
assert.equal(receipt.host.pid, first.pid);
});
test('D2: a live bus host is never taken over, and its state is left as it was', { timeout: 120000 }, async t => {
const { ctx, roots } = await instance(t);
const live = await busStart(ctx, 'alpha', { detach: true, noLaunch: true });
const before = readFileSync(join(roots.alpha, 'bus-host/host.json'), 'utf8');
await assert.rejects(busRevive(ctx, 'alpha'), e => e.name === 'CliError' && e.message === `bus host pid ${live.pid} is alive`);
assert.equal(readFileSync(join(roots.alpha, 'bus-host/host.json'), 'utf8'), before);
assert.equal(recoveries(roots.alpha).length, 0);
const status = await busStatus(ctx, 'alpha');
assert.equal(status.healthy, true);
assert.equal(status.pid, live.pid);
});
test('D2: a writer lock held by a live process refuses the revive, even with the host dead', { timeout: 120000 }, async t => {
const { ctx, roots, extra } = await instance(t);
const first = await busStart(ctx, 'alpha', { detach: true, noLaunch: true });
await kill(first);
// A live process the test started holds the writer lock.
const holder = spawn('sleep', ['60'], { stdio: 'ignore' }); extra.push(holder);
writeFileSync(join(roots.alpha, 'bus/writer.lock'), JSON.stringify({ pid: holder.pid, at: new Date().toISOString() }), { mode: 0o600 });
const lockBefore = readFileSync(join(roots.alpha, 'bus/writer.lock'), 'utf8');
await assert.rejects(busRevive(ctx, 'alpha'), e => e.name === 'CliError' && e.message === `bus writer pid ${holder.pid} is alive`);
assert.equal(readFileSync(join(roots.alpha, 'bus/writer.lock'), 'utf8'), lockBefore);
assert.equal(existsSync(join(roots.alpha, 'bus-host/host.json')), true);
assert.equal(recoveries(roots.alpha).length, 0);
// The Console-start revive leaves it too, and logs the refusal.
const lines = [], trail = [];
const results = await reviveBuses({ ctx, listOrgs, busStatus, busRevive, env: {}, trail: e => trail.push(e), log: line => lines.push(line) });
assert.deepEqual(results.find(r => r.id === 'alpha'), { id: 'alpha', outcome: 'refused' });
assert.ok(lines.some(line => line.includes(`alpha: refused (bus writer pid ${holder.pid} is alive)`)), lines.join('\n'));
assert.deepEqual(trail.filter(e => e.org === 'alpha').map(e => e.outcome), ['requested', 'refused']);
assert.equal(existsSync(join(roots.alpha, 'bus-host/host.json')), true);
});
test('D2: Console start brings both killed buses back and reattaches to one that still runs', { timeout: 180000 }, async t => {
const { ctx, roots } = await instance(t);
const a = await busStart(ctx, 'alpha', { detach: true, devLocalOwner: true, noLaunch: true });
const b = await busStart(ctx, 'beta', { detach: true, devLocalOwner: true, noLaunch: true });
await kill(a); await kill(b);
const trail = [], lines = [];
const results = await reviveBuses({ ctx, listOrgs, busStatus, busRevive, env: {}, trail: e => trail.push(e), log: line => lines.push(line) });
assert.deepEqual(results.map(r => [r.id, r.outcome]), [['alpha', 'revived'], ['beta', 'revived']]);
for (const id of IDS) {
const status = await busStatus(ctx, id);
assert.equal(status.healthy, true, id);
assert.equal(host(roots[id]).options.devLocalOwner, true);
assert.deepEqual(trail.filter(e => e.org === id).map(e => [e.action, e.outcome, e.by.sub]), [['bus.revive', 'requested', 'local-dev-owner'], ['bus.revive', 'done', 'local-dev-owner']]);
}
// A second Console start finds them running and leaves them.
const pids = Object.fromEntries(await Promise.all(IDS.map(async id => [id, (await busStatus(ctx, id)).pid])));
const again = await reviveBuses({ ctx, listOrgs, busStatus, busRevive, env: {}, trail: e => trail.push(e), log: () => {} });
assert.deepEqual(again.map(r => [r.id, r.outcome]), [['alpha', 'running'], ['beta', 'running']]);
for (const id of IDS) assert.equal((await busStatus(ctx, id)).pid, pids[id]);
});
test('D2: a start that fails after the dead state was cleared says so (error.recovered)', { timeout: 120000 }, async t => {
const { ctx, roots } = await instance(t);
const first = await busStart(ctx, 'alpha', { detach: true, noLaunch: true });
await kill(first);
// A record busStart refuses (--pm with --no-launch), rewritten in place: the clear goes through and
// the start that follows fails, the way a host that cannot come up would.
const file = join(roots.alpha, 'bus-host/host.json');
writeFileSync(file, JSON.stringify({ ...host(roots.alpha), options: { devLocalOwner: false, pm: true, noLaunch: true } }));
await assert.rejects(busRevive(ctx, 'alpha'), e => e.name === 'CliError' && /--pm or --no-launch/.test(e.message) && e.recovered?.pid === first.pid);
assert.equal(existsSync(file), false);
assert.equal(recoveries(roots.alpha).length, 1);
});
test('D2: an entry swapped after inspection is put back and nothing is removed', t => {
const dir = mkdtempSync(join(tmpdir(), 'aside-'));
t.after(() => rmSync(dir, { recursive: true, force: true }));
const a = join(dir, 'host.json'), b = join(dir, 'writer.lock');
writeFileSync(a, 'dead host', { mode: 0o600 }); writeFileSync(b, 'dead writer', { mode: 0o600 });
const entries = [{ path: a, st: lstatSync(a) }, { path: b, st: lstatSync(b) }];
// The swap: the inspected writer.lock is moved off (its inode stays in use) and a new one takes the name.
renameSync(b, join(dir, 'kept')); writeFileSync(b, 'live writer', { mode: 0o600 });
assert.throws(() => setAside(entries), e => e.name === 'CliError' && e.message === 'bus recovery state changed');
assert.equal(readFileSync(a, 'utf8'), 'dead host');
assert.equal(readFileSync(b, 'utf8'), 'live writer');
assert.deepEqual(readdirSync(dir).sort(), ['host.json', 'kept', 'writer.lock']);
// Unswapped, the entries go aside and remove() deletes only those.
renameSync(join(dir, 'kept'), b);
const fresh = [{ path: a, st: lstatSync(a) }, { path: b, st: lstatSync(b) }];
const aside = setAside(fresh);
assert.equal(existsSync(a) || existsSync(b), false);
writeFileSync(a, 'a new host', { mode: 0o600 });
aside.remove();
assert.deepEqual(readdirSync(dir), ['host.json']);
assert.equal(readFileSync(a, 'utf8'), 'a new host');
});
+2
View File
@@ -21,6 +21,8 @@ test('organization CLI renders, validates, creates, updates and initializes git'
info = JSON.parse(invoke('org', 'show', 'alpha', '--json')); const archived = JSON.parse(invoke('org', 'archive', 'alpha', '--if-revision', String(info.revision), '--json')); assert.equal(archived.revision, info.revision + 1);
assert.equal(JSON.parse(invoke('org', 'list', '--json'))[0].state, 'archived');
const restored = JSON.parse(invoke('org', 'restore', 'alpha', '--if-revision', String(archived.revision), '--json')); assert.equal(restored.revision, archived.revision + 1);
// E1 (P1-4): restore brings the organization back running; the delete below stops the bus again.
assert.equal(restored.bus.outcome, 'started'); assert.ok(Number.isInteger(restored.bus.pid)); assert.equal(JSON.parse(invoke('bus', 'status', 'alpha', '--json')).healthy, true);
info = JSON.parse(invoke('org', 'show', 'alpha', '--json')); const receipt = JSON.parse(invoke('org', 'delete', 'alpha', '--confirm', 'alpha', '--if-revision', String(info.revision), '--json')); assert.equal(receipt.org, 'alpha'); assert.deepEqual(JSON.parse(invoke('org', 'list', '--json')), []);
});
test('business and org aliases conflict in either order', () => { for (const args of [['--business','alpha','--org','beta'], ['--org','beta','--business','alpha']]) { const r = run(process.env, 'tasks', ...args); assert.notEqual(r.status, 0); assert.match(r.stderr, /conflict/i); } });
+130
View File
@@ -0,0 +1,130 @@
// #1873 CLI parity for the Console's Proxy page: status, unit, init-lock, init --generate-key and
// fence clear --restart. Every verb runs only from a human shell; nothing prints a key or a path.
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { main } from '../src/cli.mjs';
import { proxyCommand } from '../src/model-proxy.mjs';
import { io } from './helpers.mjs';
const code = c => e => e.code === c;
const fakeSystem = () => ({ dataRoot: join(mkdtempSync(join(tmpdir(), 'ps-cli-')), 'data') });
const STATUS = {
release: { fetched: true, version: '8.0.42', panelVersion: '1.26.7', verified: true },
init: { initialized: true, partial: false, settings: { port: 18432 }, files: [] },
unit: { unit: 'mosaic-proxy.service', installed: true, active: 'active', pid: 77, port: 18432, listening: true },
fence: { set: false }, initLock: { present: false, clearable: false, blockers: [] },
ownProxy: { available: false, state: 'not built' }, next: ['accounts'],
};
function fakeAdmin({ restart = { confirmed: true } } = {}) {
const calls = [];
return {
calls,
status: async () => { calls.push(['status']); return STATUS; },
render: async () => { calls.push(['render']); return { text: '[Unit]\nDescription=fixture\n' }; },
unitAction: async op => { calls.push(['unit', op]); return op === 'restart' ? { receipt: { op: 'unit.restart' }, evidence: restart } : { op, ok: true }; },
initLockClear: async () => { calls.push(['init-lock.clear']); return { cleared: true, receipt: { op: 'init-lock.clear' } }; },
fenceClear: async call => { calls.push(['fence.clear']); return call('proxy.fence.clear', { evidence: restart }); },
};
}
const human = () => io();
test('every new setup verb refuses inside an agent shell and with --tester, before the controller runs', async () => {
const admin = fakeAdmin();
for (const args of [['proxy', 'status'], ['proxy', 'status', '--json'], ['proxy', 'unit', 'render'], ['proxy', 'unit', 'start'], ['proxy', 'unit', 'restart'],
['proxy', 'init-lock', 'status'], ['proxy', 'init-lock', 'clear'], ['proxy', 'fence', 'clear', '--restart', '--business', 'demo'], ['proxy', 'init', '--generate-key', '--business', 'demo']]) {
await assert.rejects(main(args, io({ CODEX_THREAD_ID: 'fixture' }), { system: fakeSystem(), proxyAdmin: admin, transport: () => () => { throw Error('must not call'); } }), code('human-required'), args.join(' '));
await assert.rejects(main([...args, '--tester'], human(), { system: fakeSystem(), proxyAdmin: admin }), code('human-required'), `${args.join(' ')} --tester`);
}
assert.deepEqual(admin.calls, []);
});
test('proxy status prints the same facts the page shows, and --json the status object', async () => {
const admin = fakeAdmin();
const x = human();
await main(['proxy', 'status'], x, { system: fakeSystem(), proxyAdmin: admin });
assert.match(x.out.text, /^release: 8\.0\.42 \(panel 1\.26\.7\) hashes verified$/m);
assert.match(x.out.text, /^unit: mosaic-proxy\.service installed, active pid 77, port 18432 answering$/m);
assert.match(x.out.text, /^own proxy: not built$/m);
assert.match(x.out.text, /^next: accounts$/m);
const y = human();
await main(['proxy', 'status', '--json'], y, { system: fakeSystem(), proxyAdmin: admin });
assert.deepEqual(JSON.parse(y.out.text), STATUS);
await assert.rejects(main(['proxy', 'status', '--verbose'], human(), { system: fakeSystem(), proxyAdmin: admin }), code('proxy-usage'));
});
test('proxy unit and init-lock verbs map one to one onto the controller; bad usage refuses', async () => {
const admin = fakeAdmin();
const x = human();
await main(['proxy', 'unit', 'render'], x, { system: fakeSystem(), proxyAdmin: admin });
assert.equal(x.out.text, '[Unit]\nDescription=fixture\n');
for (const op of ['install', 'start', 'stop']) await main(['proxy', 'unit', op], human(), { system: fakeSystem(), proxyAdmin: admin });
await main(['proxy', 'unit', 'status'], human(), { system: fakeSystem(), proxyAdmin: admin });
await main(['proxy', 'init-lock', 'status'], human(), { system: fakeSystem(), proxyAdmin: admin });
const y = human();
await main(['proxy', 'init-lock', 'clear'], y, { system: fakeSystem(), proxyAdmin: admin });
assert.equal(JSON.parse(y.out.text).cleared, true);
assert.deepEqual(admin.calls, [['render'], ['unit', 'install'], ['unit', 'start'], ['unit', 'stop'], ['status'], ['status'], ['init-lock.clear']]);
for (const args of [['proxy', 'unit'], ['proxy', 'unit', 'reload'], ['proxy', 'unit', 'start', 'now'], ['proxy', 'init-lock'], ['proxy', 'init-lock', 'clear', '--force']])
await assert.rejects(main(args, human(), { system: fakeSystem(), proxyAdmin: admin }), code('proxy-usage'), args.join(' '));
});
test('a controller refusal reaches the shell as its proxy code only, with no path or detail', async () => {
const admin = fakeAdmin();
admin.unitAction = async () => { throw Object.assign(Error('/home/owner/.config/mosaic-dev/proxy.json pa-cli-sentinel'), { code: 'proxy-unit-missing' }); };
admin.status = async () => { throw Error('pa-cli-sentinel'); };
await assert.rejects(main(['proxy', 'unit', 'start'], human(), { system: fakeSystem(), proxyAdmin: admin }), e => e.code === 'proxy-unit-missing' && !e.message.includes('pa-cli-sentinel') && !e.message.includes('/home/'));
await assert.rejects(main(['proxy', 'status'], human(), { system: fakeSystem(), proxyAdmin: admin }), e => e.code === 'proxy-unit-failed' && !e.message.includes('pa-cli-sentinel'));
});
test('proxy init --generate-key needs no terminal, never prompts, and sends generate to the bus', async () => {
const x = human();
assert.notEqual(x.stdin.isTTY, true);
const calls = [], system = fakeSystem();
await main(['proxy', 'init', '--generate-key', '--business', 'demo', '--port', '18440', '--rotate-grace-minutes', '5'], x, {
system, promptProxyKey: () => { throw Error('must not prompt'); },
transport: b => async (verb, args) => { calls.push({ b, verb, args }); return { port: 18440, rotateGraceMinutes: 5, binary: '/fixture' }; } });
assert.deepEqual(calls, [{ b: 'demo', verb: 'proxy.init', args: { generate: true, port: 18440, rotateGraceMinutes: 5 } }]);
assert.deepEqual(JSON.parse(x.out.text), { port: 18440, rotateGraceMinutes: 5, firstOutputSeconds: null, generated: true });
// The same init receipt the page writes, settings only.
const [receipt] = readFileSync(join(system.dataRoot, 'state', 'proxy-admin.jsonl'), 'utf8').trim().split('\n').map(JSON.parse);
assert.deepEqual({ ...receipt, time: 'T' }, { time: 'T', op: 'init', generated: true, port: 18440, rotateGraceMinutes: 5, firstOutputSeconds: null });
assert.match(x.err.text, /^receipt: \{"time":/m);
await assert.rejects(main(['proxy', 'init', '--generate-key', '--generate-key', '--business', 'demo'], human(), { system: fakeSystem() }), code('proxy-usage'));
await assert.rejects(main(['proxy', 'init', '--generate-key', '--port', '8317', '--business', 'demo'], human(), { system: fakeSystem() }), code('proxy-config-invalid'));
});
test('proxy fence clear --restart restarts first and clears only on a confirmed restart', async () => {
const admin = fakeAdmin();
const calls = [];
const x = human();
await main(['proxy', 'fence', 'clear', '--restart', '--business', 'demo'], x, { system: fakeSystem(), proxyAdmin: admin,
transport: b => async (verb, args) => { calls.push({ b, verb, args }); return { cleared: true, evidence: { after: { pid: 2 } } }; } });
assert.deepEqual(admin.calls, [['unit', 'restart'], ['fence.clear']]);
assert.deepEqual(calls, [{ b: 'demo', verb: 'proxy.fence.clear', args: { evidence: { confirmed: true } } }]);
assert.deepEqual(JSON.parse(x.out.text), { restart: { op: 'unit.restart' }, cleared: true, evidence: { after: { pid: 2 } } });
const unconfirmed = fakeAdmin({ restart: { confirmed: false } });
await assert.rejects(main(['proxy', 'fence', 'clear', '--restart', '--business', 'demo'], human(), { system: fakeSystem(), proxyAdmin: unconfirmed,
transport: () => () => { throw Error('must not call'); } }), code('proxy-restart-unconfirmed'));
assert.deepEqual(unconfirmed.calls, [['unit', 'restart']]);
await assert.rejects(main(['proxy', 'fence', 'clear', '--restart', '--restart'], human(), { system: fakeSystem(), proxyAdmin: admin }), code('proxy-usage'));
});
test('proxy fetch shows the page\'s progress steps and writes the same fetch receipt; stdout keeps its line', async () => {
const system = fakeSystem();
const x = human();
await proxyCommand(['fetch'], x, { system, fetch: async ({ progress }) => { for (const step of ['download-archive', 'verify-archive', 'install']) progress(step); return { binary: 'fixture-binary', panel: 'fixture-panel' }; } });
assert.deepEqual(JSON.parse(x.out.text), { binary: 'fixture-binary', panel: 'fixture-panel' });
assert.match(x.err.text, /^fetch: download-archive\nfetch: verify-archive\nfetch: install\nreceipt: /);
const receipts = readFileSync(join(system.dataRoot, 'state', 'proxy-admin.jsonl'), 'utf8').trim().split('\n').map(JSON.parse);
assert.equal(receipts.length, 1);
assert.equal(receipts[0].op, 'fetch');
assert.equal(receipts[0].verified, true);
assert.match(receipts[0].archiveSha256, /^[0-9a-f]{64}$/);
// A failed hash check writes no receipt and keeps its code.
const failed = fakeSystem();
await assert.rejects(proxyCommand(['fetch'], human(), { system: failed, fetch: async () => { throw Object.assign(Error('mismatch'), { code: 'proxy-fetch-hash' }); } }), code('proxy-fetch-hash'));
assert.throws(() => readFileSync(join(failed.dataRoot, 'state', 'proxy-admin.jsonl')), { code: 'ENOENT' });
});
+51
View File
@@ -125,3 +125,54 @@ buses, the proxy's own panel, other holders of the management key, and the
human `proxy fetch` and `proxy init` tools do not participate. Such writers can
still race a whole-list write or alter proxy state. A server-side fence is a
follow-up; do not run those writers concurrently with organization buses.
### Setup from the Console (#1873)
Settings > Model proxy has a Proxy setup panel for the instance owner: the
`--dev-local-owner` owner on a dev Console, or the root owner under `--sso`.
Agents, testers and grant holders get `human-required` on every setup route,
read or write. The panel shows the release (pinned version, hashes verified),
initialization, the unit (installed, active, pid, port), the fence, the init
lock, each organization's proxy (shared) and catalogue, and own proxy. Each
state offers its next step as one button. CLI parity:
mosaic proxy status [--json]
mosaic proxy fetch
mosaic proxy init --generate-key [--port N] [--rotate-grace-minutes N] [--first-output-seconds N] --business ID
mosaic proxy unit render|status|install|start|stop|restart
mosaic proxy fence clear --restart --business ID
mosaic proxy init-lock status|clear
- Fetch runs the same PIN and hash checks as `mosaic proxy fetch`; the page
shows each step as it runs.
- Init asks the bus to generate the management key (`proxy.init` with
`generate: true`, 48 CSPRNG bytes). The key goes to the key file only; no
response, event or receipt carries it. A pasted key with its confirmation is
accepted only when the page is on HTTPS or on loopback from a loopback
peer; a plain-HTTP LAN page gets the generated key only
(`proxy-key-transport`). Init keeps its exclusive-create rules and never
rewrites `config.json`. Restart the other buses afterwards.
- The unit verbs install the same user unit as `scripts/proxy-service.sh`.
Health is the unit's main process tree listening on the configured port.
- `fence clear --restart` (and the page's fence step) restarts the unit,
confirms a new process that started after the fence and listens on the port,
then clears the fence with that evidence under the lock. The receipt holds the
before and after pids. Without a confirmed restart it refuses
(`proxy-restart-unconfirmed`). A fence file with no readable time also refuses,
because a restart cannot be shown to come after it; restart the service
yourself and use `fence clear --proxy-restarted`, which is unchanged.
- `proxy-admin-busy` (one setup action at a time) is per process: the Console
and a `mosaic proxy` command each hold their own. The writes that matter are
still serialized across processes: init by the bus's init lock and
exclusive create, key writes by the instance-wide proxy lock, and unit
actions by systemd.
- The page and `status --json` carry the last ten receipts from
`proxy-admin.jsonl`, so they survive a reload. CLI `init --generate-key`
writes the same init receipt as the page.
- The init lock clears only when every bus on the instance is stopped and no
`proxy.json`, key file or proxy state directory exists. Otherwise the panel
and `init-lock status` list why not.
- Own proxy per organization is not built: the configuration has one proxy
per instance (ruling 8, deferred).
Receipts: `<dataRoot>/state/proxy-admin.jsonl` (labels, pids and port only).
+140
View File
@@ -0,0 +1,140 @@
// #1873 proxy setup controller shared by the Console page (webui proxy-admin.mjs) and
// `mosaic proxy status|unit|init-lock|fence clear --restart`. Host actions run here; init and
// the fence clear go to the bus, which decides them. Results and receipts carry labels, key IDs
// and process ids only: never a key, token, absolute path or upstream text.
import { homedir } from 'node:os';
import { fetchPinned } from './fetch.mjs';
import { PIN } from './common.mjs';
import { releaseState, initState, fenceState, initLockStatus, clearInitLock, appendReceipt, recentReceipts, createUnitControl, FILE_LABELS } from './admin.mjs';
export const proxyCode = (error, fallback) => (/^proxy-[a-z-]+$/.test(error?.code ?? '') ? error.code : fallback);
const failure = code => Object.assign(new Error(code), { code });
// `buses()` proves the init-lock condition "bus stopped" ({stopped, running:[label]}).
// `orgs()` lists the organizations this proxy serves: [{id, catalogue}]. `unit` and `fetch` are
// (and `pin`) seams for tests; the defaults run the real proxy-service.sh, systemctl and pinned fetch.
export function createProxyAdmin({ dataRoot, configDir, instance = null, unitDir = null, unit = null, fetch = fetchPinned, buses, orgs = async () => [], now = () => new Date(), env = process.env, pin = PIN }) {
const port = () => { try { return initState(configDir, dataRoot).settings?.port ?? null; } catch { return null; } };
const control = unit ?? createUnitControl({ instance, unitDir, port, env });
let job = { state: 'idle', step: null, receipt: null, error: null };
// The last confirmed restart this page made. One fence clear may use it.
let restarted = null;
let busy = null;
const receipt = record => appendReceipt(dataRoot, { time: now().toISOString(), ...record });
const fetchRecord = () => ({ op: 'fetch', version: pin.version, panelVersion: pin.panel.version, archiveSha256: pin.archive.sha256, panelSha256: pin.panel.sha256, binarySha256: pin.binary.sha256, verified: true });
async function exclusive(name, action) {
if (busy) throw failure('proxy-admin-busy');
busy = name;
try { return await action(); } finally { busy = null; }
}
async function unitStatus() {
try { return await control.status(); } catch (error) { return { unit: control.unit, installed: false, active: 'unknown', healthy: false, error: proxyCode(error, 'proxy-unit-failed') }; }
}
function nextSteps({ release, init, unitState, fence, lock }) {
if (!release.fetched || release.problem) return ['fetch'];
if (lock.present) return lock.clearable ? ['init-lock-clear'] : ['init-lock-wait'];
if (init.partial || init.problem) return ['init-repair'];
if (!init.initialized) return ['init'];
if (!unitState.installed) return ['unit-install'];
if (unitState.active !== 'active') return ['unit-start'];
if (!unitState.healthy) return ['unit-restart'];
if (fence) return restarted ? ['fence-clear'] : ['unit-restart'];
return ['accounts'];
}
return {
unit: control.unit,
async status() {
// The job is read before the release: a fetch that ends during the awaits below then reads
// "running" (and the page polls again), never "done" next to a release read before it ended.
const fetchJob = { ...job };
const release = releaseState(dataRoot, pin);
const init = initState(configDir, dataRoot);
const unitState = await unitStatus();
const fence = fenceState(dataRoot);
const lock = init.lock ? initLockStatus({ configDir, dataRoot, buses: await buses() }) : { present: false, clearable: false, blockers: [] };
let organizations = null;
try { organizations = await orgs(); } catch { organizations = null; }
return {
release: { version: release.version, panelVersion: release.panelVersion, fetched: release.fetched, verified: release.verified, problem: release.problem },
init: {
initialized: init.initialized, partial: init.partial, problem: init.problem, settings: init.settings,
files: Object.entries(init.files).map(([name, found]) => ({ label: FILE_LABELS[name], present: found })),
},
unit: unitState,
fence: fence ? { set: true, org: fence.org ?? null, op: fence.op ?? null, time: fence.time ?? null, invalid: fence.invalid === true } : { set: false },
initLock: lock,
fetch: fetchJob,
restart: restarted ? { confirmed: true, observedAt: restarted.observedAt, pid: restarted.after.pid } : null,
busy,
receipts: recentReceipts(dataRoot),
organizations,
// Ruling 8: the system config holds one proxy per instance; an organization's own proxy
// needs a config format that does not exist yet.
ownProxy: { available: false, state: 'not built', reason: 'This instance has one shared proxy. A separate proxy per organization is not built yet.' },
next: nextSteps({ release, init, unitState, fence, lock }),
};
},
// Runs in the background; the page polls status for its step and receipt.
fetchStart() {
if (job.state === 'running' || busy) throw failure('proxy-admin-busy');
job = { state: 'running', step: 'start', receipt: null, error: null };
busy = 'fetch';
Promise.resolve()
.then(() => fetch({ dataRoot, pin, progress: step => { job.step = step; } }))
.then(() => {
job = { state: 'done', step: 'done', error: null, receipt: receipt(fetchRecord()) };
})
.catch(error => { job = { state: 'failed', step: job.step, receipt: null, error: proxyCode(error, 'proxy-fetch-failed') }; })
.finally(() => { busy = null; });
return { state: 'running' };
},
// The CLI's fetch: the same pinned fetch and receipt, in the foreground. Returns the fetch
// result (for the CLI's existing output) and the receipt.
async fetchRun({ progress = () => {} } = {}) {
if (job.state === 'running') throw failure('proxy-admin-busy');
return exclusive('fetch', async () => {
const result = await fetch({ dataRoot, pin, progress });
return { result, receipt: receipt(fetchRecord()) };
});
},
async init(call, args) {
return exclusive('init', async () => {
const result = await call('proxy.init', args);
const settings = { port: result.port, rotateGraceMinutes: result.rotateGraceMinutes ?? null, firstOutputSeconds: result.firstOutputSeconds ?? null };
return { settings, receipt: receipt({ op: 'init', generated: args.generate === true, ...settings }) };
});
},
async render() {
const text = await control.render();
const home = homedir();
return { unit: control.unit, text: home && home !== '/' ? text.split(home).join('~') : text };
},
async unitAction(op) {
return exclusive(`unit-${op}`, async () => {
if (op === 'restart') {
const { state, evidence } = await control.restart();
restarted = evidence.confirmed ? evidence : null;
return { state, evidence, receipt: receipt({ op: 'unit.restart', unit: control.unit, confirmed: evidence.confirmed, before: { pid: evidence.before.pid }, after: { pid: evidence.after.pid, listening: evidence.after.listening }, observedAt: evidence.observedAt }) };
}
const state = await control[op]();
// Any other unit action ends the restart this page confirmed.
restarted = null;
return { state, receipt: receipt({ op: `unit.${op}`, unit: control.unit, active: state.active, pid: state.pid ?? null, healthy: state.healthy === true }) };
});
},
// Only after this page restarted the proxy and saw the new process. The bus re-proves it.
async fenceClear(call) {
return exclusive('fence-clear', async () => {
if (!restarted) throw failure('proxy-restart-required');
const evidence = restarted;
// Kept until the bus answers: a bus that is down or busy leaves the confirmed restart for a retry.
const result = await call('proxy.fence.clear', { evidence });
if (restarted === evidence) restarted = null;
return { cleared: result.cleared === true, receipt: receipt({ op: 'fence.clear', cleared: result.cleared === true, evidence: result.evidence ?? null }) };
});
},
async initLockClear() {
return exclusive('init-lock-clear', async () => ({ receipt: clearInitLock({ configDir, dataRoot, buses: await buses(), now }) }));
},
};
}
+310
View File
@@ -0,0 +1,310 @@
// #1873 proxy setup state and host actions shared by `mosaic proxy` and the Console page.
// Never imports the management client and never reads the management key file: state is
// file presence, the pinned install hashes, the fence file and the proxy unit's systemd state.
// Results and receipts name files by label, never by absolute path, and carry no key.
import fs from 'node:fs';
import path from 'node:path';
import net from 'node:net';
import { execFile } from 'node:child_process';
import { fileURLToPath } from 'node:url';
import { PIN, refuse } from './common.mjs';
import { installPaths, loadProxy, verifyInstall } from './settings.mjs';
const REPO = fileURLToPath(new URL('../../../', import.meta.url)).replace(/\/$/, '');
export const INSTANCE_NAME = /^[a-z][a-z0-9]{1,15}$/;
export const FILE_LABELS = Object.freeze({ settings: 'proxy.json', key: 'the management key file', proxyDir: 'the proxy state directory', lock: 'the init lock' });
export function initFiles(configDir, dataRoot) {
return {
settings: path.join(configDir, 'proxy.json'),
key: path.join(configDir, 'secrets', 'proxy-management.key'),
proxyDir: path.join(dataRoot, 'proxy'),
lock: path.join(configDir, '.proxy-init.lock'),
};
}
// Presence only. A path that cannot be examined counts as present, so nothing clears on doubt.
function present(file) { try { fs.lstatSync(file); return true; } catch (error) { return error.code !== 'ENOENT'; } }
const verified = new Map();
// The pinned release: hashes are checked once per file identity (dev, inode, size, mtime, ctime, mode),
// in nanoseconds. mtime can be set back by the file's owner; ctime cannot, so an in-place rewrite re-hashes.
export function releaseState(dataRoot, pin = PIN) {
const paths = installPaths(dataRoot, pin);
const out = { version: pin.version, panelVersion: pin.panel.version, fetched: false, verified: false, problem: null };
let stats;
try { stats = [paths.binary, paths.panel].map(file => fs.lstatSync(file, { bigint: true })); }
catch (error) { out.problem = error.code === 'ENOENT' ? 'proxy-install-missing' : 'proxy-install-invalid'; return out; }
out.fetched = true;
const identity = stats.map(s => `${s.dev}:${s.ino}:${s.size}:${s.mtimeNs}:${s.ctimeNs}:${s.mode}`).join('|');
if (verified.get(paths.dir) === identity) { out.verified = true; return out; }
try { verifyInstall(dataRoot, pin); verified.set(paths.dir, identity); out.verified = true; }
catch (error) { out.problem = /^proxy-install-(missing|invalid)$/.test(error.code ?? '') ? error.code : 'proxy-install-invalid'; }
return out;
}
export function initState(configDir, dataRoot) {
const files = initFiles(configDir, dataRoot);
const found = Object.fromEntries(Object.entries(files).map(([name, file]) => [name, present(file)]));
let settings = null, problem = null;
if (found.settings) {
try {
const proxy = loadProxy(configDir, dataRoot);
settings = { port: proxy.port, rotateGraceMinutes: proxy.rotateGraceMinutes ?? null, firstOutputSeconds: proxy.firstOutputSeconds ?? null };
} catch (error) { problem = /^proxy-[a-z-]+$/.test(error.code ?? '') ? error.code : 'proxy-config-invalid'; }
}
const initialized = found.settings && found.key && found.proxyDir && !problem;
const partial = !initialized && (found.settings || found.key || found.proxyDir);
return { initialized, partial, problem, settings, files: found, lock: found.lock };
}
// Read-only view of the uncertain-write fence. Never constructs a proxy lock, which writes.
export function fenceState(dataRoot) {
const file = path.join(dataRoot, 'proxy', 'mosaic-keys.uncertain');
let stat;
try { stat = fs.lstatSync(file); } catch (error) { if (error.code === 'ENOENT') return null; return { invalid: true }; }
if (!stat.isFile() || stat.uid !== process.getuid() || (stat.mode & 0o077) || stat.size > 4096) return { invalid: true };
let entry;
try { entry = JSON.parse(fs.readFileSync(file, 'utf8')); } catch { return { invalid: true }; }
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) return { invalid: true };
return {
org: typeof entry.org === 'string' ? entry.org : null,
op: typeof entry.op === 'string' ? entry.op : null,
time: typeof entry.time === 'string' ? entry.time : null,
pid: Number.isSafeInteger(entry.pid) ? entry.pid : null,
};
}
// README conditions for a stale init lock: the bus is stopped (so no init is running), and
// none of proxy.json, the key file or the proxy dir exists. `buses` is the caller's proof:
// { stopped: boolean, running: [label] }.
export function initLockStatus({ configDir, dataRoot, buses }) {
const state = initState(configDir, dataRoot);
const blockers = [];
if (!buses || buses.stopped !== true) blockers.push(`bus running: ${(buses?.running ?? ['unknown']).join(', ')}`);
for (const name of ['settings', 'key', 'proxyDir']) if (state.files[name]) blockers.push(`${FILE_LABELS[name]} exists`);
return { present: state.lock, clearable: state.lock && blockers.length === 0, blockers: state.lock ? blockers : [] };
}
export function clearInitLock({ configDir, dataRoot, buses, now = () => new Date() }) {
const status = initLockStatus({ configDir, dataRoot, buses });
if (!status.present) refuse('proxy-init-lock-absent');
if (!status.clearable) refuse('proxy-init-lock-held');
const lock = initFiles(configDir, dataRoot).lock;
const stat = fs.lstatSync(lock);
if (!stat.isFile() || stat.uid !== process.getuid()) refuse('proxy-init-lock-held');
fs.unlinkSync(lock);
return appendReceipt(dataRoot, { op: 'init-lock.clear', time: now().toISOString(), conditions: ['bus stopped', 'no proxy.json', 'no management key file', 'no proxy state directory'] });
}
// Append-only admin receipts beside the other state logs. Labels and key ids only.
export function receiptFile(dataRoot) { return path.join(dataRoot, 'state', 'proxy-admin.jsonl'); }
export function appendReceipt(dataRoot, record) {
const dir = path.join(dataRoot, 'state');
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
const fd = fs.openSync(receiptFile(dataRoot), 'a', 0o600);
try { fs.writeFileSync(fd, JSON.stringify(record) + '\n'); fs.fsyncSync(fd); } finally { fs.closeSync(fd); }
return record;
}
// The last few receipts, oldest first, for the page after a reload. Reads only the file's tail and
// skips a line that does not parse (a torn last write). Receipts never hold a key or a body.
export function recentReceipts(dataRoot, limit = 10) {
let fd;
try { fd = fs.openSync(receiptFile(dataRoot), 'r'); } catch { return []; }
try {
const size = fs.fstatSync(fd).size, length = Math.min(size, 65536), buffer = Buffer.alloc(length);
fs.readSync(fd, buffer, 0, length, size - length);
const lines = buffer.toString('utf8').split('\n').slice(size > length ? 1 : 0).filter(Boolean);
const out = [];
for (const line of lines) { try { const value = JSON.parse(line); if (value && typeof value === 'object' && typeof value.op === 'string') out.push(value); } catch { /* torn line */ } }
return out.slice(-limit);
} finally { fs.closeSync(fd); }
}
export function unitName(instance) {
if (instance !== null && instance !== undefined && instance !== 'dev' && !INSTANCE_NAME.test(instance)) refuse('proxy-unit-usage');
return instance && instance !== 'dev' ? `mosaic-proxy-${instance}.service` : 'mosaic-proxy.service';
}
export function defaultRun(command, args, { env, timeout = 60000 } = {}) {
return new Promise(resolve => {
execFile(command, args, { env, timeout, maxBuffer: 1024 * 1024, killSignal: 'SIGKILL' }, (error, stdout, stderr) => {
resolve({ code: error ? (typeof error.code === 'number' ? error.code : 1) : 0, stdout: String(stdout ?? ''), stderr: String(stderr ?? '') });
});
});
}
export function probePort(port, { host = '127.0.0.1', timeout = 1000 } = {}) {
return new Promise(resolve => {
if (!Number.isInteger(port)) return resolve(false);
const socket = net.connect({ host, port });
const done = value => { socket.destroy(); resolve(value); };
socket.setTimeout(timeout, () => done(false));
socket.once('connect', () => done(true));
socket.once('error', () => done(false));
});
}
const UNIT_CODES = ['proxy-unit-usage', 'proxy-unit-exists', 'proxy-unit-dropin', 'proxy-unit-copy', 'proxy-unit-path', 'proxy-not-configured'];
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
// The proxy user unit for one instance. `port` is a function so a fresh init is seen.
// `unitDir` is passed to proxy-service.sh --dir; null keeps that script's default.
export function createUnitControl({ instance = null, port = () => null, unitDir = null, env = process.env, run = defaultRun, repo = REPO, probe = probePort, waitMs = 30000, pollMs = 250, now = () => new Date() } = {}) {
const unit = unitName(instance);
if (unitDir !== null && (typeof unitDir !== 'string' || !path.isAbsolute(unitDir))) refuse('proxy-unit-path');
const script = path.join(repo, 'scripts', 'proxy-service.sh');
const instanceArgs = instance && instance !== 'dev' ? ['--instance', instance] : [];
async function service(op) {
const result = await run('bash', [script, ...instanceArgs, op, ...(unitDir && op === 'install' ? ['--dir', unitDir] : [])], { env });
if (result.code === 0) return result;
const code = /^proxy-service: (proxy-[a-z-]+)$/m.exec(result.stderr)?.[1];
refuse(UNIT_CODES.includes(code) ? code : 'proxy-unit-failed');
}
async function status() {
const result = await run('systemctl', ['--user', 'show', unit, '--property=LoadState,ActiveState,SubState,MainPID,ExecMainStartTimestampMonotonic,NRestarts'], { env, timeout: 15000 });
if (result.code !== 0) refuse('proxy-unit-failed');
const fields = Object.fromEntries(result.stdout.split('\n').filter(Boolean).map(line => { const i = line.indexOf('='); return [line.slice(0, i), line.slice(i + 1)]; }));
const pid = /^[1-9][0-9]*$/.test(fields.MainPID ?? '') ? Number(fields.MainPID) : null;
const since = /^[1-9][0-9]*$/.test(fields.ExecMainStartTimestampMonotonic ?? '') ? Number(fields.ExecMainStartTimestampMonotonic) : null;
const p = port();
const listening = fields.ActiveState === 'active' && Number.isInteger(p) ? await probe(p) : false;
return {
unit,
installed: fields.LoadState === 'loaded',
load: fields.LoadState ?? 'unknown', active: fields.ActiveState ?? 'unknown', sub: fields.SubState ?? 'unknown',
pid, since, restarts: /^[0-9]+$/.test(fields.NRestarts ?? '') ? Number(fields.NRestarts) : null,
port: Number.isInteger(p) ? p : null, listening,
healthy: fields.ActiveState === 'active' && pid !== null && listening,
};
}
async function waitFor(test) {
const deadline = Date.now() + waitMs;
for (;;) {
const state = await status();
if (test(state)) return state;
if (Date.now() >= deadline) return state;
await sleep(pollMs);
}
}
async function systemctl(verb) {
const result = await run('systemctl', ['--user', verb, unit], { env, timeout: 90000 });
if (result.code !== 0) refuse('proxy-unit-failed');
}
return {
unit,
status,
async render() { return (await service('render')).stdout; },
async install() { await service('install'); return status(); },
async start() {
const before = await status();
if (!before.installed) refuse('proxy-unit-missing');
await systemctl('start');
const after = await waitFor(s => s.healthy);
if (!after.healthy) refuse('proxy-unit-unhealthy');
return after;
},
async stop() {
const before = await status();
if (!before.installed) refuse('proxy-unit-missing');
await systemctl('stop');
return waitFor(s => s.active === 'inactive' || s.active === 'failed');
},
// Restart evidence is what a fence clear needs: a new main process, started after the old
// one, active and answering on the proxy port. Anything less is not a confirmed restart.
async restart() {
const before = await status();
if (!before.installed) refuse('proxy-unit-missing');
await systemctl('restart');
const changed = s => s.healthy && s.pid !== null && s.pid !== before.pid && s.since !== null && (before.since === null || s.since > before.since);
const after = await waitFor(changed);
const confirmed = changed(after);
return {
state: after,
evidence: {
unit, confirmed,
before: { pid: before.pid, since: before.since, active: before.active },
after: { pid: after.pid, since: after.since, active: after.active, listening: after.listening },
observedAt: now().toISOString(),
},
};
},
};
}
// Process start time as wall-clock milliseconds, from /proc (start ticks since boot + btime).
export function processStartMs(pid, { proc = '/proc' } = {}) {
try {
const stat = fs.readFileSync(path.join(proc, String(pid), 'stat'), 'utf8');
const fields = stat.slice(stat.lastIndexOf(')') + 2).split(' ');
const ticks = Number(fields[19]);
const btime = Number(/^btime ([0-9]+)$/m.exec(fs.readFileSync(path.join(proc, 'stat'), 'utf8'))?.[1]);
if (!Number.isFinite(ticks) || !Number.isFinite(btime)) return null;
return btime * 1000 + Math.floor(ticks * 10);
} catch { return null; }
}
// `pid` and every process descended from it, from each process's parent field in /proc.
export function processTree(pid, { proc = '/proc' } = {}) {
const parents = new Map();
let names; try { names = fs.readdirSync(proc); } catch { return [pid]; }
for (const name of names) {
if (!/^[1-9][0-9]*$/.test(name)) continue;
try {
const stat = fs.readFileSync(path.join(proc, name, 'stat'), 'utf8');
const ppid = Number(stat.slice(stat.lastIndexOf(')') + 2).split(' ')[1]);
if (!parents.has(ppid)) parents.set(ppid, []);
parents.get(ppid).push(Number(name));
} catch { /* gone */ }
}
const out = [pid];
for (let i = 0; i < out.length && out.length < 256; i++) out.push(...(parents.get(out[i]) ?? []));
return out;
}
// True when `pid` or one of its descendants holds a listening TCP socket on `port` (IPv4 or
// IPv6), read from /proc. The unit's main process is the node runner; the pinned binary it
// spawns is the listener.
export function pidListensOn(pid, port, { proc = '/proc' } = {}) {
const hex = port.toString(16).toUpperCase().padStart(4, '0');
const inodes = new Set();
for (const table of ['tcp', 'tcp6']) {
let text; try { text = fs.readFileSync(path.join(proc, 'net', table), 'utf8'); } catch { continue; }
for (const line of text.split('\n').slice(1)) {
const cols = line.trim().split(/\s+/);
if (cols.length > 9 && cols[1].endsWith(`:${hex}`) && cols[3] === '0A') inodes.add(cols[9]);
}
}
if (inodes.size === 0) return false;
for (const member of processTree(pid, { proc })) {
let fds; try { fds = fs.readdirSync(path.join(proc, String(member), 'fd')); } catch { continue; }
for (const fd of fds) {
let link; try { link = fs.readlinkSync(path.join(proc, String(member), 'fd', fd)); } catch { continue; }
const m = /^socket:\[([0-9]+)\]$/.exec(link);
if (m && inodes.has(m[1])) return true;
}
}
return false;
}
// A fence clear accepts restart evidence only after re-proving it here, on the host: the
// evidence names a confirmed restart to a new process, and that process is alive now, started
// after the fence was set, and is the one listening on the proxy port. Caller-supplied fields
// are never trusted on their own.
export function verifyRestart(evidence, { fence, port, startMs = processStartMs, listens = pidListensOn, now = () => Date.now() } = {}) {
const int = v => Number.isSafeInteger(v) && v > 0;
const plain = v => v !== null && typeof v === 'object' && !Array.isArray(v);
if (!plain(evidence) || Object.keys(evidence).sort().join(',') !== 'after,before,confirmed,observedAt,unit'
|| typeof evidence.unit !== 'string' || !/^mosaic-proxy(-[a-z][a-z0-9]{1,15})?\.service$/.test(evidence.unit)
|| typeof evidence.observedAt !== 'string' || Number.isNaN(Date.parse(evidence.observedAt))
|| !plain(evidence.before) || !plain(evidence.after)) refuse('proxy-restart-unconfirmed');
const { before, after } = evidence;
if (evidence.confirmed !== true || !int(after.pid) || !int(after.since) || after.pid === before.pid) refuse('proxy-restart-unconfirmed');
const observed = Date.parse(evidence.observedAt);
if (observed > now() + 60000 || now() - observed > 10 * 60 * 1000) refuse('proxy-restart-unconfirmed');
const started = startMs(after.pid);
if (started === null) refuse('proxy-restart-unconfirmed');
const fenceTime = fence && typeof fence.time === 'string' ? Date.parse(fence.time) : NaN;
if (fence && !(Number.isFinite(fenceTime) && started > fenceTime)) refuse('proxy-restart-unconfirmed');
if (!Number.isInteger(port) || !listens(after.pid, port)) refuse('proxy-restart-unconfirmed');
return { unit: evidence.unit, before: { pid: int(before.pid) ? before.pid : null }, after: { pid: after.pid, startedAt: new Date(started).toISOString() }, observedAt: evidence.observedAt, listeningPort: port };
}
+9 -1
View File
@@ -29,15 +29,21 @@ export async function downloadRelease(url, { testOrigin } = {}) {
}
refuse('proxy-fetch-redirect');
}
export async function fetchPinned({ dataRoot, pin = PIN, download = downloadRelease }) {
// `progress(step)` names each stage for a caller that shows it (#1873 Console page); never a path.
export async function fetchPinned({ dataRoot, pin = PIN, download = downloadRelease, progress = () => {} }) {
safePath(dataRoot, 'proxy-fetch-existing');
const paths = installPaths(dataRoot, pin);
safePath(paths.dir, 'proxy-fetch-existing');
// Fetch and verify both assets before any install filesystem mutation.
progress('download-archive');
const archive = await download(pin.archive.url);
progress('verify-archive');
if (sha256(archive) !== pin.archive.sha256) refuse('proxy-fetch-hash');
progress('download-panel');
const panel = await download(pin.panel.url);
progress('verify-panel');
if (sha256(panel) !== pin.panel.sha256) refuse('proxy-fetch-hash');
progress('extract-binary');
let binary;
try {
const listing = execFileSync('tar', ['-tzf', '-'], { input: archive, maxBuffer: 1024 * 1024, stdio: ['pipe', 'pipe', 'pipe'] }).toString().split('\n').filter(Boolean);
@@ -45,7 +51,9 @@ export async function fetchPinned({ dataRoot, pin = PIN, download = downloadRele
// Only the root executable is read to stdout. No archive path is extracted to disk.
binary = execFileSync('tar', ['-xOzf', '-', 'cli-proxy-api'], { input: archive, maxBuffer: 256 * 1024 * 1024, stdio: ['pipe', 'pipe', 'pipe'] });
} catch { refuse('proxy-fetch-archive'); }
progress('verify-binary');
if (sha256(binary) !== pin.binary.sha256) refuse('proxy-fetch-hash');
progress('install');
try {
fs.lstatSync(paths.dir);
if (fs.readdirSync(paths.dir).sort().join(',') !== 'cli-proxy-api,static'
@@ -0,0 +1,229 @@
// #1873 the setup controller the Console page and `mosaic proxy` share: next steps from state,
// a fetch receipt with every pinned hash, init through the bus call, and the fence clear that
// needs a restart this controller confirmed.
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { createHash } from 'node:crypto';
import { createProxyAdmin } from '../src/admin-control.mjs';
import { initFiles, receiptFile } from '../src/admin.mjs';
import { PIN } from '../src/common.mjs';
const code = name => error => error.code === name;
const SENTINEL = 'pa-sentinel-management-key-0123456789';
const stopped = async () => ({ stopped: true, running: [] });
function fixture(t, { unitState = { installed: false, active: 'inactive', healthy: false }, restart = null, fetch = null, pin = PIN, statusHook = null } = {}) {
const root = fs.mkdtempSync(join(tmpdir(), 'pc-'));
t.after(() => fs.rmSync(root, { recursive: true, force: true }));
const configDir = join(root, 'config'), dataRoot = join(root, 'data');
fs.mkdirSync(configDir, { mode: 0o700 }); fs.mkdirSync(dataRoot, { mode: 0o700 });
const ops = [];
const unit = {
unit: 'mosaic-proxy-zz.service',
status: async () => { await statusHook?.(); return { unit: 'mosaic-proxy-zz.service', ...unitState }; },
render: async () => `ExecStart=/usr/bin/node ${process.env.HOME ?? '/nohome'}/repo/run.mjs\n`,
install: async () => { ops.push('install'); return { ...unitState, installed: true }; },
start: async () => { ops.push('start'); return { ...unitState, active: 'active', pid: 11, healthy: true }; },
stop: async () => { ops.push('stop'); return { ...unitState, active: 'inactive', pid: null }; },
restart: async () => { ops.push('restart'); return restart ?? { state: unitState, evidence: { unit: 'mosaic-proxy-zz.service', confirmed: true, before: { pid: 10, since: 1, active: 'active' }, after: { pid: 11, since: 2, active: 'active', listening: true }, observedAt: new Date(0).toISOString() } }; },
};
const admin = createProxyAdmin({ dataRoot, configDir, unit, buses: stopped, fetch: fetch ?? (async () => ({})), now: () => new Date(0), orgs: async () => [{ id: 'acme', catalogue: 'ready' }], pin });
return { root, configDir, dataRoot, admin, ops, files: initFiles(configDir, dataRoot) };
}
const receipts = dataRoot => { try { return fs.readFileSync(receiptFile(dataRoot), 'utf8').trim().split('\n').filter(Boolean).map(JSON.parse); } catch { return []; } };
test('a fresh instance says fetch first; status names files by label and offers no own proxy', async t => {
const { root, admin } = fixture(t);
const status = await admin.status();
assert.deepEqual(status.next, ['fetch']);
assert.equal(status.release.version, PIN.version);
assert.equal(status.release.fetched, false);
assert.deepEqual(status.init.files.map(f => f.label), ['proxy.json', 'the management key file', 'the proxy state directory', 'the init lock']);
assert.deepEqual(status.ownProxy, { available: false, state: 'not built', reason: 'This instance has one shared proxy. A separate proxy per organization is not built yet.' });
assert.deepEqual(status.organizations, [{ id: 'acme', catalogue: 'ready' }]);
assert.equal(JSON.stringify(status).includes(root), false);
});
test('fetch runs in the background, reports each step and writes a receipt with every pinned hash', async t => {
const steps = [];
let release;
const gate = new Promise(r => { release = r; });
const { dataRoot, admin } = fixture(t, { fetch: async ({ pin, progress }) => { assert.equal(pin, PIN); progress('verify-archive'); steps.push('called'); await gate; } });
assert.deepEqual(admin.fetchStart(), { state: 'running' });
await new Promise(r => setImmediate(r));
assert.throws(() => admin.fetchStart(), code('proxy-admin-busy'));
await assert.rejects(admin.unitAction('start'), code('proxy-admin-busy'));
const running = await admin.status();
assert.equal(running.fetch.state, 'running'); assert.equal(running.fetch.step, 'verify-archive'); assert.equal(running.busy, 'fetch');
release();
await new Promise(r => setTimeout(r, 10));
const done = await admin.status();
assert.equal(done.fetch.state, 'done');
assert.deepEqual(done.fetch.receipt, { time: new Date(0).toISOString(), op: 'fetch', version: PIN.version, panelVersion: PIN.panel.version, archiveSha256: PIN.archive.sha256, panelSha256: PIN.panel.sha256, binarySha256: PIN.binary.sha256, verified: true });
assert.deepEqual(receipts(dataRoot), [done.fetch.receipt]);
});
test('a fetch that ends while status is being read is never reported done with the release still not fetched', async t => {
// E2E finding: status read the release before its awaits and the job after them, so the page
// saw "done" next to "Not fetched", stopped polling and kept offering Fetch.
const sha = text => createHash('sha256').update(text).digest('hex');
const pin = { ...PIN, archive: { ...PIN.archive, sha256: sha('a') }, binary: { sha256: sha('b') }, panel: { ...PIN.panel, sha256: sha('p') } };
let release, during = null;
const gate = new Promise(r => { release = r; });
const f = fixture(t, { pin, statusHook: async () => { const d = during; during = null; await d?.(); }, fetch: async ({ dataRoot }) => {
await gate;
const dir = join(dataRoot, 'opt', 'cliproxyapi', PIN.version);
fs.mkdirSync(join(dir, 'static'), { recursive: true });
fs.writeFileSync(join(dir, 'cli-proxy-api'), 'b'); fs.writeFileSync(join(dir, 'static', 'management.html'), 'p');
} });
f.admin.fetchStart();
await new Promise(r => setImmediate(r));
during = async () => { release(); await new Promise(r => setTimeout(r, 10)); };
const racing = await f.admin.status();
assert.equal(during, null, 'the fetch ended inside this status read');
assert.equal(racing.fetch.state === 'done' && !racing.release.fetched, false, `fetch ${racing.fetch.state} with release fetched=${racing.release.fetched}`);
const after = await f.admin.status();
assert.equal(after.fetch.state, 'done'); assert.equal(after.release.verified, true); assert.equal(after.next[0], 'init');
});
test('a refused fetch keeps its code and writes no receipt', async t => {
const { dataRoot, admin } = fixture(t, { fetch: async () => { throw Object.assign(Error('/home/x upstream body'), { code: 'proxy-fetch-hash' }); } });
admin.fetchStart();
await new Promise(r => setTimeout(r, 10));
const status = await admin.status();
assert.equal(status.fetch.state, 'failed'); assert.equal(status.fetch.error, 'proxy-fetch-hash');
assert.equal(JSON.stringify(status).includes('upstream body'), false);
assert.deepEqual(receipts(dataRoot), []);
});
test('init sends the arguments to proxy.init and its receipt carries settings only', async t => {
const { dataRoot, admin } = fixture(t);
const calls = [];
const result = await admin.init(async (verb, args) => { calls.push([verb, args]); return { port: 18432, binary: '/fixture', rotateGraceMinutes: 5, firstOutputSeconds: 90 }; }, { generate: true, port: 18432 });
assert.deepEqual(calls, [['proxy.init', { generate: true, port: 18432 }]]);
assert.deepEqual(result.settings, { port: 18432, rotateGraceMinutes: 5, firstOutputSeconds: 90 });
assert.deepEqual(receipts(dataRoot), [{ time: new Date(0).toISOString(), op: 'init', generated: true, port: 18432, rotateGraceMinutes: 5, firstOutputSeconds: 90 }]);
await admin.init(async () => ({ port: 18433, binary: '/fixture' }), { key: SENTINEL });
assert.equal(fs.readFileSync(receiptFile(dataRoot), 'utf8').includes(SENTINEL), false);
assert.equal(JSON.stringify(receipts(dataRoot)).includes('/fixture'), false);
});
test('fence clear refuses without a restart, and after an unconfirmed one, before calling the bus', async t => {
const { admin, ops } = fixture(t, { restart: { state: {}, evidence: { unit: 'mosaic-proxy-zz.service', confirmed: false, before: { pid: 10 }, after: { pid: 10, listening: true }, observedAt: new Date(0).toISOString() } } });
const calls = [];
const call = async (verb, args) => { calls.push([verb, args]); return { cleared: true }; };
await assert.rejects(admin.fenceClear(call), code('proxy-restart-required'));
const restart = await admin.unitAction('restart');
assert.equal(restart.evidence.confirmed, false);
assert.equal(restart.receipt.confirmed, false);
await assert.rejects(admin.fenceClear(call), code('proxy-restart-required'));
assert.deepEqual(calls, []);
assert.deepEqual(ops, ['restart']);
});
test('fence clear after a confirmed restart sends that evidence once; another unit action ends it', async t => {
const { dataRoot, admin } = fixture(t, { unitState: { installed: true, active: 'active', healthy: true, pid: 11 } });
const calls = [];
const call = async (verb, args) => { calls.push([verb, args]); return { cleared: true, evidence: { after: { pid: 11 } } }; };
const restart = await admin.unitAction('restart');
assert.equal(restart.evidence.confirmed, true);
assert.deepEqual((await admin.status()).restart, { confirmed: true, observedAt: new Date(0).toISOString(), pid: 11 });
const cleared = await admin.fenceClear(call);
assert.deepEqual(calls, [['proxy.fence.clear', { evidence: restart.evidence }]]);
assert.equal(cleared.cleared, true);
assert.deepEqual(cleared.receipt.evidence, { after: { pid: 11 } });
await assert.rejects(admin.fenceClear(call), code('proxy-restart-required'), 'one restart, one clear');
await admin.unitAction('restart');
await admin.unitAction('stop');
await assert.rejects(admin.fenceClear(call), code('proxy-restart-required'), 'a stop ends the confirmed restart');
assert.deepEqual(receipts(dataRoot).map(r => r.op), ['unit.restart', 'fence.clear', 'unit.restart', 'unit.stop']);
});
test('a fence clear the bus did not answer keeps the confirmed restart for a retry', async t => {
const { admin } = fixture(t, { unitState: { installed: true, active: 'active', healthy: true, pid: 11 } });
let fail = true;
const call = async () => { if (fail) { fail = false; throw Object.assign(new Error('bus down'), { code: 'bus-unavailable' }); } return { cleared: true }; };
await admin.unitAction('restart');
await assert.rejects(admin.fenceClear(call), /bus down/);
assert.equal((await admin.status()).restart?.confirmed, true, 'the restart is still confirmed');
assert.equal((await admin.fenceClear(call)).cleared, true);
assert.equal((await admin.status()).restart, null);
});
test('next step walks fetch, init lock, repair, init, unit install, start, restart, fence and accounts', async t => {
const sha = text => createHash('sha256').update(text).digest('hex');
const pin = { ...PIN, archive: { ...PIN.archive, sha256: sha('a') }, binary: { sha256: sha('b') }, panel: { ...PIN.panel, sha256: sha('p') } };
const state = { installed: false, active: 'inactive', healthy: false, pid: null };
const f = fixture(t, { unitState: state, pin });
const next = async () => (await f.admin.status()).next[0];
assert.equal(await next(), 'fetch');
const binDir = join(f.dataRoot, 'opt', 'cliproxyapi', PIN.version);
fs.mkdirSync(join(binDir, 'static'), { recursive: true });
fs.writeFileSync(join(binDir, 'cli-proxy-api'), 'b'); fs.writeFileSync(join(binDir, 'static', 'management.html'), 'wrong');
assert.equal(await next(), 'fetch', 'a hash mismatch is not a fetched release');
fs.rmSync(join(binDir, 'static', 'management.html')); fs.writeFileSync(join(binDir, 'static', 'management.html'), 'p');
fs.mkdirSync(join(f.configDir, 'secrets'), { mode: 0o700 });
fs.writeFileSync(f.files.key, 'fixture', { mode: 0o600 });
fs.writeFileSync(f.files.lock, '', { mode: 0o600 });
assert.equal(await next(), 'init-lock-wait', 'a key file blocks the lock clear');
fs.rmSync(f.files.key);
assert.equal(await next(), 'init-lock-clear');
fs.rmSync(f.files.lock);
fs.writeFileSync(f.files.key, 'fixture', { mode: 0o600 });
assert.equal(await next(), 'init-repair');
fs.rmSync(f.files.key);
assert.equal(await next(), 'init');
fs.writeFileSync(f.files.key, 'fixture', { mode: 0o600 });
fs.mkdirSync(f.files.proxyDir, { mode: 0o700 });
fs.writeFileSync(f.files.settings, JSON.stringify({ binary: join(binDir, 'cli-proxy-api'), port: 18432 }), { mode: 0o600 });
const initialized = await f.admin.status();
assert.equal(initialized.init.initialized, true);
assert.deepEqual(initialized.init.settings, { port: 18432, rotateGraceMinutes: null, firstOutputSeconds: null });
assert.equal(initialized.next[0], 'unit-install');
state.installed = true;
assert.equal(await next(), 'unit-start');
state.active = 'active';
assert.equal(await next(), 'unit-restart', 'active but not answering');
state.healthy = true;
assert.equal(await next(), 'accounts');
fs.writeFileSync(join(f.files.proxyDir, 'mosaic-keys.uncertain'), JSON.stringify({ org: 'acme', op: 'write', time: new Date(0).toISOString(), pid: 1 }), { mode: 0o600 });
const fenced = await f.admin.status();
assert.deepEqual(fenced.fence, { set: true, org: 'acme', op: 'write', time: new Date(0).toISOString(), invalid: false });
assert.equal(fenced.next[0], 'unit-restart', 'a fence needs a restart first');
await f.admin.unitAction('restart');
assert.equal(await next(), 'fence-clear');
});
test('the init lock clears through the controller only on the README conditions', async t => {
const { dataRoot, admin, files } = fixture(t);
await assert.rejects(admin.initLockClear(), code('proxy-init-lock-absent'));
fs.writeFileSync(files.lock, '', { mode: 0o600 });
fs.mkdirSync(files.proxyDir, { mode: 0o700 });
await assert.rejects(admin.initLockClear(), code('proxy-init-lock-held'));
assert.deepEqual((await admin.status()).initLock, { present: true, clearable: false, blockers: ['the proxy state directory exists'] });
fs.rmdirSync(files.proxyDir);
const result = await admin.initLockClear();
assert.equal(result.receipt.op, 'init-lock.clear');
assert.equal(fs.existsSync(files.lock), false);
assert.deepEqual(receipts(dataRoot).map(r => r.op), ['init-lock.clear']);
});
test('render replaces the home directory with ~', async t => {
const { admin } = fixture(t);
const { unit, text } = await admin.render();
assert.equal(unit, 'mosaic-proxy-zz.service');
if (process.env.HOME && process.env.HOME !== '/') assert.equal(text.includes(process.env.HOME), false);
});
test('status carries the last receipts from proxy-admin.jsonl, so the page shows them after a reload', async t => {
const { dataRoot, admin } = fixture(t, { unitState: { installed: true, active: 'active', healthy: true, pid: 11 } });
assert.deepEqual((await admin.status()).receipts, []);
for (let i = 0; i < 12; i++) await admin.unitAction('stop');
fs.appendFileSync(receiptFile(dataRoot), '{"op":"torn');
const { receipts } = await admin.status();
assert.equal(receipts.length, 10);
assert.ok(receipts.every(r => r.op === 'unit.stop'));
});
+195
View File
@@ -0,0 +1,195 @@
// #1873 proxy setup host helpers: restart proof from a fake /proc, the stale init-lock rules,
// unit control over a fake systemctl, and receipts that carry no path or key.
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { createHash } from 'node:crypto';
import { PIN } from '../src/common.mjs';
import { installPaths } from '../src/settings.mjs';
import {
processTree, pidListensOn, processStartMs, verifyRestart, initLockStatus, clearInitLock, initFiles, initState,
fenceState, createUnitControl, releaseState, receiptFile, unitName,
} from '../src/admin.mjs';
const code = name => error => error.code === name;
const scratch = t => { const dir = fs.mkdtempSync(join(tmpdir(), 'pa-')); t.after(() => fs.rmSync(dir, { recursive: true, force: true })); return dir; };
// A fake /proc: runner pid 100 (start tick 500), its child 101 listens on `port` via socket inode 5555.
function fakeProc(t, { port = 8080, listener = 101, start = 500 } = {}) {
const proc = join(scratch(t), 'proc');
const stat = (pid, ppid, ticks) => `${pid} (node) S ${ppid} 1 1 0 -1 0 0 0 0 0 0 0 0 0 20 0 1 0 ${ticks} 0 0\n`;
fs.mkdirSync(join(proc, 'net'), { recursive: true });
fs.writeFileSync(join(proc, 'stat'), 'cpu 0\nbtime 1000\n');
for (const [pid, ppid, ticks] of [[1, 0, 1], [100, 1, start], [101, 100, start + 1], [200, 1, 1]]) {
fs.mkdirSync(join(proc, String(pid), 'fd'), { recursive: true });
fs.writeFileSync(join(proc, String(pid), 'stat'), stat(pid, ppid, ticks));
}
fs.symlinkSync('socket:[5555]', join(proc, String(listener), 'fd', '3'));
fs.symlinkSync('/dev/null', join(proc, '100', 'fd', '0'));
const hex = port.toString(16).toUpperCase().padStart(4, '0');
fs.writeFileSync(join(proc, 'net', 'tcp'), ' sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode\n'
+ ` 0: 0100007F:${hex} 00000000:0000 0A 00000000:00000000 00:00000000 00000000 1000 0 5555 1 0 100 0 0 10 0\n`
+ ` 1: 0100007F:${hex} 0100007F:9999 01 00000000:00000000 00:00000000 00000000 1000 0 7777 1 0 100 0 0 10 0\n`);
return proc;
}
test('processTree follows parent links; pidListensOn finds a listener in a child of the unit main pid', t => {
const proc = fakeProc(t);
assert.deepEqual(processTree(100, { proc }).sort(), [100, 101]);
assert.equal(pidListensOn(100, 8080, { proc }), true);
assert.equal(pidListensOn(200, 8080, { proc }), false, 'an unrelated process does not count');
assert.equal(pidListensOn(100, 8081, { proc }), false, 'another port does not count');
assert.equal(processStartMs(100, { proc }), 1000 * 1000 + 5000);
assert.equal(processStartMs(999, { proc }), null);
});
const evidence = (over = {}) => ({
unit: 'mosaic-proxy.service', confirmed: true,
before: { pid: 50, since: 10, active: 'active' }, after: { pid: 100, since: 20, active: 'active', listening: true },
observedAt: new Date(2000).toISOString(), ...over,
});
const proof = { fence: { time: new Date(1000).toISOString() }, port: 8080, startMs: () => 1500, listens: (pid, port) => pid === 100 && port === 8080, now: () => 2000 };
test('verifyRestart accepts a confirmed restart to a new live listener started after the fence', () => {
const result = verifyRestart(evidence(), proof);
assert.deepEqual(result, { unit: 'mosaic-proxy.service', before: { pid: 50 }, after: { pid: 100, startedAt: new Date(1500).toISOString() }, observedAt: new Date(2000).toISOString(), listeningPort: 8080 });
});
test('verifyRestart refuses every unproved restart with proxy-restart-unconfirmed', () => {
const cases = [
['not confirmed', evidence({ confirmed: false }), proof],
['same pid', evidence({ before: { pid: 100, since: 10, active: 'active' } }), proof],
['extra field', { ...evidence(), forged: true }, proof],
['bad unit name', evidence({ unit: '../x.service' }), proof],
['stale observation', evidence({ observedAt: new Date(2000).toISOString() }), { ...proof, now: () => 2000 + 11 * 60 * 1000 }],
['process gone', evidence(), { ...proof, startMs: () => null }],
['started before the fence', evidence(), { ...proof, startMs: () => 900 }],
['fence without time', evidence(), { ...proof, fence: { invalid: true } }],
['not listening', evidence(), { ...proof, listens: () => false }],
['no port', evidence(), { ...proof, port: null }],
['not an object', 'restarted', proof],
];
for (const [name, value, options] of cases) assert.throws(() => verifyRestart(value, options), code('proxy-restart-unconfirmed'), name);
});
function dirs(t) {
const root = scratch(t);
const configDir = join(root, 'config'), dataRoot = join(root, 'data');
fs.mkdirSync(configDir, { recursive: true, mode: 0o700 }); fs.mkdirSync(dataRoot, { recursive: true, mode: 0o700 });
return { root, configDir, dataRoot, files: initFiles(configDir, dataRoot) };
}
test('init lock: absent refuses, held names each README blocker, clear needs every condition and writes a receipt', t => {
const { root, configDir, dataRoot, files } = dirs(t);
const stopped = { stopped: true, running: [] };
assert.deepEqual(initLockStatus({ configDir, dataRoot, buses: stopped }), { present: false, clearable: false, blockers: [] });
assert.throws(() => clearInitLock({ configDir, dataRoot, buses: stopped }), code('proxy-init-lock-absent'));
fs.writeFileSync(files.lock, '', { mode: 0o600 });
fs.mkdirSync(files.proxyDir, { mode: 0o700 });
const held = initLockStatus({ configDir, dataRoot, buses: { stopped: false, running: ['organization acme'] } });
assert.equal(held.clearable, false);
assert.deepEqual(held.blockers, ['bus running: organization acme', 'the proxy state directory exists']);
assert.equal(JSON.stringify(held).includes(root), false, 'blockers name labels, never paths');
assert.throws(() => clearInitLock({ configDir, dataRoot, buses: { stopped: false, running: ['the instance bus'] } }), code('proxy-init-lock-held'));
assert.throws(() => clearInitLock({ configDir, dataRoot, buses: stopped }), code('proxy-init-lock-held'));
assert.equal(fs.existsSync(files.lock), true);
fs.rmdirSync(files.proxyDir);
assert.throws(() => clearInitLock({ configDir, dataRoot, buses: undefined }), code('proxy-init-lock-held'), 'no bus proof counts as running');
const receipt = clearInitLock({ configDir, dataRoot, buses: stopped, now: () => new Date(0) });
assert.equal(fs.existsSync(files.lock), false);
assert.deepEqual(receipt, { op: 'init-lock.clear', time: new Date(0).toISOString(), conditions: ['bus stopped', 'no proxy.json', 'no management key file', 'no proxy state directory'] });
assert.deepEqual(fs.readFileSync(receiptFile(dataRoot), 'utf8').trim().split('\n').map(JSON.parse), [receipt]);
assert.equal((fs.statSync(receiptFile(dataRoot)).mode & 0o777), 0o600);
});
test('initState reports partial state from file presence and fenceState reads the fence without writing', t => {
const { configDir, dataRoot, files } = dirs(t);
assert.deepEqual(initState(configDir, dataRoot), { initialized: false, partial: false, problem: null, settings: null, files: { settings: false, key: false, proxyDir: false, lock: false }, lock: false });
fs.mkdirSync(files.proxyDir, { mode: 0o700 });
assert.equal(initState(configDir, dataRoot).partial, true);
assert.equal(fenceState(dataRoot), null);
fs.writeFileSync(join(files.proxyDir, 'mosaic-keys.uncertain'), JSON.stringify({ org: 'acme', op: 'apikey.add', time: '2026-11-11T00:00:00.000Z', pid: 7 }), { mode: 0o600 });
assert.deepEqual(fenceState(dataRoot), { org: 'acme', op: 'apikey.add', time: '2026-11-11T00:00:00.000Z', pid: 7 });
fs.chmodSync(join(files.proxyDir, 'mosaic-keys.uncertain'), 0o644);
assert.deepEqual(fenceState(dataRoot), { invalid: true });
assert.deepEqual(fs.readdirSync(files.proxyDir), ['mosaic-keys.uncertain']);
});
test('releaseState: nothing fetched names the next step without a path', t => {
const { root, dataRoot } = dirs(t);
const state = releaseState(dataRoot);
assert.equal(state.fetched, false); assert.equal(state.verified, false); assert.equal(state.problem, 'proxy-install-missing');
assert.equal(JSON.stringify(state).includes(root), false);
});
test('releaseState re-hashes a release file rewritten in place with its mtime set back', async t => {
const { dataRoot } = dirs(t);
const sha = bytes => createHash('sha256').update(bytes).digest('hex');
const binary = Buffer.from('made-up proxy binary 0001'), panel = Buffer.from('<html>made-up panel</html>');
const pin = { ...PIN, version: '0.0.0-test', binary: { ...PIN.binary, sha256: sha(binary) }, panel: { ...PIN.panel, sha256: sha(panel) } };
const paths = installPaths(dataRoot, pin);
fs.mkdirSync(join(paths.dir, 'static'), { recursive: true });
fs.writeFileSync(paths.binary, binary, { mode: 0o755 }); fs.writeFileSync(paths.panel, panel);
const when = 1700000000;
for (const file of [paths.binary, paths.panel]) fs.utimesSync(file, when, when);
assert.equal(releaseState(dataRoot, pin).verified, true);
await new Promise(resolve => setTimeout(resolve, 50));
const before = fs.statSync(paths.binary, { bigint: true });
fs.writeFileSync(paths.binary, Buffer.from('made-up proxy binary 0002'), { flag: 'r+' });
fs.utimesSync(paths.binary, when, when);
const after = fs.statSync(paths.binary, { bigint: true });
assert.deepEqual([after.ino, after.size, after.mtimeNs, after.mode], [before.ino, before.size, before.mtimeNs, before.mode], 'only ctime tells the files apart');
const state = releaseState(dataRoot, pin);
assert.equal(state.verified, false); assert.equal(state.problem, 'proxy-install-invalid');
});
test('unit names follow the instance and refuse a bad instance', () => {
assert.equal(unitName(null), 'mosaic-proxy.service');
assert.equal(unitName('dev'), 'mosaic-proxy.service');
assert.equal(unitName('uiorg'), 'mosaic-proxy-uiorg.service');
assert.throws(() => unitName('../x'), code('proxy-unit-usage'));
});
// A fake systemctl / proxy-service.sh. `states` is the sequence `systemctl show` reports.
function fakeUnit(states, { install = { code: 0, stdout: '', stderr: '' }, verbs = [] } = {}) {
let i = 0;
const run = async (command, args) => {
if (command === 'systemctl' && args[1] === 'show') {
const s = states[Math.min(i++, states.length - 1)];
return { code: 0, stdout: `LoadState=${s.load ?? 'loaded'}\nActiveState=${s.active}\nSubState=running\nMainPID=${s.pid ?? 0}\nExecMainStartTimestampMonotonic=${s.since ?? 0}\nNRestarts=0\n`, stderr: '' };
}
if (command === 'systemctl') { verbs.push(args[1]); return { code: 0, stdout: '', stderr: '' }; }
verbs.push(args.filter(a => !a.endsWith('proxy-service.sh')).join(' '));
return install;
};
return createUnitControl({ instance: 'zz', port: () => 18432, run, probe: async () => true, waitMs: 0, pollMs: 0, now: () => new Date(0) });
}
test('unit restart is confirmed only when a new main process is active and listening', async () => {
const verbs = [];
const control = fakeUnit([{ active: 'active', pid: 10, since: 5 }, { active: 'active', pid: 11, since: 6 }], { verbs });
const { evidence: e } = await control.restart();
assert.equal(e.confirmed, true);
assert.deepEqual(e.before, { pid: 10, since: 5, active: 'active' });
assert.deepEqual(e.after, { pid: 11, since: 6, active: 'active', listening: true });
assert.equal(e.unit, 'mosaic-proxy-zz.service');
assert.deepEqual(verbs, ['restart']);
const same = await fakeUnit([{ active: 'active', pid: 10, since: 5 }, { active: 'active', pid: 10, since: 5 }]).restart();
assert.equal(same.evidence.confirmed, false, 'the same process is not a restart');
const down = await fakeUnit([{ active: 'active', pid: 10, since: 5 }, { active: 'failed', pid: 0, since: 0 }]).restart();
assert.equal(down.evidence.confirmed, false, 'a failed unit is not a restart');
});
test('unit actions map proxy-service refusals by code and refuse a missing unit', async () => {
await assert.rejects(fakeUnit([{ load: 'not-found', active: 'inactive' }]).start(), code('proxy-unit-missing'));
await assert.rejects(fakeUnit([{ load: 'not-found', active: 'inactive' }]).restart(), code('proxy-unit-missing'));
await assert.rejects(fakeUnit([{ active: 'inactive' }], { install: { code: 3, stdout: '', stderr: 'proxy-service: proxy-unit-exists\n' } }).install(), code('proxy-unit-exists'));
await assert.rejects(fakeUnit([{ active: 'inactive' }], { install: { code: 3, stdout: '', stderr: 'proxy-service: /home/x secret detail\n' } }).install(), code('proxy-unit-failed'));
await assert.rejects(fakeUnit([{ active: 'active', pid: 0 }, { active: 'failed', pid: 0 }]).start(), code('proxy-unit-unhealthy'));
const verbs = [];
const started = await fakeUnit([{ active: 'inactive' }, { active: 'active', pid: 9, since: 1 }], { verbs }).start();
assert.equal(started.healthy, true); assert.deepEqual(verbs, ['start']);
assert.throws(() => createUnitControl({ unitDir: 'relative' }), code('proxy-unit-path'));
});
+29
View File
@@ -625,3 +625,32 @@ other two-file journals, a crash or IO failure between replacement and receipt
append can leave committed state without its receipt; an observed append failure
has `committed: true` and must be inspected before retrying. It never rolls back
committed authority silently or fabricates a success receipt.
### Organization autonomy (AU)
The optional `autonomy` object selects `in the loop`, `near the loop` or
`out of the loop` for `cross-role`, `merge`, `policy` and `authority`.
Without it, cross-role, merge and policy retain their arbiter routing and
plain hire/fire/change retain person approval. Only the owner Console writes
this setting; every change records the authenticated owner and writer receipt.
In the loop, the arbiter resolves and a person confirms or returns the decision
with a reason. Near the loop, the agent's resolution takes effect and the owner
receives a Console notice. `reverseWindowHours` is an integer from 1 to 168
(default 24). `notices: true` is required near the loop. `authorityResolver`
names a distinct organization instance holding `decision.resolve.authority`
withinRole; flagged grants, credential lines and root agents always retain
owner approval. No setting changes hard waits or the PRD request-mode switch.
Moving toward the loop needs no prerequisites. Moving away requires the class's
resolver, inverse, notices and review/limit prerequisites. Moving from near to
out also requires an actual near-loop resolution and a later owner trail review. Authority near requires an enabled limit check and an aggregate quota covering every selectable model (or the shared account when every enabled harness uses it). Empty budgets and existing-instance caps do not cover future hires. Authority out refuses `authority-limits-unavailable` until per-definition limits exist. A task inverse restores its prior
stored fields if no later edit intervened; a merge inverse lands a gated revert
commit. Hire/fire/change inverses reuse the existing lifecycle and writer paths,
never restore credential bindings, and refuse stale targets. Authority inverses check the window immediately before file publication. After a durable commit, completion-event, reload or relaunch failures are reported as recovery metadata. The inverse is recorded as committed; if that trail write itself fails, the response says `trailPending` rather than recording a false reversal failure. A refusal after stop/revoke leaves the agent stopped; inspect its trail before relaunching. External task
adapters without host-owned inverse evidence refuse near-loop consumption.
After the pinned window, use an ordinary new decision.
Policy has no effect executor. Per the Lead ruling, its in and existing out
levels work, while near refuses `policy-reverse-unavailable` and directs the
owner to choose in or out. Policy near and its reverse wait for that executor.
+47
View File
@@ -0,0 +1,47 @@
// AU: policy data only. Runtime prerequisites and caller identity belong to the host.
export const AUTONOMY_CLASSES = Object.freeze(['cross-role','merge','policy','authority']);
export const AUTONOMY_LEVELS = Object.freeze(['in the loop','near the loop','out of the loop']);
export const AUTONOMY_DEFAULTS = Object.freeze({'cross-role':'out of the loop',merge:'out of the loop',policy:'out of the loop',authority:'in the loop'});
const refuse = () => { const e=new Error('invalid-autonomy');e.code='invalid-autonomy';throw e; };
export function checkAutonomy(value) {
if(value===undefined)return undefined;
if(!value || typeof value!=='object' || Array.isArray(value) || ![Object.prototype,null].includes(Object.getPrototypeOf(value)))refuse();
for(const [key,v]of Object.entries(value)) {
if(AUTONOMY_CLASSES.includes(key)) {if(!AUTONOMY_LEVELS.includes(v))refuse();if(key==='policy' && v==='near the loop')throw Object.assign(new Error('policy-reverse-unavailable'),{code:'policy-reverse-unavailable'});}
else if(key==='reverseWindowHours') {if(!Number.isSafeInteger(v)||v<1||v>168)refuse();}
else if(key==='notices') {if(typeof v!=='boolean')refuse();}
else if(key==='authorityResolver') {if(typeof v!=='string'||!/^[a-z0-9][a-z0-9._-]{0,63}$/.test(v))refuse();}
else refuse();
}
return structuredClone(value);
}
export function decisionAutonomy(name, setting) {
if(!AUTONOMY_CLASSES.includes(name))refuse();
checkAutonomy(setting);
return setting?.[name]??AUTONOMY_DEFAULTS[name];
}
export function autonomyClass(action) {
return action==='git.merge.protected'?'merge':action==='policy.change'?'policy':['role.hire','role.fire','role.change'].includes(action)?'authority':'cross-role';
}
export function resolverSubject(business, resolver, target) {
if(target==null)return false;
const definition=business.roles?.[resolver]?.definition;
return target===resolver || target===definition || target===definition?.replace(/^org:/,'') || (definition!==undefined && business.roles?.[target]?.definition===definition);
}
// Aggregate provider/model limits cover names that do not exist yet. An account
// quota covers every future launch only when every enabled harness's module declares
// that its launches spend launch.account (its countsAccount export). The host passes
// that answer as countsAccount(name) -> true|false|null; core names no harness, and
// without the lookup no account quota counts.
export function authorityBudgetCovered(business, {countsAccount=()=>null}={}) {
const launch=business.launch, tokens=launch?.limits?.tokens, checks=business.plugins?.['limit-check']??[];
const quota=x=>Number.isSafeInteger(x?.limit) && x.limit>=0 && typeof x.since==='string' && !Number.isNaN(Date.parse(x.since));
const account=launch?.account?.name, harnesses=business.plugins?.harness??[];
if(account && harnesses.length && harnesses.every(h=>countsAccount(h)===true) &&
(checks.includes('daily-quota') && quota(tokens?.daily?.[account]) ||
checks.includes('token-limit') && quota(tokens?.keys?.[account]) && tokens.keys[account].reset===undefined))return true;
const models=Object.values(launch?.models??{});
return checks.includes('token-limit') && models.length>0 && models.every(({provider,model})=>
quota(tokens?.providers?.[provider]) || quota(tokens?.models?.[provider]?.[model]));
}
+353
View File
@@ -0,0 +1,353 @@
// Copy credential bindings, slices CB0 and CB1 (#1873; design
// docs/plans/2026-11-11_copy-credential-binding-design.md sections 3, 5 and 7).
//
// An organization binds a service to one concrete copy (a root agent's copy or a project agent's
// copy) through a record under the organization file's top-level `copyBindings`, keyed by the
// copy's instance name. A record holds identities, known scope names, project ids and integers:
// never a token, a token fragment or digest, a path or an environment name.
//
// { copyId, orgIncarnation, source: { level, name, project }, definition: { ref, rev, sha256 },
// generation, state, services: { gitea?: { slot, scopes, resources, rotateBy?, grantId?,
// grantGeneration? }, vikunja?: {...} }, decision, tracker? }
//
// copyId is 128 random bits minted on the first approval for that copy; it is kept, with the
// generation counting up, through every later change and the unbound tombstone. orgIncarnation is
// the registry's (org-registry.mjs): a record copied from another organization, or from a removed
// organization with the same id, never matches. `source` is the copy's level, name and project, so
// a dotted-name collision or a reused source name never inherits a record. `definition` pins the
// copy's definition identity: an org definition by its rev and the hash of its whole entry
// (composition, instructions and personality), a role template by the hash readTemplate takes over
// its file and contract.
//
// States: `pending` is approved and not verified. Only a verifier (slice CB2) may turn a record
// `bound`, and only `bound` resolves. `unbound` is the tombstone an unbind leaves.
//
// CB2 (grant metadata): a service the verifier proved carries `rotateBy` (the owner's rotation day),
// `grantId` (the service's opaque grant id, `gitea:<user id>:<token id>`: ids the service assigned,
// never token bytes) and `grantGeneration` (counts every verified grant of that service; it stays
// when a new issue strips the other two). The verifier's commit writes `bound` only when every
// service holds a grant, and no grant id appears twice across the organization's copies. Launch
// admission, which re-proves each grant, is CB3.
//
// Everything here is pure: no file is read, no secret, no slot.
import { randomBytes } from "node:crypto";
import { refuse } from "./errors.mjs";
import { GITEA_SCOPE_CATEGORIES, HOST_SERVICE, ID_PATTERN, SERVICES, VIKUNJA_GRANTABLE } from "./vocabulary.mjs";
import { ORG_PREFIX } from "./definitions.mjs";
import { canonicalJson, isPlainObject, sha256 } from "./util.mjs";
export const COPY_ID = /^[0-9a-f]{32}$/;
export const INCARNATION = /^[0-9a-f]{32}$/;
const SHA256 = /^[0-9a-f]{64}$/;
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
const BOT_NAME = /^bot-[a-z0-9][a-z0-9._-]{0,60}$/;
const GITEA_SCOPE = /^(read|write):([a-z]+)$/;
const RECORD_KEYS = ["copyId", "orgIncarnation", "source", "definition", "generation", "state", "services", "decision", "tracker"];
const SERVICE_KEYS = ["slot", "scopes", "resources"];
const GRANT_KEYS = ["rotateBy", "grantId", "grantGeneration"];
const GRANT_ID = /^(gitea|vikunja):[1-9][0-9]{0,15}:[1-9][0-9]{0,15}$/;
const DAY = /^\d{4}-\d{2}-\d{2}$/;
const realDay = (value) => typeof value === "string" && DAY.test(value) && !Number.isNaN(Date.parse(value))
&& new Date(value).toISOString().slice(0, 10) === value;
export const BINDING_STATES = Object.freeze(["pending", "bound", "unbound"]);
// CB1 binds the core services (vocabulary SERVICES) only. A plugin service has no verifier design yet.
export const BINDABLE_SERVICES = SERVICES;
export const MAX_BINDINGS = 256;
export const newCopyId = () => randomBytes(16).toString("hex");
// CB2: the one service account a copy's binding may use, derived from the registry incarnation, the
// copyId and the service, so another organization, another copy and another service never share
// it. Gitea limits a login to 40 characters; this is 36. Not a secret and not token-derived.
export function copyBotLogin(incarnation, copyId, service) {
if (typeof incarnation !== "string" || !INCARNATION.test(incarnation) || typeof copyId !== "string" || !COPY_ID.test(copyId)
|| !BINDABLE_SERVICES.includes(service)) return null;
return `mosaic-copy-${sha256(`copy-bot\0${incarnation}\0${copyId}\0${service}`).slice(0, 24)}`;
}
// Whether every service of a binding record holds a verified grant (CB2).
export const allGranted = (services) => isPlainObject(services) && Object.keys(services).length > 0
&& Object.values(services).every((v) => isPlainObject(v) && typeof v.grantId === "string");
// Fixed text for every binding refusal. `service` is a registered service label or null, never a
// submitted value.
const TEXT = {
"human-required": () => "Only the organization owner can approve credential binding.",
"credential-unbound": (s) => `credentials.${s} needs an owner-approved binding.`,
"credential-scope-exceeded": (s) => `credentials.${s}.scopes exceeds the approved role.`,
"credential-scope-unverified": (s) => `credentials.${s} permissions could not be verified.`,
"credential-identity-refused": (s) => `credentials.${s} is not this copy's own service account.`,
"credential-date": (s) => `credentials.${s} rotation date is invalid.`,
"credential-binding-changed": (s) => `credentials.${s} changed; read and approve again.`,
"credential-role-changed": (s) => `credentials.${s} no longer matches the approved definition; the owner must review the binding.`,
"copy-unavailable": () => "copy is no longer active in this organization.",
"copy-refused": () => "copy identity is ambiguous; the owner must resolve the definition collision.",
};
const label = (service) => (BINDABLE_SERVICES.includes(service) ? service : "service");
export function bindingText(code, service = null) {
const text = TEXT[code];
return text ? text(label(service)) : code;
}
export class CopyBindingError extends Error {
constructor(code, service = null) {
super(bindingText(code, service));
this.name = "CopyBindingError";
this.code = code;
if (service !== null) this.service = label(service);
}
}
export const bindingFail = (code, service = null) => { throw new CopyBindingError(code, service); };
// ---- Definition pin ----
// The identity a binding pins for a copy's definition. `definitions` is the merged root and org
// definition map as the boot snapshot holds it; `readTemplate` reads a role template (definitions.mjs).
// An org definition: its rev and the hash of its canonical entry. A template: rev null and the
// template hash. A version 1 template has no contract and cannot be pinned: null.
export function definitionPin(ref, { definitions = {}, rolesDir, readTemplate }) {
if (typeof ref !== "string") return null;
if (ref.startsWith(ORG_PREFIX)) {
const name = ref.slice(ORG_PREFIX.length);
const entry = Object.hasOwn(definitions, name) ? definitions[name] : null;
if (!isPlainObject(entry) || !Number.isSafeInteger(entry.rev) || entry.rev < 1) return null;
return { ref, rev: entry.rev, sha256: sha256(canonicalJson(entry)) };
}
if (!ID_PATTERN.test(ref) || typeof readTemplate !== "function") return null;
try {
return { ref, rev: null, sha256: readTemplate(rolesDir, ref).sha256 };
} catch (error) {
if (error?.name === "BusinessError") return null;
throw error;
}
}
export const samePin = (a, b) => isPlainObject(a) && isPlainObject(b) && a.ref === b.ref && a.rev === b.rev && a.sha256 === b.sha256;
// ---- Scope semantics ----
// Gitea: {category: "read"|"write"}, one level per category. admin and all are never granted.
function giteaLevels(scopes) {
if (!Array.isArray(scopes)) return null;
const out = {};
for (const scope of scopes) {
const m = typeof scope === "string" ? GITEA_SCOPE.exec(scope) : null;
if (!m || !GITEA_SCOPE_CATEGORIES.includes(m[2]) || Object.hasOwn(out, m[2])) return null;
out[m[2]] = m[1];
}
return out;
}
const RANK = { read: 1, write: 2 };
// Vikunja: the set of "group verb" pairs, each one VIKUNJA_GRANTABLE lists.
function vikunjaPairs(scopes) {
if (!isPlainObject(scopes)) return null;
const out = new Set();
for (const [group, verbs] of Object.entries(scopes)) {
if (!Object.hasOwn(VIKUNJA_GRANTABLE, group) || !Array.isArray(verbs) || verbs.length === 0) return null;
for (const verb of verbs) {
if (typeof verb !== "string" || !VIKUNJA_GRANTABLE[group].includes(verb) || out.has(`${group} ${verb}`)) return null;
out.add(`${group} ${verb}`);
}
}
return out;
}
export function validScopes(service, scopes) {
if (service === "gitea") { const l = giteaLevels(scopes); return l !== null && Object.keys(l).length > 0; }
if (service === "vikunja") { const p = vikunjaPairs(scopes); return p !== null && p.size > 0; }
return false;
}
// Whether `approved` is within `role` for the service, compared by the service's semantics: Gitea
// read is narrower than write in one category and categories are independent; Vikunja compares
// each group and verb pair. A missing category or group is no permission. Never a string compare.
export function scopesWithin(service, approved, role) {
if (service === "gitea") {
const a = giteaLevels(approved), r = giteaLevels(role);
if (!a || !r) return false;
return Object.entries(a).every(([category, level]) => Object.hasOwn(r, category) && RANK[level] <= RANK[r[category]]);
}
if (service === "vikunja") {
const a = vikunjaPairs(approved), r = vikunjaPairs(role);
if (!a || !r) return false;
return [...a].every((pair) => r.has(pair));
}
return false;
}
// effective(V) = role-declared(V) intersect approved(V), as scopes of the service's own shape.
// Null when the intersection is empty.
export function intersectScopes(service, approved, role) {
if (service === "gitea") {
const a = giteaLevels(approved), r = giteaLevels(role);
if (!a || !r) return null;
const out = Object.keys(a).filter((c) => Object.hasOwn(r, c)).sort()
.map((c) => `${RANK[a[c]] <= RANK[r[c]] ? a[c] : r[c]}:${c}`);
return out.length ? out : null;
}
if (service === "vikunja") {
const a = vikunjaPairs(approved), r = vikunjaPairs(role);
if (!a || !r) return null;
const out = {};
for (const pair of [...a].filter((p) => r.has(p)).sort()) {
const [group, verb] = pair.split(" ");
(out[group] ??= []).push(verb);
}
return Object.keys(out).length ? out : null;
}
return null;
}
// The role's declared scopes per bindable service. A host service is bound per host elsewhere and
// is not a copy binding; a service outside the core pair cannot be bound in CB1.
export function roleServices(role) {
const out = {};
for (const c of role?.credentials ?? []) {
if (c.service === HOST_SERVICE) continue;
out[c.service] = c.scopes;
}
return out;
}
// ---- Record schema ----
// The positional refusal for record `i` (1-based): it never names a submitted key or value.
const at = (i, field) => `copyBindings entry ${i} ${field} field is invalid`;
// Checks the whole `copyBindings` value of an organization document. `trackers` is the list of
// {bot, botId} the document's instances and its tracker sync already use; a copy's tracker bot is
// unique against them and against other copies. Throws a BusinessError with a positional message.
export function validateCopyBindings(value, { trackers = [] } = {}) {
if (value === undefined) return;
if (!isPlainObject(value)) refuse("copyBindings field is invalid");
const keys = Object.keys(value);
if (keys.length > MAX_BINDINGS) refuse("copyBindings field is invalid");
const bots = new Set(trackers.map((t) => t.bot)), botIds = new Set(trackers.map((t) => t.botId)), copyIds = new Set(), grants = new Set();
keys.forEach((instance, n) => {
const i = n + 1, r = value[instance];
if (!ID_PATTERN.test(instance)) refuse(`copyBindings entry ${i} name is invalid`);
if (!isPlainObject(r)) refuse(`copyBindings entry ${i} is invalid`);
const unknown = Object.keys(r).some((k) => !RECORD_KEYS.includes(k));
if (unknown) refuse(`copyBindings entry ${i} has an unknown field`);
if (typeof r.copyId !== "string" || !COPY_ID.test(r.copyId)) refuse(at(i, "copyId"));
if (copyIds.has(r.copyId)) refuse(at(i, "copyId"));
copyIds.add(r.copyId);
if (typeof r.orgIncarnation !== "string" || !INCARNATION.test(r.orgIncarnation)) refuse(at(i, "orgIncarnation"));
const s = r.source;
if (!isPlainObject(s) || Object.keys(s).sort().join(",") !== "level,name,project"
|| !["root", "project"].includes(s.level) || typeof s.name !== "string" || !ID_PATTERN.test(s.name)
|| (s.level === "root" ? s.project !== null : typeof s.project !== "string" || !ID_PATTERN.test(s.project))
|| (s.level === "root" ? s.name : `${s.name}.${s.project}`) !== instance) refuse(at(i, "source"));
const d = r.definition;
if (!isPlainObject(d) || Object.keys(d).sort().join(",") !== "ref,rev,sha256" || typeof d.ref !== "string"
|| typeof d.sha256 !== "string" || !SHA256.test(d.sha256)
|| (d.ref.startsWith(ORG_PREFIX)
? !ID_PATTERN.test(d.ref.slice(ORG_PREFIX.length)) || !Number.isSafeInteger(d.rev) || d.rev < 1
: !ID_PATTERN.test(d.ref) || d.rev !== null)) refuse(at(i, "definition"));
if (!Number.isSafeInteger(r.generation) || r.generation < 1) refuse(at(i, "generation"));
if (!BINDING_STATES.includes(r.state)) refuse(at(i, "state"));
if (typeof r.decision !== "string" || !UUID.test(r.decision)) refuse(at(i, "decision"));
if (!isPlainObject(r.services)) refuse(at(i, "services"));
const services = Object.keys(r.services);
if ((r.state === "unbound") !== (services.length === 0)) refuse(at(i, "services"));
for (const service of services) {
const v = r.services[service];
const extra = isPlainObject(v) ? Object.keys(v).filter((k) => !SERVICE_KEYS.includes(k)) : [];
if (!BINDABLE_SERVICES.includes(service) || !isPlainObject(v) || SERVICE_KEYS.some((k) => !Object.hasOwn(v, k))
|| extra.some((k) => !GRANT_KEYS.includes(k)) || v.slot !== "self" || !validScopes(service, v.scopes)) refuse(at(i, "services"));
// CB2: rotateBy and grantId come together; a grant id needs its generation; one grant id per organization.
if ((v.rotateBy === undefined) !== (v.grantId === undefined) || (v.grantId !== undefined && v.grantGeneration === undefined)
|| (v.rotateBy !== undefined && !realDay(v.rotateBy))
|| (v.grantId !== undefined && (typeof v.grantId !== "string" || !GRANT_ID.test(v.grantId) || v.grantId.split(":")[0] !== service))
|| (v.grantGeneration !== undefined && (!Number.isSafeInteger(v.grantGeneration) || v.grantGeneration < 1))) refuse(at(i, "services"));
if (v.grantId !== undefined) {
if (grants.has(v.grantId)) refuse(`copyBindings entry ${i} services field uses a grant another binding uses`);
grants.add(v.grantId);
}
const p = v.resources;
if (!isPlainObject(p) || Object.keys(p).join(",") !== "projects" || !Array.isArray(p.projects) || p.projects.length === 0
|| p.projects.some((x) => typeof x !== "string" || !ID_PATTERN.test(x)) || new Set(p.projects).size !== p.projects.length
|| (s.level === "project" && (p.projects.length !== 1 || p.projects[0] !== s.project))) refuse(at(i, "services"));
}
if (r.tracker !== undefined) {
const t = r.tracker;
if (!services.includes("vikunja") || !isPlainObject(t) || Object.keys(t).sort().join(",") !== "bot,botId"
|| typeof t.bot !== "string" || !BOT_NAME.test(t.bot) || !Number.isSafeInteger(t.botId) || t.botId < 1) refuse(at(i, "tracker"));
if (bots.has(t.bot) || botIds.has(t.botId)) refuse(`copyBindings entry ${i} tracker field uses a bot another instance uses`);
bots.add(t.bot);
botIds.add(t.botId);
}
});
}
// ---- Resolver (CB0) ----
// What a copy's binding grants in this organization, from plain inputs only:
// orgId, incarnation the organization and its live registry incarnation (null: none)
// snapshot the boot snapshot materializeCopies made for orgId
// instance the copy's instance name
// record copyBindings[instance] from the organization document, or undefined
// pin definitionPin of the copy's current definition (null: cannot pin)
// role the copy's current resolved role ({credentials: [...]})
// projects the project ids that admit this copy now: for a root copy, the projects
// whose union holds it (an excluded or refusing project is left out), not
// the organization's whole project list
// Returns { ok: true, copyId, generation, services: { V: { scopes, projects } } } when every
// service the role needs is bound, or { ok: false, code, service } with the first refusal. Only a
// `bound` record resolves; pending, partial, tombstoned, foreign and stale records never do.
export function resolveCopyBinding({ orgId, incarnation, snapshot, instance, record, pin, role, projects = [] }) {
const no = (code, service = null) => ({ ok: false, code, service: service === null ? null : label(service) });
if (!snapshot || snapshot.org !== orgId) return no("copy-unavailable");
const copy = Object.hasOwn(snapshot.copies ?? {}, instance) ? snapshot.copies[instance] : null;
// An org instance of the same name shadows the copy, and a removed or excluded source makes none.
if (!copy) return no("copy-unavailable");
if (copy.state === "refused") return no(copy.claims ? "copy-refused" : "copy-unavailable");
const needed = roleServices(role);
const first = Object.keys(needed).sort()[0] ?? null;
if (record === undefined || record === null) return no("credential-unbound", first);
if (typeof incarnation !== "string" || !INCARNATION.test(incarnation) || record.orgIncarnation !== incarnation) return no("credential-unbound", first);
const source = { level: copy.level, name: copy.name, project: copy.project };
if (canonicalJson(record.source) !== canonicalJson(source)) return no("credential-unbound", first);
if (!pin || !samePin(record.definition, pin) || record.definition.ref !== copy.definition) return no("credential-role-changed", first);
if (record.state !== "bound") return no("credential-unbound", first);
const approved = record.services ?? {};
for (const service of Object.keys(approved).sort()) {
if (!Object.hasOwn(needed, service)) return no("credential-scope-exceeded", service);
if (!scopesWithin(service, approved[service].scopes, needed[service])) return no("credential-scope-exceeded", service);
}
const admitted = new Set(copy.level === "project" ? (projects.includes(copy.project) ? [copy.project] : []) : projects);
const services = {};
for (const service of Object.keys(needed).sort()) {
if (!BINDABLE_SERVICES.includes(service) || !Object.hasOwn(approved, service)) return no("credential-unbound", service);
const scopes = intersectScopes(service, approved[service].scopes, needed[service]);
const reach = approved[service].resources.projects.filter((p) => admitted.has(p)).sort();
if (!scopes || reach.length === 0) return no("credential-unbound", service);
services[service] = { scopes, projects: reach };
}
if (!Object.keys(services).length) return no("credential-unbound", first);
return { ok: true, copyId: record.copyId, generation: record.generation, services };
}
// ---- Proposal checks (CB1) ----
// The binding payload a chief proposes: { service: { scopes, projects } } for a bind, or null for
// an unbind. Each service must be one the copy's role declares, its scopes within the role and its
// projects admitted for the copy (`projects`: for a root copy, the projects whose union holds it).
// Never trims a broader request. Returns the normalized services.
export function checkBindingRequest(services, { copy, role, projects }) {
if (services === null) return null;
if (!isPlainObject(services) || Object.keys(services).length === 0) refuse("binding services field is invalid");
const needed = roleServices(role);
const out = {};
for (const service of Object.keys(services).sort()) {
if (!BINDABLE_SERVICES.includes(service)) refuse("binding services field is invalid");
const v = services[service];
if (!isPlainObject(v) || Object.keys(v).sort().join(",") !== "projects,scopes") refuse("binding services field is invalid");
if (!validScopes(service, v.scopes)) refuse("binding scopes field is invalid");
if (!Object.hasOwn(needed, service) || !scopesWithin(service, v.scopes, needed[service])) bindingFail("credential-scope-exceeded", service);
if (!Array.isArray(v.projects) || v.projects.length === 0 || v.projects.some((p) => typeof p !== "string" || !ID_PATTERN.test(p))
|| new Set(v.projects).size !== v.projects.length) refuse("binding projects field is invalid");
const allowed = copy.level === "project" ? [copy.project] : projects;
if (v.projects.some((p) => !allowed.includes(p))) bindingFail("credential-scope-exceeded", service);
out[service] = { slot: "self", scopes: structuredClone(v.scopes), resources: { projects: [...v.projects].sort() } };
}
return out;
}
+152 -7
View File
@@ -13,14 +13,34 @@ import { OrganizationError } from './organization.mjs';
import { resolveInstance } from './resolve.mjs';
import { checkRoutes } from './launch-settings.mjs';
import { VERB_RIGHTS } from './org-rights.mjs';
import { readRegistry, withRegistryWriter } from './org-registry.mjs';
import { ensureOrgIncarnation, orgIncarnation, readRegistry, withRegistryWriter } from './org-registry.mjs';
import { checkDefinitions } from './definitions.mjs';
import { loadRole } from './role.mjs';
import { copyInstanceName } from './agent-copies.mjs';
import { resolveAgentLevels } from './agent-levels.mjs';
import { bindingFail, checkBindingRequest, definitionPin, newCopyId, samePin } from './copy-bindings.mjs';
const fail = code => { throw new OrganizationError(code); };
const hash = x => createHash('sha256').update(canonicalJson(x)).digest('hex');
const UUID = /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/;
const keys = (x, allowed, required = []) => { if (!isPlainObject(x) || Object.keys(x).some(k => !allowed.includes(k)) || required.some(k => !Object.hasOwn(x,k))) fail('invalid-request'); };
const id = x => { if (typeof x !== 'string' || !ID_PATTERN.test(x) || ['human','tester','user'].includes(x)) fail('invalid-request'); };
const requestKeys = ['op','level','project','name','role','credentials','personality','route','reason'];
const requestKeys = ['op','level','project','name','role','credentials','personality','route','reason','binding'];
// #1873 CB1: a copy credential binding (bind or unbind) is owner-only. No agent resolves it, whatever
// authority decision or resolver it carries; a person needs org.hire and must be the current owner
// of the organization. An instance operator alone is not.
const bindingOwnerOnly = () => fail('human-required');
export function authorizeHire(proposal, caller) {
if(proposal?.binding!==undefined) {
if(!caller || caller.agent!==false)bindingOwnerOnly();
if(!caller.rights?.includes(VERB_RIGHTS['hire.resolve.console']))fail('right-required');
if(caller.owner!==true)bindingOwnerOnly();
return;
}
if(caller?.agent===true) {
if(typeof caller.authorityDecision!=='string' || typeof caller.resolver!=='string')fail('human-required');
if(caller.authorityDecision!==proposal.id || !caller.rights?.includes('decision.resolve.authority') || proposal.flagged.length || proposal.credentialLines.length || proposal.level==='root' || caller.resolver===proposal.name)fail('hire-owner-required');
return;
}
if (!caller || caller.agent !== false) fail('human-required');
if (!caller.rights?.includes(VERB_RIGHTS['hire.resolve.console'])) fail('right-required');
if ((proposal.flagged.length || proposal.credentialLines.length) && caller.owner !== true) fail('hire-owner-required');
@@ -33,12 +53,16 @@ export function credentialLines(credentials) {
const publicOf = p => ({ id:p.id, revision:p.revision, op:p.request.op, level:p.request.level, name:p.request.name,
...(p.request.project === undefined ? {} : {project:p.request.project}), route:p.request.route ?? null,
...(p.entry ? {role:structuredClone(p.entry.role)} : {}),
// #1873 CB1: ids, scope names, project ids and integers. Never a token, a slot path or a file path.
...(p.kind === 'binding' ? {binding:structuredClone(p.binding)} : {}),
// reason and all free text stay in the private proposal as well.
flagged:p.flagged, credentialLines:p.credentialLines, expiresAt:p.expiresAt, digest:hash(p) });
// org-defscan (#1873): `scanDefinition(name, entry)` is the bus's definition check on the approved
// entry; it throws (definition-holds-credential, naming the definition and key) when the live store
// holds text in it. Without one, the entry still goes through assertClean whole.
export function hireDecisions({ writer, rolesDir, configDir, dataRoot, assertClean, scanDefinition, system = {}, levels = () => null, catalogueFor = async () => null, registryFor = () => buildRegistry() }) {
// `copies(business)` (#1873 CB1) is the bus's boot snapshot of this organization's copies
// (agent-copies.mjs materializeCopies). A bus without one refuses every binding: copy-unavailable.
export function hireDecisions({ writer, rolesDir, configDir, dataRoot, assertClean, scanDefinition, system = {}, levels = () => null, catalogueFor = async () => null, registryFor = () => buildRegistry(), copies = () => null }) {
const scanEntry = (name, entry) => {
if (typeof scanDefinition === 'function') return scanDefinition(name, structuredClone(entry));
if (typeof assertClean !== 'function') fail('definition-secret-check-required');
@@ -78,12 +102,97 @@ export function hireDecisions({ writer, rolesDir, configDir, dataRoot, assertCle
}
const location = value => { if (!UUID.test(value)) fail('invalid-request'); return join(home,`${value}.json`); };
const read = value => { try { return readJsonFile(location(value),'private hire proposal',st=>{if(st.uid!==process.getuid() || st.mode&0o077 || st.nlink!==1)fail('decision-stale');}); } catch { fail('decision-stale'); } };
// ---- #1873 CB1: copy credential binding subtype ----
// The copy, its pinned definition and role, as this bus booted them and the file holds them now.
// Every refusal is a fixed code; nothing names a token, a slot or a path.
const bindingFacts = (business, request, doc) => {
const snapshot = copies(business);
const project = request.level === 'project' ? request.project : null;
const instance = copyInstanceName(request.name, project);
const copy = instance !== null && snapshot?.org === business && Object.hasOwn(snapshot.copies ?? {}, instance) ? snapshot.copies[instance] : null;
if (!copy || copy.name !== request.name || copy.project !== project || copy.level !== request.level) fail('copy-unavailable');
if (copy.state === 'refused') fail(copy.claims ? 'copy-refused' : 'copy-unavailable');
// A root copy is only as fresh as its root receipts: removed, replaced or unreadable since boot refuses.
if (copy.level === 'root') { const stale = rootRefusal(copy.name); if (stale) fail(stale); }
// A named instance added since boot shadows the copy (design section 6).
if (Object.hasOwn(doc.roles ?? {}, instance)) fail('copy-unavailable');
const definitions = { ...(snapshot.definitions ?? {}), ...(doc.definitions ?? {}) };
const pin = definitionPin(copy.definition, { definitions, rolesDir, readTemplate });
if (!pin) fail('credential-definition-unpinned');
const role = copy.definition.startsWith('org:') ? checkDefinitions(definitions, 'copy definitions')[copy.definition] : loadRole(rolesDir, copy.definition);
if (!role) fail('credential-definition-unpinned');
// The projects that admit this copy now. A root copy reaches only the projects whose union,
// recomputed from this document and the boot's root agents, still holds it: an exclude entry or a
// refusing union bars it, and a request naming that project refuses (credential-scope-exceeded).
const code = Object.keys(doc.projects ?? {}).filter(p => doc.projects[p]?.kind !== 'library');
let projects = code;
if (copy.level === 'root') {
let levels;
try { levels = resolveAgentLevels({ orgId: business, doc: { ...doc, definitions }, root: { agents: snapshot.rootAgents ?? {} }, rolesDir }); }
catch (error) { if (error?.name === 'BusinessError') fail('copy-unavailable'); throw error; }
projects = code.filter(p => levels.unions[p]?.ok && levels.unions[p].agents.some(a => a.level === 'root' && a.name === copy.name));
}
return { instance, copy, pin, role, definitions, projects, source: { level: copy.level, name: copy.name, project } };
};
const bindingLines = services => credentialLines(Object.entries(services ?? {}).map(([service, v]) => ({ service, scopes: v.scopes })));
async function prepareBinding(business, input, { id: decision, expiresAt }) {
if (input.op !== 'change' || !['root','project'].includes(input.level) || ['role','route','credentials','personality'].some(k => k in input)) fail('invalid-request');
if (input.level === 'project') id(input.project); else if ('project' in input) fail('invalid-request');
keys(input.binding, ['services'], ['services']);
const { document: doc, revision } = writer.read(business);
const facts = bindingFacts(business, input, doc);
let after;
try { after = checkBindingRequest(input.binding.services, { copy: facts.copy, role: facts.role, projects: facts.projects }); }
catch (error) { if (error?.name === 'CopyBindingError') fail(error.code); fail('invalid-request'); }
const current = doc.copyBindings?.[facts.instance] ?? null;
if (current && canonicalJson(current.source) !== canonicalJson(facts.source)) fail('credential-binding-changed');
if (after === null && (!current || current.state === 'unbound')) fail('credential-unbound');
// An org reserved before CB0 gets its incarnation here, under the registry lock.
const incarnation = ensureOrgIncarnation(configDir, business);
if (incarnation === null) fail('copy-unavailable');
if (current && current.orgIncarnation !== incarnation) fail('credential-binding-changed');
const binding = {
instance: facts.instance, source: facts.source, copyId: current?.copyId ?? newCopyId(), fresh: !current, orgIncarnation: incarnation,
definition: facts.pin, before: current && current.state !== 'unbound' ? structuredClone(current.services) : null, after, generation: current?.generation ?? 0,
};
// Only the structured fields are kept. The free-text reason is never written to the proposal or
// covered by its digest: a chief may paste a path or a token fragment there by mistake.
const request = { op: input.op, level: input.level, name: input.name, ...(input.level === 'project' ? { project: input.project } : {}), binding: { services: structuredClone(input.binding.services) } };
const p = { id: decision, business, kind: 'binding', prior: null, revision, rootRevision: null, request, entry: null, definition: null,
binding, flagged: [], credentialLines: bindingLines(after ?? binding.before), expiresAt };
mkdirSync(home, { recursive: true, mode: 0o700 });
const st = lstatSync(home); if (!st.isDirectory() || st.isSymbolicLink() || st.uid !== process.getuid() || st.mode & 0o077) fail('organization-refused');
const fd = openSync(location(decision), 'wx', 0o600);
try { writeFileSync(fd, JSON.stringify(p)); fsyncSync(fd); } finally { closeSync(fd); }
return publicOf(p);
}
// Everything the approval pinned, read again before any effect: the file revision, the copy, its
// definition, the registry incarnation, the generation and the requested scopes.
async function checkBinding(business, p) {
const { document: doc, revision } = writer.read(business);
if (revision !== p.revision) fail('decision-stale');
const facts = bindingFacts(business, p.request, doc);
const b = p.binding;
if (facts.instance !== b.instance || canonicalJson(facts.source) !== canonicalJson(b.source)) fail('credential-binding-changed');
if (!samePin(facts.pin, b.definition)) fail('credential-role-changed');
if (orgIncarnation(await readRegistry({ configDir }), business) !== b.orgIncarnation) fail('decision-stale');
const current = doc.copyBindings?.[b.instance] ?? null;
if ((current?.generation ?? 0) !== b.generation || (current ? current.copyId !== b.copyId : !b.fresh)) fail('credential-binding-changed');
if (b.after !== null) {
let after;
try { after = checkBindingRequest(p.request.binding.services, { copy: facts.copy, role: facts.role, projects: facts.projects }); }
catch (error) { if (error?.name === 'CopyBindingError') fail(error.code); fail('invalid-request'); }
if (canonicalJson(after) !== canonicalJson(b.after)) fail('decision-stale');
}
return facts;
}
async function prepare(business, input, { id: decision, expiresAt }) {
keys(input,requestKeys,['op','level','name','reason']); id(input.name);
if (!['hire','fire','change'].includes(input.op) || !['org','project','root'].includes(input.level)) fail('invalid-request');
if (typeof input.reason!=='string' || input.reason.trim().length===0 || input.reason.length>500) fail('invalid-request');
if (typeof assertClean!=='function') fail('definition-secret-check-required');
assertClean(input);
if ('binding' in input) return prepareBinding(business, input, { id: decision, expiresAt });
if (input.level==='project') id(input.project); else if ('project' in input) fail('invalid-request');
if (input.op!=='fire') {
keys(input.route,['harness','model','thinking'],['harness','model']);
@@ -149,7 +258,8 @@ export function hireDecisions({ writer, rolesDir, configDir, dataRoot, assertCle
}
if(input.op==='change' && input.level==='root' && Object.entries(root.document.agents).some(([name,e])=>name!==input.name && e.definition===current.definition))fail('definition-shared');
const targetDefinition=input.op==='change' && current?.definition?.startsWith('org:') ? current.definition.slice(4) : `hire-${decision}`;
const p={id:decision,business,revision,rootRevision:root?.revision??null,request:structuredClone(input),entry,definition:targetDefinition,flagged,credentialLines:lines,expiresAt};
const prior={instance:current??null,definition:current?.definition?.startsWith('org:')?(input.level==='root'?root.document.definitions:doc.definitions)?.[current.definition.slice(4)]??null:null,launched:doc.launch?.instances?.includes(input.name)??false};
const p={id:decision,business,prior,revision,rootRevision:root?.revision??null,request:structuredClone(input),entry,definition:targetDefinition,flagged,credentialLines:lines,expiresAt};
mkdirSync(home,{recursive:true,mode:0o700});
const st=lstatSync(home); if(!st.isDirectory() || st.isSymbolicLink() || st.uid!==process.getuid() || st.mode&0o077)fail('organization-refused');
const fd=openSync(location(decision),'wx',0o600);
@@ -163,6 +273,8 @@ export function hireDecisions({ writer, rolesDir, configDir, dataRoot, assertCle
// issued after the proposal refuses it here, before preview, check or apply goes further.
if(p.entry)scanEntry(p.definition,p.entry);
if(adjust===undefined)return p;
// A binding is never adjusted: narrowing or replacing it is a new decision (design section 4).
if(p.kind==='binding')fail('invalid-request');
keys(adjust,['role','credentials','route']);
if(!p.entry)fail('invalid-request');
assertClean(adjust);
@@ -206,6 +318,7 @@ export function hireDecisions({ writer, rolesDir, configDir, dataRoot, assertCle
if (p.business!==business || caller.digest!==view.digest)fail('decision-stale');
if(Date.parse(p.expiresAt)<=(caller.now??Date.now)())fail('decision-expired');
authorizeHire(view,caller);
if(p.kind==='binding'){await checkBinding(business,p);return p;}
if(writer.read(business).revision!==p.revision)fail('decision-stale');
// Re-run composition against current policy and scan private text again before any effect.
if(p.entry) {
@@ -223,6 +336,14 @@ export function hireDecisions({ writer, rolesDir, configDir, dataRoot, assertCle
}
async function apply(business, decision, caller) {
const p=await check(business,decision,caller);
if(p.kind==='binding') {
const facts=await checkBinding(business,p);
// A root copy's receipts are read again under the organization lock, before the write and
// before publication, so a removal or replacement after checkBinding still refuses.
const fresh=()=>{if(facts.copy.level==='root'){const stale=rootRefusal(facts.copy.name);if(stale)fail(stale);}};
return writer.applyCopyBindingDecision(business,{...p,digest:publicOf(p).digest},{actor:caller.actor,owner:caller.owner===true,definitions:facts.definitions,
beforeWrite:()=>{fresh();caller.beforeWrite?.();},beforePublish:()=>{fresh();caller.beforePublish?.();}});
}
const r=p.request;
const vars=r.route ? {harness:r.route.harness,model:r.route.model,...(r.route.thinking?{thinking:r.route.thinking}:{})} : {};
const currentDoc=r.level==='root'?(await readRootAgentsFile(configDir)).document:writer.read(business).document;
@@ -238,8 +359,32 @@ export function hireDecisions({ writer, rolesDir, configDir, dataRoot, assertCle
return publish();
}
if(r.level==='project') return writer.setProjectAgent(business,{revision:p.revision,project:r.project,name:r.name,entry},caller.actor,
{approvedDefinition:p.entry?{name:p.definition,entry:approvedEntry}:null,decision:p.id,assertClean,beforeWrite:caller.beforeWrite});
return writer.applyHireDecision(business,p,{actor:caller.actor,assertClean});
{approvedDefinition:p.entry?{name:p.definition,entry:approvedEntry}:null,decision:p.id,assertClean,beforeWrite:caller.beforeWrite,beforePublish:caller.beforePublish,authorityDecision:caller.authorityDecision});
return writer.applyHireDecision(business,p,{actor:caller.actor,assertClean,beforeWrite:caller.beforeWrite,beforePublish:caller.beforePublish,authorityDecision:caller.authorityDecision});
}
return {prepare,templates:()=>({templates:templateHashes(rolesDir).map(({template,sha256})=>({name:template,sha256}))}),preview:async(business,decision,caller)=>{const p=await adjusted(business,decision,caller.adjust);if(Date.parse(p.expiresAt)<=(caller.now??Date.now)())fail('decision-expired');authorizeHire(publicOf(p),caller);return publicOf(p);},rootRefusal,rootActive:name=>rootRefusal(name)===null,check:async(...args)=>{await check(...args);},apply,read: decision => publicOf(read(decision))};
async function reverse(business,decision,caller) {
const p=read(decision);if(p.business!==business || p.kind==='binding' || !p.prior || p.request.level==='root')fail('decision-stale');
const r=p.request,old=writer.read(business),current=r.level==='org'?old.document.roles[r.name]:old.document.projects[r.project]?.agents?.[r.name];
// A later approved change must not be overwritten by an earlier inverse.
if(r.op==='fire') {
if(r.level==='project'?current!==undefined:old.document.launch.instances.includes(r.name))fail('decision-stale');
if(r.level==='org') {
const strip=x=>{const {credentials,tracker,...rest}=x??{};return rest;};
if(canonicalJson(strip(current))!==canonicalJson(strip(p.prior.instance)))fail('decision-stale');
}
if(p.prior.definition && canonicalJson(old.document.definitions?.[p.prior.instance.definition.slice(4)])!==canonicalJson(p.prior.definition))fail('decision-stale');
} else if(current?.hireDecision!==decision)fail('decision-stale');
const publicationGuard=()=>{(caller.beforePublish??caller.beforeWrite)();if(p.prior.definition)scanEntry(p.prior.instance.definition.slice(4),p.prior.definition);if(writer.read(business).revision!==old.revision)fail('decision-stale');};
const guard=()=>{caller.beforeWrite();publicationGuard();};
guard();
if(r.op==='hire') {
if(r.level==='org')return caller.fire({...publicOf(p),revision:old.revision});
await caller.stop(publicOf(p),guard);guard();
return writer.setProjectAgent(business,{revision:old.revision,project:r.project,name:r.name,entry:null},caller.actor,{beforeWrite:guard,beforePublish:publicationGuard});
}
const stopped=await caller.stop(publicOf(p),guard);guard();
const result=writer.reverseHireDecision(business,p,{...caller,beforeWrite:guard,beforePublish:publicationGuard});
return {...result,...(r.op==='change' && stopped?.running?{relaunch:publicOf(p)}:{})};
}
return {prepare,reverse,templates:()=>({templates:templateHashes(rolesDir).map(({template,sha256})=>({name:template,sha256}))}),preview:async(business,decision,caller)=>{const p=await adjusted(business,decision,caller.adjust);if(Date.parse(p.expiresAt)<=(caller.now??Date.now)())fail('decision-expired');authorizeHire(publicOf(p),caller);return publicOf(p);},rootRefusal,rootActive:name=>rootRefusal(name)===null,check:async(...args)=>{await check(...args);},apply,read: decision => publicOf(read(decision))};
}
@@ -355,13 +355,14 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null, r
// webby-0094 N1: an instance already delisted (a hand edit, or a repeat after step B) still has
// its tokens revoked, with no write. A revoke is safe to repeat.
if (!old.launch.instances.includes(name)) {
context?.beforeWrite?.();
const revoked = revoke ? revoke(business, name, "fire", services) : null;
return { ...read(business, actor), transaction: null, delisted: false, ...(revoked ? { credentials: revoked } : {}) };
}
const next = structuredClone(old);
next.launch.instances = old.launch.instances.filter(entry => entry !== name);
let revoked = null;
const beforeWrite = revoke ? () => { revoked = revoke(business, name, "fire", services); } : null;
const beforeWrite = () => { context?.beforeWrite?.(); if(revoke)revoked = revoke(business, name, "fire", services); context?.beforeWrite?.(); };
const result = commit(business, old, next, { revision, actor, context, beforeWrite });
return { ...result, delisted: true, ...(revoked ? { credentials: revoked } : {}) };
}
@@ -422,7 +423,7 @@ export function instanceSettings({ dir, rolesDir, system, levels = () => null, r
// writer checks again under its lock, before the revoke (row 239), and that check is what keeps
// a stale fire or delete from revoking.
if (writer.read(business).revision !== revision) throw new OrganizationError("revision-conflict", "organization revision changed; read again");
const receipt = writer.write(business, next, { revision, actor, context, beforeWrite });
const receipt = writer.write(business, next, { revision, actor, context, beforeWrite, beforePublish:context?.beforePublish });
return { ...read(business, actor), transaction: receipt.transaction, restart: false };
}
+12 -5
View File
@@ -132,15 +132,18 @@ export function checkRoutes({ proposed, old = null, levels, catalogue = null })
}
const currentEntry = proposed.launch?.models?.[vars[model]];
const previousEntry = old?.launch?.models?.[before[model]];
const changed = [harness, model, thinking].some(field => vars[field] !== before[field]) || canonicalJson(currentEntry ?? null) !== canonicalJson(previousEntry ?? null);
if (changed && previousEntry?.module && !currentEntry?.module && vars[model] === before[model]) throw new OrganizationError('model-provider-unavailable');
if (changed && currentEntry?.module) {
// D3 (P0-4): the catalogue answers which harness a model runs on, not its thinking level, so
// a change that keeps the harness, the model key and its entry needs no catalogue; the
// harness checks below still run on the new thinking level.
const routeChanged = [harness, model].some(field => vars[field] !== before[field]) || canonicalJson(currentEntry ?? null) !== canonicalJson(previousEntry ?? null);
if (routeChanged && previousEntry?.module && !currentEntry?.module && vars[model] === before[model]) throw new OrganizationError('model-provider-unavailable');
if (routeChanged && currentEntry?.module) {
if (catalogue?.status !== 'ready') throw new OrganizationError('model-provider-unavailable');
const offered = catalogue.models?.find(m => m.key === vars[model]);
if (!offered || !offered.available || !proposed.plugins?.['model-provider']?.includes(currentEntry.module) || canonicalJson(currentEntry) !== canonicalJson({ provider: offered.provider, model: offered.model, module: offered.module, upstream: offered.upstream })) throw new OrganizationError('model-provider-unavailable');
if (!offered.harnesses.includes(vars[harness])) throw new OrganizationError('model-harness-incompatible');
}
if (changed && currentEntry && !currentEntry.module && proposed.plugins?.['model-provider']?.length) {
if (routeChanged && currentEntry && !currentEntry.module && proposed.plugins?.['model-provider']?.length) {
if (catalogue?.status !== 'ready') throw new OrganizationError('model-provider-unavailable');
const mapping = catalogue.resolveLegacy ? catalogue.resolveLegacy(currentEntry) : catalogue.legacy?.[vars[model]];
const offered = mapping && catalogue.models?.find(m => m.module === mapping.module && m.upstream === mapping.upstream && m.model === currentEntry.model);
@@ -369,6 +372,10 @@ export function launchSettings({ dir, rolesDir, system, sessions = () => [], now
for (const [choice, prefix] of [[route, ''], [fallback, 'fallback.']]) {
if (!choice) continue;
const unchanged = ROUTE_FIELDS.every(field => priorVars[prefix + field] === choice[field]);
// D3 (P0-4): a choice that keeps the harness and model key needs no catalogue, as in
// checkRoutes; only its thinking level may differ. A key the catalogue offers as a
// different model still refuses model-key-collision below on any change.
const sameRoute = ['harness', 'model'].every(field => priorVars[prefix + field] === choice[field]);
const offered = catalogue?.models?.find(m => m.key === choice.model);
const existing = next.launch.models[choice.model];
const entry = offered ? { provider: offered.provider, model: offered.model, module: offered.module, upstream: offered.upstream } : null;
@@ -377,7 +384,7 @@ export function launchSettings({ dir, rolesDir, system, sessions = () => [], now
if (!existing && !offered) throw new OrganizationError('unknown-model', `${instance} variable ${prefix}model isn't a launch.models key`);
if (existing && offered && canonicalJson(existing) !== canonicalJson(entry) && !unchanged) throw new OrganizationError('model-key-collision');
if (!existing || existing.module) {
if (unchanged) continue;
if (sameRoute) continue;
if (catalogue?.status !== 'ready') throw new OrganizationError('model-catalogue-unavailable');
if (!offered || !offered.available || !Array.isArray(next.plugins?.['model-provider']) || !next.plugins['model-provider'].includes(offered.module)) throw new OrganizationError('model-provider-unavailable');
if (!offered.harnesses.includes(choice.harness)) throw new OrganizationError('model-harness-incompatible');
+23 -2
View File
@@ -50,9 +50,19 @@ async function readPrivate(file) {
async function privateDir(path, what = 'a directory') { const st = await fs.lstat(path).catch(rethrow(what)); if (!st.isDirectory() || st.isSymbolicLink() || st.uid !== process.getuid() || (st.mode & 0o077)) fail('org-mismatch', 'unsafe private directory', path); }
function validateRegistry(doc, file) {
if (!doc || doc.version !== 1 || typeof doc.instance !== 'string' || !doc.orgs || typeof doc.orgs !== 'object' || Array.isArray(doc.orgs)) fail('org-mismatch', 'invalid registry', file);
for (const [id, entry] of Object.entries(doc.orgs)) { idCheck(id); if (!entry || entry.id !== id || typeof entry.root !== 'string' || !['reserved', 'active', 'deleting', 'archived'].includes(entry.state) || !Number.isSafeInteger(entry.revision) || entry.revision < 1 || typeof entry.created !== 'string') fail('org-mismatch', 'invalid registry entry', file); }
for (const [id, entry] of Object.entries(doc.orgs)) { idCheck(id); if (!entry || entry.id !== id || typeof entry.root !== 'string' || !['reserved', 'active', 'deleting', 'archived'].includes(entry.state) || !Number.isSafeInteger(entry.revision) || entry.revision < 1 || typeof entry.created !== 'string' || (entry.incarnation !== undefined && (typeof entry.incarnation !== 'string' || !INCARNATION.test(entry.incarnation)))) fail('org-mismatch', 'invalid registry entry', file); }
return doc;
}
// Registry incarnation (CB0, #1873): 128 random bits minted when an id is
// reserved. A removed and recreated organization with the same id gets a new
// value, so metadata bound to the old one never matches. An entry reserved
// before this field existed has none; nothing binds to it (orgIncarnation
// returns null and every binding refuses).
export const INCARNATION = /^[0-9a-f]{32}$/;
export function orgIncarnation(registry, id) {
const value = registry?.orgs && Object.hasOwn(registry.orgs, id) ? registry.orgs[id]?.incarnation : undefined;
return typeof value === 'string' && INCARNATION.test(value) ? value : null;
}
export async function readRegistry({ configDir }) {
await safeDir(configDir, false, CONFIG); await privateDir(configDir, CONFIG); const file = join(configDir, 'org-runtime.json'); let doc;
try { doc = await readPrivate(file); } catch (e) { if (e.code === 'ENOENT') return null; if (e instanceof OrgError) throw e; fail('org-mismatch', `invalid registry: ${e instanceof SyntaxError ? e.message : quiet(e, 'registry file').message}`); }
@@ -126,6 +136,17 @@ export function withRegistryWriter(configDir, fn) {
return result;
} finally { if (!pending) release(); }
}
// An active entry reserved before CB0 has no incarnation. The first binding proposal for it mints
// one under the registry lock; no binding can exist before that, so none is orphaned. A removed and
// recreated id is reserved afresh and gets a new value. Returns null for any entry not active.
export function ensureOrgIncarnation(configDir, id) {
return withRegistryWriter(configDir, (registry, publish) => {
const entry = registry?.orgs && Object.hasOwn(registry.orgs, id) ? registry.orgs[id] : null;
if (!entry || entry.state !== 'active') return null;
if (orgIncarnation(registry, id) === null) { entry.incarnation = randomBytes(16).toString('hex'); publish(); }
return entry.incarnation;
});
}
export async function reserveOrg(ctx, id) {
const root = orgRoot(ctx.dataRoot, id);
return withRegistry(ctx, async registry => {
@@ -135,7 +156,7 @@ export async function reserveOrg(ctx, id) {
await safeDir(dirname(root), true, 'org-runtime under the data root'); await privateDir(dirname(root), 'org-runtime under the data root');
try { await fs.mkdir(root, { mode: 0o700 }); } catch (e) { if (e.code === 'EEXIST') fail('org-exists', 'organization root already exists', root); throw quiet(e, 'the organization root'); }
await fs.writeFile(join(root, '.mosaic-org.json'), JSON.stringify({ instance: ctx.instance, id }) + '\n', { mode: 0o600, flag: 'wx' }).catch(rethrow('the organization marker'));
const entry = { id, root, state: 'reserved', created: new Date().toISOString(), revision: 1 }; registry.orgs[id] = entry; return { ...entry };
const entry = { id, root, state: 'reserved', created: new Date().toISOString(), revision: 1, incarnation: randomBytes(16).toString('hex') }; registry.orgs[id] = entry; return { ...entry };
});
}
export async function resolveOrg(ctx, id) {
+5
View File
@@ -32,6 +32,11 @@ export const ROLE_RIGHTS = record({
// M5 must separately exclude credential.mint from decide and assignees from
// member task writes; these names alone cannot express action/field restrictions.
export const VERB_RIGHTS = record({
'autonomy.get.console': null,
'autonomy.set.console': null,
'autonomy.review.console': null,
'decision.confirm.console': null,
'decision.reverse.console': null,
'project.list': 'org.view',
names: 'org.view',
'thread.list': 'thread.read',
+4 -1
View File
@@ -9,7 +9,7 @@ import { ID_PATTERN } from './vocabulary.mjs';
import { OrgError, orgRoot, quiet, readRegistry, reserveOrg, resolveOrg, withRegistry, withOrgLifecycle } from './org-registry.mjs';
import { loadInstalled } from '../../plugins/src/loader.mjs';
import { BINDING_NAME } from '../../discord/src/binding.mjs';
import { parseJsonFileText } from './util.mjs';
import { canonicalJson, parseJsonFileText } from './util.mjs';
const rolesDir = resolve(dirname(fileURLToPath(import.meta.url)), '../../../roles');
const owner = Object.freeze({ provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner' });
const fail = (code, message, path) => { throw new OrgError(code, message, path); };
@@ -38,6 +38,9 @@ async function receipts(root) {
}
function normalizeDoc(ctx, id, input, existing) {
if (!input || input.id !== id) fail('org-invalid', 'document id must match organization id', '$.id');
// #1873 CB1: copy bindings change only through the owner-approved binding decision's writer.
// A create or a whole-document update that adds, edits or drops one is refused.
if (canonicalJson(input.copyBindings ?? null) !== canonicalJson(existing?.copyBindings ?? null)) fail('organization-bindings-protected', 'copyBindings change only through an owner-approved binding decision', '$.copyBindings');
const doc = structuredClone(input); const root = orgRoot(ctx.dataRoot, id);
for (const [project, entry] of Object.entries(doc.projects ?? {})) {
checkId(project); if (!entry || entry.kind === 'library') fail('org-invalid', 'configured projects must be Git projects', `$.projects.${project}`);
@@ -1,3 +1,4 @@
import { checkAutonomy } from './autonomy.mjs';
// The organization configuration file, still the v1 business file at
// <configDir>/businesses/<id>.json (REQ-ROLE-3, note section 2, addendum A
// sections 7 and 8; rename design r2 stage B: names only, same bytes). Human edits go through the trusted
@@ -19,6 +20,7 @@ import { checkMapping } from "../../library/src/mapping.mjs";
import { forkProblem, giteaName, parseGiteaUrl, HOST_SERVICE, CREATIVE_DEFINITIONS } from "./vocabulary.mjs";
import { checkAppearance } from "./appearance.mjs";
import { checkProjectAgents } from "./agent-levels.mjs";
import { validateCopyBindings } from "./copy-bindings.mjs";
import {
requireObject, rejectUnknownKeys, requireId, requirePositiveInt, requireString, requireDistinctList, readJsonFile, deepFreeze,
} from "./util.mjs";
@@ -27,7 +29,7 @@ const BOT_NAME = /^bot-[a-z0-9][a-z0-9._-]{0,60}$/;
// How long a thread's session stays up with no turn before the launcher stops
// it, when launch.threadIdleSeconds is absent (REQ-THREAD-2, row 121).
export const THREAD_IDLE_SECONDS = 900;
const TOP_KEYS = ["businessVersion", "id", "human", "arbiters", "projects", "vars", "tracker", "roles", "launch", "plugins", "hosts", "retired", "replyLimits", "usage", "appearance", "definitions"];
const TOP_KEYS = ["businessVersion", "id", "human", "arbiters", "projects", "vars", "tracker", "roles", "launch", "plugins", "hosts", "retired", "replyLimits", "usage", "appearance", "definitions", "autonomy", "copyBindings"];
// The config directory: next to the system config, $MOSAIC_CONFIG or
// ~/.config/mosaic-dev/config.json, the same rule mosaic-config.mjs uses.
@@ -343,6 +345,10 @@ export function validateOrganizationDocument(document, file, { rolesDir, dataRoo
refuse(`${file} tracker.sync must use its own bot, not the one roles.${instance} uses`);
}
}
// #1873 CB1: copy credential bindings. Each copy's tracker bot is unique against every named
// instance's bot, the tracker sync bot and the other copies.
validateCopyBindings(document.copyBindings, { trackers: [...Object.values(roles).map((r) => r.tracker).filter(Boolean), ...(tracker ? [tracker.sync] : [])] });
const copyBindings = document.copyBindings === undefined ? undefined : structuredClone(document.copyBindings);
const launch = document.launch === undefined ? null : checkLaunch(document.launch, roles, definitions, file);
// REQ-PLUG-1: the module names switched on per kind. The names are checked
// here; whether each module loads is the loader's check, at load time.
@@ -357,12 +363,21 @@ export function validateOrganizationDocument(document, file, { rolesDir, dataRoo
if (!hasModule("system-type", id) && installedNames !== null && !installedNames.has(id)) refuse(`${file} plugins.system-type names unknown module ${id}`);
}
const usage = document.usage === undefined ? undefined : checkUsageSettings(document.usage);
const autonomy = checkAutonomy(document.autonomy);
if(autonomy?.authorityResolver) {
const entry=roles[autonomy.authorityResolver],right='decision.resolve.authority';
if(!entry || !definitions[entry.definition].authority.withinRole.includes(right) ||
[vars,entry.vars].some(layer=>layer?.['limits.authority']!==undefined && !layer['limits.authority'].includes(right))) {
const e=new Error('invalid-autonomy');e.code='invalid-autonomy';throw e;
}
}
const appearance = checkAppearance(document.appearance, `${file} appearance`);
const retired = document.retired === undefined ? undefined : checkRetired(document.retired, roles, file);
return deepFreeze({
businessVersion: 1, id: document.id, file, human, arbiters, projects, vars, tracker, roles, launch, plugins, definitions,
...(retired ? { retired } : {}), ...(usage === undefined ? {} : { usage }), ...(appearance === undefined ? {} : { appearance }),
...(autonomy === undefined ? {} : { autonomy }), ...(retired ? { retired } : {}), ...(usage === undefined ? {} : { usage }), ...(appearance === undefined ? {} : { appearance }),
...(copyBindings === undefined ? {} : { copyBindings }),
});
}
+205 -10
View File
@@ -12,9 +12,10 @@ import { isDailyModelQuota } from "../../plugins/src/model-quota.mjs";
import { checkReplyLimits } from "../../library/src/limits.mjs";
import { ADDITION_VERB, additionProblem } from "./project-bootstrap.mjs";
import { remoteProblem, remoteTarget } from "./project-remotes.mjs";
import { withRegistryWriter } from "./org-registry.mjs";
import { orgIncarnation, withRegistryWriter } from "./org-registry.mjs";
import { allGranted, bindingText, definitionPin, samePin } from "./copy-bindings.mjs";
import { entryForm, projectAgentProblem, rootForProjectCheck } from "./agent-levels-write.mjs";
import { checkDefinition, checkDefinitionName, instancesNaming } from "./definitions.mjs";
import { checkDefinition, checkDefinitionName, instancesNaming, readTemplate } from "./definitions.mjs";
export class OrganizationError extends Error {
constructor(code, message = code) {
@@ -27,6 +28,15 @@ const fail = (code, message) => {
throw new OrganizationError(code, message);
};
// #1873 CB1: the capability only applyCopyBindingDecision holds. Every other commit refuses a change
// to `copyBindings`, whatever options its caller spreads in (write() passes its caller's options).
const BINDING_WRITE = Symbol("copy-binding-write");
const bindingsFence = (old, document, capability) => {
if (capability !== BINDING_WRITE && canonicalJson(document?.copyBindings ?? null) !== canonicalJson(old.copyBindings ?? null)) {
fail("organization-bindings-protected", "copyBindings change only through an owner-approved binding decision");
}
};
function regular(file) {
let fd;
try {
@@ -107,6 +117,12 @@ function usageFence(old, document, actor) {
fail("organization-usage-human-only");
}
function autonomyFence(old, document, actor) {
if(canonicalJson(old.autonomy)===canonicalJson(document.autonomy))return;
const agents=Object.entries(old.roles??{}).flatMap(([n,e])=>[n,`${e.definition}.${n}`]);
if(actor!==old.human || actor==='tester' || agents.includes(actor))fail('human-required');
}
function systemTypesFence(old, document, actor) {
if (canonicalJson(old.plugins?.['system-type'] ?? []) === canonicalJson(document?.plugins?.['system-type'] ?? [])) return;
const agents = Object.entries(old.roles ?? {}).flatMap(([name, role]) => [name, `${role?.definition}.${name}`]);
@@ -276,7 +292,7 @@ export function organizationWriter({ dir, rolesDir, dataRoot = null, barrier = (
// the old document, for a writer that builds what it publishes from what it read under the lock.
// #1873 A2: `pathsOnly` logs each change's path and presence without its values, for a verb whose
// values can hold a var a person typed (a secret pasted in the wrong place must not reach evidence).
function commit(id, document, { revision, actor, detail, context, beforeWrite = null, guard, pathsOnly = false }) {
function commit(id, document, { revision, actor, detail, context, beforeWrite = null, beforePublish = null, guard, pathsOnly = false, capability = null, incarnation = undefined }) {
requireId(actor, "authenticated actor");
requireId(id, "organization id");
let revoked = false;
@@ -291,23 +307,30 @@ export function organizationWriter({ dir, rolesDir, dataRoot = null, barrier = (
fail('org-mismatch', 'organization document and registry disagree');
}
if (revision !== before) fail("revision-conflict", "organization revision changed; read again");
// #1873 CB1: a binding names the registry incarnation it was approved in. A removed and
// recreated organization with the same id, or a legacy entry with none, never matches.
if (incarnation !== undefined && (!entry || orgIncarnation(registry, id) === null || orgIncarnation(registry, id) !== incarnation)) fail("decision-stale", "organization incarnation changed; raise the decision again");
const old = parseJsonFileText(oldBytes, file, "organization file");
if (typeof document === "function") document = document(old);
guard(old, document);
bindingsFence(old, document, capability);
replyLimitsFence(old, document, actor);
modelQuotaFence(old, document, actor);
usageFence(old, document, actor);
systemTypesFence(old, document, actor);
autonomyFence(old, document, actor);
validateBusinessDocument(document, file, { rolesDir, dataRoot });
const bytes = JSON.stringify(document, null, 2) + "\n";
// Check and log exactly what lands on disk, for every setter: a value JSON
// can't carry (a Date, a Map) serializes as something else (row 131, sage-0314).
const written = JSON.parse(bytes);
guard(old, written);
bindingsFence(old, written, capability);
replyLimitsFence(old, written, actor);
modelQuotaFence(old, written, actor);
usageFence(old, written, actor);
systemTypesFence(old, written, actor);
autonomyFence(old, written, actor);
validateBusinessDocument(written, file, { rolesDir, dataRoot });
if (beforeWrite) {
beforeWrite();
@@ -335,6 +358,13 @@ export function organizationWriter({ dir, rolesDir, dataRoot = null, barrier = (
rmSync(candidate);
fail("revision-conflict", "organization revision changed before publication");
}
// Pure admission check after candidate IO, immediately before publication.
// A refused prepared candidate is never rolled forward by recovery.
try { beforePublish?.(); } catch (error) {
log(id, { ...event, state: 'aborted' });
rmSync(candidate);
throw error;
}
renameSync(candidate, file);
syncDir(parent);
if (entry) { entry.digest = after; entry.revision++; publishRegistry(); }
@@ -358,7 +388,7 @@ export function organizationWriter({ dir, rolesDir, dataRoot = null, barrier = (
if (canonicalJson(document?.definitions ?? {}) !== canonicalJson(old.definitions ?? {})) fail("organization-definitions-protected");
};
function write(id, document, options) {
return commit(id, document, { ...options, guard: (old, next) => { projectsProtected(old, next); definitionsProtected(old, next); } });
return commit(id, document, { ...options, capability: null, incarnation: undefined, guard: (old, next) => { projectsProtected(old, next); definitionsProtected(old, next); } });
}
// #1873 T1: the only writer of `definitions`. `next(current)` returns the new entry without its
@@ -456,7 +486,7 @@ export function organizationWriter({ dir, rolesDir, dataRoot = null, barrier = (
const root = rootForProjectCheck(dir, rolesDir);
const guard = (old, document) => {
const agents = Object.entries(old.roles ?? {}).flatMap(([n, role]) => [n, `${role?.definition}.${n}`]);
if ((!approval && actor !== old.human) || actor === "tester" || agents.includes(actor)) fail("human-required");
if ((!approval && actor !== old.human) || actor === "tester" || (approval?.authorityDecision!==approval?.decision && agents.includes(actor))) fail("human-required");
if (entry === null && old.projects?.[project]?.agents?.[name] === undefined) fail("agent-not-found", `projects.${project}.agents.${name} does not exist`);
const checked = structuredClone(document);
if (approval?.approvedDefinition) {
@@ -476,16 +506,16 @@ export function organizationWriter({ dir, rolesDir, dataRoot = null, barrier = (
if (entry === null) delete agents[name]; else agents[name] = entry;
const { agents: _drop, ...rest } = target;
return { ...old, ...(approval?.approvedDefinition ? {definitions:{...(old.definitions??{}),[approval.approvedDefinition.name]:approval.approvedDefinition.entry}} : {}), projects: { ...old.projects, [project]: Object.keys(agents).length ? { ...rest, agents } : rest } };
}, { revision, actor, guard, beforeWrite:approval?.beforeWrite, pathsOnly: true, detail: { verb: "project.agents.set", project, agent: name, form: entryForm(entry), ...(approval?.decision ? {decision:approval.decision}: {}) } });
}, { revision, actor, guard, beforeWrite:approval?.beforeWrite, beforePublish:approval?.beforePublish, pathsOnly: true, detail: { verb: "project.agents.set", project, agent: name, form: entryForm(entry), ...(approval?.decision ? {decision:approval.decision}: {}) } });
}
// A4 trusted decision executor: one locked publication of the role, route and definition.
// The broker has consumed the matching digest before entering this host-only writer.
function applyHireDecision(id, proposal, { actor, assertClean, beforeWrite = null }) {
function applyHireDecision(id, proposal, { actor, assertClean, beforeWrite = null, beforePublish = null, authorityDecision = null }) {
const { request: r, definition, entry, revision } = proposal;
if (!proposal.id || r.level !== 'org') fail('invalid-request');
const build = old => {
if (actor === 'tester' || Object.entries(old.roles).some(([n,e]) => actor === n || actor === `${e.definition}.${n}`)) fail('human-required');
if (actor === 'tester' || (authorityDecision!==proposal.id && Object.entries(old.roles).some(([n,e]) => actor === n || actor === `${e.definition}.${n}`))) fail('human-required');
if ((old.launch?.keep ?? []).includes(r.name)) fail('instance-kept');
if ((old.retired ?? []).includes(r.name)) fail('organization-instance-retired');
if (r.name === old.launch?.by || Object.values(old.arbiters).includes(r.name)) fail('organization-instance-referenced');
@@ -511,12 +541,177 @@ export function organizationWriter({ dir, rolesDir, dataRoot = null, barrier = (
}
return next;
};
return commit(id, build, {revision,actor,beforeWrite,pathsOnly:true,guard:(old,next)=>{
return commit(id, build, {revision,actor,beforeWrite,beforePublish,pathsOnly:true,guard:(old,next)=>{
if(canonicalJson(next)!==canonicalJson(build(old)))fail('organization-definitions-only');
projectsProtected(old,next);
},context:{decision:proposal.id,digest:proposal.digest??null},detail:{verb:'hire.apply',decision:proposal.id,op:r.op,agent:r.name}});
}
// #1873 CB1 (design sections 3 and 4): the only writer of `copyBindings`, one copy per call. The
// broker has consumed the owner's approval of this exact digest before it gets here; the writer
// still refuses the tester, every agent identity and a caller that is not the owner, and rechecks
// under the locks the registry incarnation, the document revision, the copy's binding generation
// and copyId, and its definition pin. A bind writes `pending` (approved, not verified: nothing
// resolves or launches it until a verifier exists); an unbind writes the `unbound` tombstone. The
// record holds ids, scope names and integers only; the receipt holds paths only.
// `definitions` is the boot snapshot's merged definition map, for a root copy whose definition
// is not in this file.
function applyCopyBindingDecision(id, proposal, { actor, owner = false, definitions = {}, beforeWrite = null, beforePublish = null }) {
const { binding: b, revision } = proposal ?? {};
if (typeof proposal?.id !== "string" || proposal.kind !== "binding" || !b || typeof b.instance !== "string") fail("invalid-request");
const humanOnly = () => fail("human-required", bindingText("human-required"));
// Before the lock: no owner, no write. The agent identities are checked again under it.
if (owner !== true || actor === "tester") humanOnly();
const build = old => {
const agents = [
...Object.entries(old.roles ?? {}).flatMap(([n, e]) => [n, `${e?.definition}.${n}`]),
...Object.entries(old.projects ?? {}).flatMap(([p, v]) => Object.entries(v?.agents ?? {}).flatMap(([n, e]) => [n, `${n}.${p}`, `${e?.definition}.${n}.${p}`])),
...Object.keys(old.copyBindings ?? {}), ...Object.keys(definitions ?? {}), b.instance, b.source?.name,
];
if (owner !== true || actor === "tester" || agents.includes(actor)) humanOnly();
const current = old.copyBindings?.[b.instance];
if ((current?.generation ?? 0) !== b.generation || (current ? current.copyId !== b.copyId : b.fresh !== true)
|| (current && canonicalJson(current.source) !== canonicalJson(b.source))) fail("credential-binding-changed", bindingText("credential-binding-changed", Object.keys(b.after ?? b.before ?? {})[0] ?? null));
if (b.after === null && (!current || current.state === "unbound")) fail("credential-binding-changed", bindingText("credential-binding-changed"));
const pin = definitionPin(b.definition.ref, { definitions: { ...(definitions ?? {}), ...(old.definitions ?? {}) }, rolesDir, readTemplate });
if (!samePin(pin, b.definition)) fail("credential-role-changed", bindingText("credential-role-changed", Object.keys(b.after ?? b.before ?? {})[0] ?? null));
const record = {
copyId: b.copyId, orgIncarnation: b.orgIncarnation, source: structuredClone(b.source), definition: structuredClone(b.definition),
generation: b.generation + 1, state: b.after === null ? "unbound" : "pending",
services: b.after === null ? {} : structuredClone(b.after), decision: proposal.id,
};
return { ...structuredClone(old), copyBindings: { ...(old.copyBindings ?? {}), [b.instance]: record } };
};
return commit(id, build, {
revision, actor, beforeWrite, beforePublish, pathsOnly: true, capability: BINDING_WRITE, incarnation: b.orgIncarnation,
guard: (old, next) => {
if (canonicalJson(next) !== canonicalJson(build(old))) fail("organization-bindings-protected", "copyBindings change only through an owner-approved binding decision");
projectsProtected(old, next);
definitionsProtected(old, next);
},
context: { decision: proposal.id, digest: proposal.digest ?? null },
detail: { verb: b.after === null ? "binding.unbind" : "binding.apply", decision: proposal.id, agent: b.instance, generation: b.generation + 1 },
});
}
// #1873 CB2 (design section 5 steps 4 to 6): a copy's service credential is issued in two writes
// around the remote work; no lock is held across it. stageCopyGrant deactivates first: the record
// goes pending, the service loses its grantId and rotateBy (grantGeneration stays) and the binding
// generation counts up. Only then does the bus write the slot and ask the service. commitCopyGrant
// rechecks under the locks everything the stage pinned (registry incarnation, document revision,
// generation, copyId, decision, source and definition pin), runs `beforeWrite` (the bus's slot and
// session recheck), then writes the grant: grantGeneration counts up, and the record is `bound` once
// every service holds a grant. A refusal or a crash between the two leaves the record pending and
// nothing resolves it; recovery is a new verified issue. Owner only, as a binding decision.
// `op` is { instance, service, copyId, generation, decision, incarnation } as the bus read it.
function copyGrantFence(old, op, { actor, owner, definitions }) {
const { instance, service } = op;
const agents = [
...Object.entries(old.roles ?? {}).flatMap(([n, e]) => [n, `${e?.definition}.${n}`]),
...Object.entries(old.projects ?? {}).flatMap(([p, v]) => Object.entries(v?.agents ?? {}).flatMap(([n, e]) => [n, `${n}.${p}`, `${e?.definition}.${n}.${p}`])),
...Object.keys(old.copyBindings ?? {}), ...Object.keys(definitions ?? {}), ...(old.retired ?? []),
];
if (owner !== true || actor === "tester" || agents.includes(actor)) fail("human-required", bindingText("human-required"));
const current = old.copyBindings?.[instance];
if (!current || current.copyId !== op.copyId || current.generation !== op.generation || current.orgIncarnation !== op.incarnation
|| current.decision !== op.decision || current.state === "unbound" || !Object.hasOwn(current.services ?? {}, service)
|| Object.hasOwn(old.roles ?? {}, instance)) fail("credential-binding-changed", bindingText("credential-binding-changed", service));
const pin = definitionPin(current.definition.ref, { definitions: { ...(definitions ?? {}), ...(old.definitions ?? {}) }, rolesDir, readTemplate });
if (!samePin(pin, current.definition)) fail("credential-role-changed", bindingText("credential-role-changed", service));
return current;
}
const grantOp = (op) => {
if (!op || typeof op.instance !== "string" || typeof op.service !== "string" || !Number.isSafeInteger(op.generation)
|| typeof op.copyId !== "string" || typeof op.decision !== "string" || typeof op.incarnation !== "string") fail("invalid-request");
};
function stageCopyGrant(id, op, { revision, actor, owner = false, definitions = {} }) {
grantOp(op);
if (owner !== true || actor === "tester") fail("human-required", bindingText("human-required"));
const build = old => {
const current = copyGrantFence(old, op, { actor, owner, definitions });
const services = structuredClone(current.services);
delete services[op.service].grantId;
delete services[op.service].rotateBy;
const record = { ...structuredClone(current), generation: current.generation + 1, state: "pending", services };
return { ...structuredClone(old), copyBindings: { ...old.copyBindings, [op.instance]: record } };
};
const result = commit(id, build, {
revision, actor, pathsOnly: true, capability: BINDING_WRITE, incarnation: op.incarnation,
guard: (old, next) => {
if (canonicalJson(next) !== canonicalJson(build(old))) fail("organization-bindings-protected", "copyBindings change only through an owner-approved binding decision");
projectsProtected(old, next);
definitionsProtected(old, next);
},
detail: { verb: "binding.stage", agent: op.instance, kind: [op.service], generation: op.generation + 1 },
});
return { ...result, generation: op.generation + 1 };
}
function commitCopyGrant(id, op, { grantId, rotateBy, revision, actor, owner = false, definitions = {}, beforeWrite = null }) {
grantOp(op);
if (owner !== true || actor === "tester") fail("human-required", bindingText("human-required"));
const build = old => {
const current = copyGrantFence(old, op, { actor, owner, definitions });
if (current.state !== "pending" || current.services[op.service].grantId !== undefined) fail("credential-binding-changed", bindingText("credential-binding-changed", op.service));
// One grant per organization: another copy's or another service's grant id never moves here.
for (const [name, r] of Object.entries(old.copyBindings ?? {}))
for (const [service, v] of Object.entries(r?.services ?? {}))
if (v?.grantId === grantId && !(name === op.instance && service === op.service)) fail("credential-identity-refused", bindingText("credential-identity-refused", op.service));
const services = structuredClone(current.services);
services[op.service] = { ...services[op.service], rotateBy, grantId, grantGeneration: (services[op.service].grantGeneration ?? 0) + 1 };
const record = { ...structuredClone(current), generation: current.generation + 1, state: allGranted(services) ? "bound" : "pending", services };
return { ...structuredClone(old), copyBindings: { ...old.copyBindings, [op.instance]: record } };
};
let state = null;
const result = commit(id, (old) => { const next = build(old); state = next.copyBindings[op.instance].state; return next; }, {
revision, actor, beforeWrite, pathsOnly: true, capability: BINDING_WRITE, incarnation: op.incarnation,
guard: (old, next) => {
if (canonicalJson(next) !== canonicalJson(build(old))) fail("organization-bindings-protected", "copyBindings change only through an owner-approved binding decision");
projectsProtected(old, next);
definitionsProtected(old, next);
},
detail: { verb: "binding.grant", agent: op.instance, kind: [op.service], generation: op.generation + 1 },
});
return { ...result, generation: op.generation + 1, state };
}
// AU inverse, from an immutable private proposal. It touches only the named instance and
// definition; writer validation, fresh revision and owner fence still apply. Bindings never restore.
function reverseHireDecision(id,p,{actor,beforeWrite,beforePublish}) {
const {document:old,revision}=read(id),r=p.request,prior=p.prior;
if(actor!==old.human || !prior || r.level==='root')fail('human-required');
if(r.op==='hire')fail('invalid-request'); // Hire reverses through the stop/revoke/fire executor.
const build=current=>{
const live=r.level==='org'?current.roles[r.name]:current.projects[r.project]?.agents?.[r.name];
if(r.op==='fire') {
if(r.level==='project'?live!==undefined:current.launch.instances.includes(r.name))fail('decision-stale');
if(r.level==='org') {
const strip=x=>{const {credentials,tracker,...rest}=x??{};return rest;};
if(canonicalJson(strip(live))!==canonicalJson(strip(prior.instance)))fail('decision-stale');
}
if(prior.definition && canonicalJson(current.definitions?.[prior.instance.definition.slice(4)])!==canonicalJson(prior.definition))fail('decision-stale');
} else if(live?.hireDecision!==p.id)fail('decision-stale');
const next=structuredClone(current);
if(r.level==='project') {
const entries=next.projects[r.project].agents??{};
if(prior.instance)entries[r.name]=structuredClone(prior.instance);else delete entries[r.name];
delete entries[r.name]?.credentials;delete entries[r.name]?.tracker;
next.projects[r.project].agents=entries;
} else {
if(!prior.instance)fail('decision-stale');
next.roles[r.name]=structuredClone(prior.instance);
delete next.roles[r.name].credentials;delete next.roles[r.name].tracker;
next.launch.instances=next.launch.instances.filter(n=>n!==r.name);
if(prior.launched)next.launch.instances.push(r.name);
}
if(prior.definition) {
const name=prior.instance.definition.slice(4);
next.definitions={...(next.definitions??{}),[name]:{...prior.definition,rev:(current.definitions?.[name]?.rev??0)+1}};
}
return next;
};
return commit(id,build,{revision,actor,beforeWrite,beforePublish,pathsOnly:true,guard:(current,next)=>{if(actor!==current.human || canonicalJson(build(current))!==canonicalJson(next))fail('human-required');},detail:{verb:'hire.reverse',decision:p.id,agent:r.name}});
}
// Runs `fn({ revision, document })` under the writer lock, after recovery, and nothing else may
// write this business meanwhile. `fn` must be synchronous: the lock is a directory held for its
// duration, and an awaited call would leave a half-checked world to whoever finds the lock gone.
@@ -584,5 +779,5 @@ export function organizationWriter({ dir, rolesDir, dataRoot = null, barrier = (
});
}
return { logFile, read, readRaw, write, addProject, setProjectRemote, setProjectAgent, setDefinition, applyHireDecision, hold, holdAsync, findAddition, record, recover: id => withLock(id, () => recover(id)) };
return { logFile, read, readRaw, write, addProject, setProjectRemote, setProjectAgent, setDefinition, applyHireDecision, reverseHireDecision, applyCopyBindingDecision, stageCopyGrant, commitCopyGrant, hold, holdAsync, findAddition, record, recover: id => withLock(id, () => recover(id)) };
}
+5 -8
View File
@@ -1,15 +1,12 @@
// Caller-only descriptions, shared by launch and live bus reads. Inputs are validated policy,
// never credential references, vars, call arguments or file contents.
import { decisionAutonomy } from './autonomy.mjs';
export { decisionAutonomy } from './autonomy.mjs';
import { GATED_ONLY } from './vocabulary.mjs';
const DOMAINS = Object.freeze({ 'git.merge.protected': 'technical', 'policy.change': 'delivery' });
// TI extension point for AU: today's fixed levels, without changing any routing.
export function decisionAutonomy(decisionClass) {
return ['cross-role', 'merge', 'policy'].includes(decisionClass) ? 'out of the loop' : 'in the loop';
}
export function ownRails({ instance, authority, network, credentials = [], messaging = null, arbiters = {}, launcher = null }) {
export function ownRails({ instance, authority, network, credentials = [], messaging = null, arbiters = {}, launcher = null, autonomy }) {
const actions = [];
for (const [kind, held] of Object.entries(authority ?? {})) {
for (const action of held) {
@@ -24,8 +21,8 @@ export function ownRails({ instance, authority, network, credentials = [], messa
const resolve = [...(authority?.withinRole ?? []), ...(authority?.crossRole ?? [])]
.filter(action => action.startsWith('decision.resolve.')).map(right => {
const domain = right.slice('decision.resolve.'.length);
const classes = domain === 'technical' ? ['cross-role', 'merge'] : domain === 'delivery' ? ['cross-role', 'policy'] : [];
return { right, classes: classes.map(name => ({ name, level: decisionAutonomy(name) })) };
const classes = domain === 'technical' ? ['cross-role', 'merge'] : domain === 'delivery' ? ['cross-role', 'policy'] : domain === 'authority' ? ['authority'] : [];
return { right, classes: classes.map(name => ({ name, level: decisionAutonomy(name, autonomy) })) };
});
return {
actions, resolve, network: network ?? 'unknown',
+1 -1
View File
@@ -101,7 +101,7 @@ export function resolveInstance({ system, business, project = null, instance, en
limits: { tools, network, authority: { withinRole, crossRole } },
// Row 210 (#1707, design D5): a host key is used only by the bus child, never by the session,
// so it stays out of the session's credentials and the harness's needs.
rails: ownRails({ instance, authority: { withinRole, crossRole }, network, credentials: definition.credentials, messaging: definition.messaging, arbiters: business.arbiters, launcher: business.launch?.by }),
rails: ownRails({ instance, authority: { withinRole, crossRole }, network, credentials: definition.credentials, messaging: definition.messaging, arbiters: business.arbiters, launcher: business.launch?.by, autonomy: business.autonomy }),
credentials: Object.fromEntries(Object.entries(entry.credentials).filter(([kind]) => !isHostKind(kind))),
tracker: entry.tracker,
launch,
+2
View File
@@ -60,6 +60,7 @@ export const ACTIONS = Object.freeze([
"prd.approve",
"decision.resolve.technical",
"decision.resolve.delivery",
"decision.resolve.authority",
// Row 285 (C2-08b, #1790): a creative agent writes a supporting Markdown file in the human's library.
// Added as a hard wait and routed under PRD 1.16 REQ-WAIT-3 and REQ-LIB-6; role.mjs grants it within-role
// to the creative roles only.
@@ -96,6 +97,7 @@ export const ROUTED = Object.freeze([
"prd.approve",
"decision.resolve.technical",
"decision.resolve.delivery",
"decision.resolve.authority",
// PRD 1.7 REQ-COS-3 (row 194, #1692): the chief's hire and fire, within the bounds the broker checks.
"role.hire",
"role.fire",
@@ -0,0 +1,65 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {randomUUID} from 'node:crypto';
import {rmSync} from 'node:fs';
import {join} from 'node:path';
import {tmp,rolesCopy,businessDoc,writeJson} from './helpers.mjs';
import {organizationWriter} from '../src/organization.mjs';
import {hireDecisions} from '../src/hire-decisions.mjs';
import {instanceSettings} from '../src/instance-settings.mjs';
const role={tools:['read'],network:'none',authority:{withinRole:[],crossRole:[]},credentials:[],instructions:'Prior role'};
function setup(t, scanDefinition, barrier) {
const root=tmp();t.after(()=>rmSync(root,{recursive:true,force:true}));const rolesDir=rolesCopy(root),doc=businessDoc(root);
doc.plugins={harness:['pi']};
doc.definitions={scout:{rev:3,role:{tools:role.tools,network:role.network,authority:role.authority,credentials:[]},instructions:role.instructions}};
doc.roles.scout={definition:'org:scout',vars:{harness:'pi',model:'claude-opus-5-5'}};doc.launch.instances.push('scout');
writeJson(join(root,'businesses/acme.json'),doc);
const writer=organizationWriter({dir:root,rolesDir,barrier}),service=hireDecisions({writer,rolesDir,configDir:root,dataRoot:join(root,'data'),assertClean:()=>{},scanDefinition});
let stopped=0,revoked=0;const instances=instanceSettings({dir:root,rolesDir,writer,revoke:()=>{revoked++;}});
const prepare=async request=>{const p=await service.prepare('acme',Object.fromEntries(Object.entries({op:'change',level:'org',name:'scout',role:{inline:{...role,instructions:'Changed role'}},route:{harness:'pi',model:'claude-opus-5-5'},reason:'Test',...request}).filter(([,v])=>v!==undefined)),{id:randomUUID(),expiresAt:'2099-01-01T00:00:00Z'});return p;};
const caller=p=>({agent:false,rights:['org.hire'],owner:true,instanceOwner:true,actor:'jason',digest:p.digest,subject:{provider:'test',iss:'test',sub:'owner'}});
const reverse=p=>service.reverse('acme',p.id,{actor:'jason',beforeWrite(){},stop:async()=>{stopped++;return {running:true};},fire:proposal=>instances.fire('acme',{name:proposal.name,revision:proposal.revision},{actor:'jason',context:{decision:proposal.id}})});
return {writer,service,prepare,caller,reverse,stopped:()=>stopped,revoked:()=>revoked};
}
test('AU role-change inverse restores prior definition contents and requests relaunch of a running agent',async t=>{
const f=setup(t),p=await f.prepare({});await f.service.apply('acme',p.id,f.caller(p));
const r=await f.reverse(p),doc=f.writer.read('acme').document;
assert.equal(doc.definitions.scout.instructions,'Prior role');assert.equal(doc.definitions.scout.rev,5);assert.equal(r.relaunch.name,'scout');assert.equal(f.stopped(),1);
});
test('AU fire inverse rehires prior definition without restoring revoked bindings',async t=>{
const f=setup(t),p=await f.prepare({op:'fire',role:undefined,route:undefined});await f.service.apply('acme',p.id,f.caller(p));
assert.ok(!f.writer.read('acme').document.launch.instances.includes('scout'));
await f.reverse(p);const doc=f.writer.read('acme').document;
assert.ok(doc.launch.instances.includes('scout'));assert.equal(doc.definitions.scout.instructions,'Prior role');assert.equal(doc.roles.scout.credentials,undefined);
});
test('AU hire inverse uses the normal fire/revoke executor',async t=>{
const f=setup(t),p=await f.prepare({op:'hire',name:'new-scout'});await f.service.apply('acme',p.id,f.caller(p));
await f.reverse(p);assert.ok(!f.writer.read('acme').document.launch.instances.includes('new-scout'));assert.equal(f.revoked(),1);
});
test('AU inverse never overwrites a subsequent role change or relaunched fired agent',async t=>{
const f=setup(t),p=await f.prepare({});await f.service.apply('acme',p.id,f.caller(p));
const later=await f.prepare({role:{inline:{...role,instructions:'Later'}}});await f.service.apply('acme',later.id,f.caller(later));
await assert.rejects(f.reverse(p),{code:'decision-stale'});assert.equal(f.stopped(),0);
const fire=await f.prepare({op:'fire',role:undefined,route:undefined});await f.service.apply('acme',fire.id,f.caller(fire));
const old=f.writer.read('acme');f.writer.write('acme',{...old.document,launch:{...old.document.launch,instances:[...old.document.launch.instances,'scout']}},{revision:old.revision,actor:'jason'});
await assert.rejects(f.reverse(fire),{code:'decision-stale'});
});
test('AU inverse scans prior definition against current held credentials before stopping or writing',async t=>{
let refuse=false;
const f=setup(t,()=>{if(refuse)throw Object.assign(new Error('definition-holds-credential'),{code:'definition-holds-credential'});});
const p=await f.prepare({});await f.service.apply('acme',p.id,f.caller(p));
const before=f.writer.read('acme').revision;refuse=true;
await assert.rejects(f.reverse(p),{code:'definition-holds-credential'});
assert.equal(f.stopped(),0);assert.equal(f.writer.read('acme').revision,before);
});
test('AU inverse crossing its window during candidate IO never publishes or recovers forward',async t=>{
let reversing=false,expired=false;
const f=setup(t,undefined,point=>{if(reversing && point==='prepared')expired=true;});
const p=await f.prepare({});await f.service.apply('acme',p.id,f.caller(p));
const prior=f.writer.read('acme');reversing=true;
await assert.rejects(f.service.reverse('acme',p.id,{actor:'jason',beforeWrite(){if(expired)throw Object.assign(new Error('reverse-window-closed'),{code:'reverse-window-closed'});},stop:async()=>({running:true})}),{code:'reverse-window-closed'});
assert.equal(f.writer.read('acme').revision,prior.revision);
f.writer.recover('acme');assert.equal(f.writer.read('acme').revision,prior.revision);
});
@@ -0,0 +1,62 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { decisionAutonomy } from '../src/rails.mjs';
import { VERB_RIGHTS } from '../src/org-rights.mjs';
test('AU absent key preserves fixed levels; present per-class levels are read', () => {
for (const name of ['merge', 'policy', 'cross-role']) assert.equal(decisionAutonomy(name), 'out of the loop');
assert.equal(decisionAutonomy('authority'), 'in the loop');
assert.equal(decisionAutonomy('merge', { merge: 'near the loop' }), 'near the loop');
});
test('AU unknown class or level refuses instead of inventing a default', () => {
assert.throws(() => decisionAutonomy('unknown'), { code: 'invalid-autonomy' });
assert.throws(() => decisionAutonomy('merge', { merge: 'automatic' }), { code: 'invalid-autonomy' });
});
test('AU owner-only Console verbs have explicit null rights', () => {
for (const verb of ['autonomy.get.console','autonomy.set.console','decision.confirm.console','decision.reverse.console']) assert.equal(VERB_RIGHTS[verb], null, verb);
});
import { tmp, rolesCopy, businessDoc, writeJson } from './helpers.mjs';
import { rmSync } from 'node:fs';
import { join } from 'node:path';
import { validateBusinessDocument } from '../src/organization-config.mjs';
import { organizationWriter } from '../src/organization.mjs';
test('AU setting validates in the organization file; every writer refuses non-owner edits',t=>{
const root=tmp();t.after(()=>rmSync(root,{recursive:true,force:true}));
const rolesDir=rolesCopy(root),doc=businessDoc(root);doc.autonomy={merge:'in the loop'};
assert.equal(validateBusinessDocument(doc,join(root,'acme.json'),{rolesDir}).autonomy.merge,'in the loop');
for(const autonomy of [{unknown:'in the loop'},{merge:'fast'},{deploy:'out of the loop'}])assert.throws(()=>validateBusinessDocument({...doc,autonomy},join(root,'acme.json'),{rolesDir}),{code:'invalid-autonomy'});
writeJson(join(root,'businesses/acme.json'),doc);
const writer=organizationWriter({dir:root,rolesDir}),old=writer.read('acme');
for(const actor of ['coder','tester','member'])assert.throws(()=>writer.write('acme',{...doc,autonomy:{merge:'out of the loop'}},{revision:old.revision,actor}),{code:'human-required'});
});
import {authorityBudgetCovered} from '../src/autonomy.mjs';
test('AU authority budgets cover all selectable models and only enforced account harnesses',()=>{
const cap={limit:0,since:'2026-01-01T00:00:00.000Z'};
const b={plugins:{harness:['pi'],'limit-check':['token-limit']},launch:{models:{one:{provider:'one',model:'m'},two:{provider:'two',model:'n'}},limits:{tokens:{providers:{one:cap}}}}};
assert.equal(authorityBudgetCovered(b),false);
b.launch.limits.tokens.models={two:{n:cap}};assert.equal(authorityBudgetCovered(b),true);
b.plugins['limit-check']=[];assert.equal(authorityBudgetCovered(b),false);
b.launch.account={name:'shared'};b.launch.limits.tokens={keys:{shared:cap}};b.plugins['limit-check']=['token-limit'];
// What today's two harness modules declare (packages/plugins/harness/*/index.mjs countsAccount).
const declared={countsAccount:h=>({'claude-code':true,pi:false})[h]??null};
assert.equal(authorityBudgetCovered(b,declared),false);
b.plugins.harness=['claude-code'];assert.equal(authorityBudgetCovered(b,declared),true);
b.plugins.harness.push('pi');assert.equal(authorityBudgetCovered(b,declared),false);
b.plugins.harness=['claude-code'];b.plugins['limit-check']=['daily-quota'];b.launch.limits.tokens={daily:{shared:{...cap,reset:'00:00',timeZone:'UTC'}}};assert.equal(authorityBudgetCovered(b,declared),true);
});
test('AU an account quota covers authority only when every enabled harness declares it spends the launch account (#1873)',()=>{
const cap={limit:0,since:'2026-01-01T00:00:00.000Z'};
const b={plugins:{harness:['alpha','beta'],'limit-check':['token-limit']},launch:{account:{name:'shared'},models:{one:{provider:'one',model:'m'}},limits:{tokens:{keys:{shared:cap}}}}};
const declares=map=>({countsAccount:h=>map[h]??null});
// The declared fact decides, whatever the harness is called.
assert.equal(authorityBudgetCovered(b,declares({alpha:true,beta:true})),true);
assert.equal(authorityBudgetCovered(b,declares({alpha:true,beta:false})),false);
assert.equal(authorityBudgetCovered(b,declares({alpha:true})),false,'an undeclared harness is not covered');
assert.equal(authorityBudgetCovered(b,declares({alpha:true,beta:'yes'})),false,'only true counts');
b.plugins.harness=['claude-code'];
assert.equal(authorityBudgetCovered(b,declares({'claude-code':false})),false,'the name alone covers nothing');
assert.equal(authorityBudgetCovered(b),false,'no lookup: fail closed');
// The provider/model branch needs no harness fact.
b.launch.limits.tokens.providers={one:cap};assert.equal(authorityBudgetCovered(b),true);
});
@@ -0,0 +1,480 @@
// #1873 copy credential binding, slices CB0 and CB1 (design
// docs/plans/2026-11-11_copy-credential-binding-design.md section 8, rows CB0 and CB1).
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { createHash } from 'node:crypto';
import { hireDecisions, authorizeHire } from '../src/hire-decisions.mjs';
import { organizationWriter } from '../src/organization.mjs';
import { checkRootAgents } from '../src/agent-levels.mjs';
import { copyRefusal, loadWithCopies, materializeCopies } from '../src/agent-copies.mjs';
import { readTemplate } from '../src/definitions.mjs';
import { validateBusinessDocument } from '../src/organization-config.mjs';
import { activateOrg, ensureOrgIncarnation, orgIncarnation, orgRoot, readRegistry, reserveOrg, withRegistryWriter } from '../src/org-registry.mjs';
import { definitionPin, intersectScopes, resolveCopyBinding, validateCopyBindings } from '../src/copy-bindings.mjs';
import { businessDoc, rolesCopy, writeJson } from './helpers.mjs';
import { createOrg, showOrg, templateRender, updateOrg } from '../src/org-service.mjs';
const SECRET = 'sk-test-NOT-A-SECRET-1234';
const OWNER = { actor: 'jason', agent: false, rights: ['org.hire'], owner: true, instanceOwner: true, subject: { provider: 'test', iss: 'https://test', sub: 'owner' } };
const FUTURE = '2099-01-01T00:00:00.000Z';
const ID1 = '11111111-1111-4111-8111-111111111111', ID2 = '22222222-2222-4222-8222-222222222222', ID3 = '33333333-3333-4333-8333-333333333333';
const GITEA = { gitea: { scopes: ['read:repository'], projects: ['stack'] } };
const sha = b => createHash('sha256').update(b).digest('hex');
// ---- CB0: pure resolver fixtures ----
const rolesDir = join(new URL('../../..', import.meta.url).pathname, 'roles');
const rootOf = agents => ({ state: 'ok', ...checkRootAgents({ agentsVersion: 1, agents }, { rolesDir }) });
const reviewer = () => JSON.parse(readFileSync(join(rolesDir, 'reviewer.json'), 'utf8'));
const pinOf = (ref, definitions = {}) => definitionPin(ref, { definitions, rolesDir, readTemplate });
const INC_A = 'a'.repeat(32), INC_B = 'b'.repeat(32);
const docFor = (id, edit = d => d) => edit({ id, roles: {}, projects: { stack: {}, web: {} } });
const record = (over = {}) => ({
copyId: 'c'.repeat(32), orgIncarnation: INC_A, source: { level: 'root', name: 'rev', project: null },
definition: pinOf('reviewer'), generation: 2, state: 'bound',
services: { gitea: { slot: 'self', scopes: ['read:repository', 'write:issue'], resources: { projects: ['stack'] } },
vikunja: { slot: 'self', scopes: { tasks: ['read_one'] }, resources: { projects: ['stack'] } } },
decision: ID1, ...over,
});
const resolve = (over = {}) => {
const snapshot = over.snapshot ?? materializeCopies({ orgId: 'acme', doc: docFor('acme'), root: rootOf({ rev: { definition: 'reviewer' } }), rolesDir });
return resolveCopyBinding({ orgId: 'acme', incarnation: INC_A, snapshot, instance: 'rev', record: record(), pin: pinOf('reviewer'), role: reviewer(), projects: ['stack', 'web'], ...over });
};
test('CB0 a bound record resolves to effective = role intersect approved, only on admitted projects', () => {
const r = resolve();
assert.equal(r.ok, true);
// The role declares write:issue, write:repository and read:user; the owner approved less.
assert.deepEqual(r.services.gitea, { scopes: ['write:issue', 'read:repository'], projects: ['stack'] });
assert.deepEqual(r.services.vikunja, { scopes: { tasks: ['read_one'] }, projects: ['stack'] });
assert.deepEqual(intersectScopes('gitea', ['write:repository', 'read:issue'], ['read:repository', 'write:issue']), ['read:issue', 'read:repository']);
// A project no longer admitted drops out of the reach; none left, no binding.
assert.equal(resolve({ projects: ['web'] }).code, 'credential-unbound');
// Approved broader than the role (the role narrowed): never widened, refused.
const wide = record(); wide.services.gitea.scopes = ['write:admin'];
assert.equal(resolve({ record: wide }).ok, false);
const extra = record(); extra.services.gitea.scopes = ['write:repository', 'write:organization'];
assert.deepEqual([resolve({ record: extra }).code, resolve({ record: extra }).service], ['credential-scope-exceeded', 'gitea']);
});
test('CB0 pending, partial and tombstoned records never resolve', () => {
assert.equal(resolve({ record: record({ state: 'pending' }) }).code, 'credential-unbound');
assert.equal(resolve({ record: record({ state: 'unbound', services: {} }) }).code, 'credential-unbound');
const partial = record(); delete partial.services.vikunja;
assert.deepEqual([resolve({ record: partial }).code, resolve({ record: partial }).service], ['credential-unbound', 'vikunja']);
assert.equal(resolve({ record: undefined }).code, 'credential-unbound');
});
test('CB0 identical root copies in two organizations never share a binding', () => {
const root = rootOf({ rev: { definition: 'reviewer' } });
const a = materializeCopies({ orgId: 'acme', doc: docFor('acme'), root, rolesDir });
const b = materializeCopies({ orgId: 'beta', doc: docFor('beta'), root, rolesDir });
assert.deepEqual({ ...a.copies.rev }, { ...b.copies.rev }, 'the same copy shape in both');
assert.equal(resolve({ snapshot: a }).ok, true);
// A's record copied into B carries A's incarnation.
assert.equal(resolveCopyBinding({ orgId: 'beta', incarnation: INC_B, snapshot: b, instance: 'rev', record: record(), pin: pinOf('reviewer'), role: reviewer(), projects: ['stack'] }).code, 'credential-unbound');
// A's snapshot never answers for B.
assert.equal(resolveCopyBinding({ orgId: 'beta', incarnation: INC_A, snapshot: a, instance: 'rev', record: record(), pin: pinOf('reviewer'), role: reviewer(), projects: ['stack'] }).code, 'copy-unavailable');
});
test('CB0 project dotted-name collisions never attach a record', () => {
const root = rootOf({});
// project "a.b" agent "c" and project "b" agent "c.a" both make instance c.a.b: both refuse.
const both = materializeCopies({ orgId: 'acme', doc: docFor('acme', d => ({ ...d, projects: { 'a.b': { agents: { c: { definition: 'reviewer' } } }, b: { agents: { 'c.a': { definition: 'reviewer' } } } } })), root, rolesDir });
assert.equal(both.copies['c.a.b'].state, 'refused');
const old = record({ source: { level: 'project', name: 'c.a', project: 'b' }, services: { gitea: { slot: 'self', scopes: ['read:repository'], resources: { projects: ['b'] } } } });
const args = { orgId: 'acme', incarnation: INC_A, instance: 'c.a.b', record: old, pin: pinOf('reviewer'), role: reviewer(), projects: ['a.b', 'b'] };
assert.equal(resolveCopyBinding({ ...args, snapshot: both }).code, 'copy-refused');
// Project b drops its agent; project a.b's "c" now owns c.a.b alone. b's record never attaches.
const one = materializeCopies({ orgId: 'acme', doc: docFor('acme', d => ({ ...d, projects: { 'a.b': { agents: { c: { definition: 'reviewer' } } }, b: {} } })), root, rolesDir });
assert.equal(one.copies['c.a.b'].state, 'unbound');
assert.equal(resolveCopyBinding({ ...args, snapshot: one, record: { ...old, state: 'bound' } }).code, 'credential-unbound');
// A root agent named like a project copy: the root record never attaches to the project copy.
const proj = materializeCopies({ orgId: 'acme', doc: docFor('acme', d => ({ ...d, projects: { stack: { agents: { checker: { definition: 'reviewer' } } } } })), root, rolesDir });
const rootRecord = record({ source: { level: 'root', name: 'checker.stack', project: null } });
assert.equal(resolveCopyBinding({ ...args, instance: 'checker.stack', snapshot: proj, record: rootRecord, projects: ['stack'] }).code, 'credential-unbound');
});
test('CB0 an organization instance shadows the copy; its record never resolves', () => {
const snapshot = materializeCopies({ orgId: 'acme', doc: docFor('acme', d => ({ ...d, roles: { rev: { definition: 'reviewer' } } })), root: rootOf({ rev: { definition: 'reviewer' } }), rolesDir });
assert.equal(Object.hasOwn(snapshot.copies, 'rev'), false);
assert.equal(resolve({ snapshot }).code, 'copy-unavailable');
});
test('CB0 a removed and recreated organization has a new incarnation; the old record never resolves', async t => {
const base = mkdtempSync(join(tmpdir(), 'cb0-')); t.after(() => rmSync(base, { recursive: true, force: true }));
const c = { configDir: join(base, 'config'), dataRoot: join(base, 'data'), instance: 'test' };
mkdirSync(c.configDir, { mode: 0o700 }); mkdirSync(c.dataRoot, { mode: 0o700 });
const first = await reserveOrg(c, 'acme');
assert.match(first.incarnation, /^[0-9a-f]{32}$/);
assert.notEqual((await reserveOrg(c, 'beta')).incarnation, first.incarnation);
withRegistryWriter(c.configDir, (registry, publish) => { delete registry.orgs.acme; publish(); });
rmSync(orgRoot(c.dataRoot, 'acme'), { recursive: true });
const again = await reserveOrg(c, 'acme');
assert.notEqual(again.incarnation, first.incarnation);
assert.equal(orgIncarnation(await readRegistry(c), 'acme'), again.incarnation);
assert.equal(resolve({ incarnation: again.incarnation, record: record({ orgIncarnation: first.incarnation }) }).code, 'credential-unbound');
assert.equal(resolve({ incarnation: null }).code, 'credential-unbound', 'no incarnation, no binding');
// An invalid incarnation in the registry refuses the registry.
withRegistryWriter(c.configDir, (registry, publish) => { registry.orgs.acme.incarnation = 'x'; publish(); });
await assert.rejects(readRegistry(c), { code: 'org-mismatch' });
});
test('CB0 a reused source name with another definition or level never inherits a record', () => {
// Root "rev" was a reviewer; it is removed and a new root "rev" is a coder.
const snapshot = materializeCopies({ orgId: 'acme', doc: docFor('acme'), root: rootOf({ rev: { definition: 'coder' } }), rolesDir });
const coder = JSON.parse(readFileSync(join(rolesDir, 'coder.json'), 'utf8'));
assert.equal(resolve({ snapshot, pin: pinOf('coder'), role: coder }).code, 'credential-role-changed');
// An org definition changed in place (same name, new rev or text) changes the pin.
const defs = { d: { rev: 1, role: { template: 'reviewer' } } };
const before = pinOf('org:d', defs), after = pinOf('org:d', { d: { ...defs.d, instructions: 'other' } });
assert.ok(before && after && before.sha256 !== after.sha256);
// The copyId is kept only with its own record: a record for another copyId does not move.
assert.equal(resolve({ record: record({ source: { level: 'project', name: 'rev', project: 'stack' } }) }).code, 'credential-unbound');
});
test('CB0 copyBindings schema: ids and scope names only, every refusal positional', () => {
const ok = { rev: record() };
assert.doesNotThrow(() => validateCopyBindings(ok));
const cases = [
{ rev: { ...record(), token: SECRET } },
{ rev: { ...record(), services: { gitea: { ...record().services.gitea, file: '/x/token' } } } },
{ rev: { ...record(), services: { gitea: { ...record().services.gitea, slot: '/srv/token' } } } },
{ rev: { ...record(), copyId: SECRET } },
{ rev: { ...record(), orgIncarnation: 'z' } },
{ rev: { ...record(), generation: 0 } },
{ rev: { ...record(), state: 'active' } },
{ rev: { ...record(), source: { level: 'root', name: 'other', project: null } } },
{ rev: { ...record(), state: 'unbound' } },
{ rev: record(), other: { ...record(), source: { level: 'root', name: 'other', project: null } } },
];
for (const value of cases) {
assert.throws(() => validateCopyBindings(value), e => e.name === 'BusinessError' && !e.message.includes(SECRET) && !e.message.includes('/'));
}
});
// ---- CB1: decision subtype, guarded writer ----
function setup(t, { incarnation = true, edit = d => d } = {}) {
const root = mkdtempSync(join(tmpdir(), 'cb1-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const roles = rolesCopy(root);
const doc = { ...businessDoc(root, 'demo'), plugins: { harness: ['pi'] } };
doc.projects.stack.agents = { checker: { definition: 'reviewer' } };
edit(doc);
const file = writeJson(join(root, 'businesses/demo.json'), doc);
const dataRoot = join(root, 'data'); mkdirSync(dataRoot, { mode: 0o700 });
const ctx = { configDir: root, dataRoot, instance: 'test' };
const ready = (async () => {
await reserveOrg(ctx, 'demo'); await activateOrg(ctx, 'demo');
if (!incarnation) withRegistryWriter(root, (registry, publish) => { delete registry.orgs.demo.incarnation; publish(); });
})();
const snapshot = materializeCopies({ orgId: 'demo', doc, root: { state: 'ok', ...checkRootAgents({ agentsVersion: 1, agents: { rev: { definition: 'reviewer' } } }, { rolesDir: roles }) }, rolesDir: roles });
const writer = organizationWriter({ dir: root, rolesDir: roles });
const service = hireDecisions({ writer, rolesDir: roles, configDir: root, dataRoot, assertClean: v => { if (JSON.stringify(v).includes(SECRET)) throw Object.assign(new Error('x'), { code: 'definition-holds-credential' }); }, copies: id => snapshot.org === id ? snapshot : null });
const bind = (services = GITEA, extra = {}) => ({ op: 'change', level: 'root', name: 'rev', reason: 'Bind the reviewer copy', binding: { services }, ...extra });
const prepare = async (input = bind(), id = ID1, expiresAt = FUTURE) => { await ready; return service.prepare('demo', input, { id, expiresAt }); };
const bytes = () => readFileSync(file);
return { root, roles, file, writer, service, snapshot, bind, prepare, bytes, ready, ctx, dataRoot };
}
test('CB1 owner binds and unbinds; the record holds ids only and stays pending; receipts are paths only', async t => {
const f = setup(t);
const p = await f.prepare();
assert.equal(p.binding.instance, 'rev');
assert.deepEqual(p.credentialLines, [{ service: 'gitea', scope: 'read:repository' }]);
await f.service.apply('demo', p.id, { ...OWNER, digest: p.digest });
const rec = f.writer.read('demo').document.copyBindings.rev;
assert.equal(rec.state, 'pending');
assert.equal(rec.generation, 1);
assert.deepEqual(rec.services, { gitea: { slot: 'self', scopes: ['read:repository'], resources: { projects: ['stack'] } } });
assert.deepEqual(rec.source, { level: 'root', name: 'rev', project: null });
assert.equal(rec.orgIncarnation, orgIncarnation(await readRegistry(f.ctx), 'demo'));
assert.deepEqual(rec.definition, definitionPin('reviewer', { rolesDir: f.roles, readTemplate }));
assert.equal(rec.decision, p.id);
const text = JSON.stringify(rec);
for (const bad of [SECRET, f.root, '/', 'token']) assert.ok(!text.includes(bad), bad);
const log = readFileSync(join(f.root, 'businesses/.history/demo/changes.jsonl'), 'utf8');
assert.ok(log.includes(p.id)); assert.ok(log.includes('binding.apply'));
assert.ok(!log.includes('read:repository'), 'paths only: the receipt holds no scope value');
// The owner unbinds through the same decision kind; a tombstone stays with the same copyId.
const u = await f.prepare(f.bind(null), ID2);
await f.service.apply('demo', u.id, { ...OWNER, digest: u.digest });
const tomb = f.writer.read('demo').document.copyBindings.rev;
assert.deepEqual({ state: tomb.state, services: tomb.services, generation: tomb.generation, copyId: tomb.copyId }, { state: 'unbound', services: {}, generation: 2, copyId: rec.copyId });
assert.ok(readFileSync(join(f.root, 'businesses/.history/demo/changes.jsonl'), 'utf8').includes('binding.unbind'));
// Unbinding a tombstone refuses.
await assert.rejects(f.prepare(f.bind(null), ID3), { code: 'credential-unbound' });
});
test('CB1 a project copy binds only its own project; a role-exceeding request refuses, never trims', async t => {
const f = setup(t);
const project = (services, id) => f.prepare({ op: 'change', level: 'project', project: 'stack', name: 'checker', reason: 'Bind', binding: { services } }, id);
const p = await project(GITEA, ID1);
assert.equal(p.binding.instance, 'checker.stack');
await assert.rejects(project({ gitea: { scopes: ['write:admin'], projects: ['stack'] } }, ID2), { code: 'invalid-request' });
await assert.rejects(project({ gitea: { scopes: ['write:organization'], projects: ['stack'] } }, ID2), { code: 'credential-scope-exceeded' });
await assert.rejects(project({ gitea: { scopes: ['read:repository'], projects: ['other'] } }, ID2), { code: 'credential-scope-exceeded' });
await assert.rejects(f.prepare(f.bind({ gitea: { scopes: ['read:repository'], projects: ['stack'], token: SECRET } }), ID2), e => !JSON.stringify(e).includes(SECRET) && !e.message.includes(SECRET));
// A copy the bus did not boot, and an org instance, are not copies.
await assert.rejects(f.prepare({ ...f.bind(), name: 'nobody' }, ID2), { code: 'copy-unavailable' });
await assert.rejects(f.prepare({ ...f.bind(), name: 'coder' }, ID2), { code: 'copy-unavailable' });
// A binding is only ever a change of a root or project copy, with no role, route or credentials.
for (const extra of [{ op: 'hire' }, { level: 'org' }, { route: { harness: 'pi', model: 'm' } }, { credentials: {} }]) {
await assert.rejects(f.prepare({ ...f.bind(), ...extra }, ID2), { code: 'invalid-request' });
}
});
test('CB1 only the owner resolves: member with org.hire or decide, chief, arbiter, subject, tester and reader all fail', async t => {
const plain = { binding: {}, flagged: [], credentialLines: [], level: 'root', id: ID1, name: 'rev' };
const member = { ...OWNER, owner: false, instanceOwner: false };
assert.throws(() => authorizeHire(plain, member), { code: 'human-required' });
assert.throws(() => authorizeHire(plain, { ...member, instanceOwner: true }), { code: 'human-required' }, 'an instance operator alone is not the owner');
assert.throws(() => authorizeHire(plain, { ...OWNER, rights: ['decide'] }), { code: 'right-required' });
assert.throws(() => authorizeHire(plain, { ...OWNER, rights: [] }), { code: 'right-required' }, 'reader');
for (const agent of ['chief', 'cto', 'rev', 'tester']) {
// An agent, even one carrying an authority decision and the authority right, never resolves.
assert.throws(() => authorizeHire(plain, { actor: agent, agent: true, rights: ['org.hire', 'decision.resolve.authority'], owner: true, authorityDecision: ID1, resolver: agent }), { code: 'human-required' });
}
assert.doesNotThrow(() => authorizeHire(plain, OWNER));
// Through the service: each refused caller leaves the file byte-identical.
const f = setup(t);
const p = await f.prepare();
const before = f.bytes();
for (const caller of [member, { ...OWNER, rights: ['decide'] }, { ...OWNER, rights: [] },
{ actor: 'chief', agent: true, rights: ['org.hire'], owner: true }, { actor: 'cto', agent: true, rights: ['decision.resolve.authority', 'org.hire'], authorityDecision: p.id, resolver: 'cto' },
{ actor: 'rev', agent: true, rights: ['org.hire'] }, { actor: 'tester', agent: true, rights: ['org.hire'] }]) {
await assert.rejects(f.service.apply('demo', p.id, { ...caller, digest: p.digest }));
}
assert.deepEqual(f.bytes(), before);
// The writer refuses the tester and every agent identity even when told owner:true.
const full = { ...JSON.parse(readFileSync(join(f.dataRoot, 'authority-proposals', `${p.id}.json`), 'utf8')), digest: p.digest };
for (const actor of ['tester', 'chief', 'rev', 'checker.stack', 'reviewer', 'pm']) {
assert.throws(() => f.writer.applyCopyBindingDecision('demo', full, { actor, owner: true }), { code: 'human-required' }, actor);
}
assert.throws(() => f.writer.applyCopyBindingDecision('demo', full, { actor: 'jason', owner: false }), { code: 'human-required' });
assert.deepEqual(f.bytes(), before);
});
test('CB1 generic writes never change copyBindings, whatever options they pass', async t => {
const f = setup(t); await f.ready;
const { document, revision } = f.writer.read('demo');
const forged = { ...document, copyBindings: { rev: { ...record(), orgIncarnation: orgIncarnation(await readRegistry(f.ctx), 'demo'), state: 'bound' } } };
assert.doesNotThrow(() => validateBusinessDocument(forged, f.file, { rolesDir: f.roles }), 'a valid document is still refused by the writer');
const before = f.bytes();
for (const options of [{}, { capability: Symbol('copy-binding-write') }, { capability: Symbol.for('copy-binding-write'), incarnation: forged.copyBindings.rev.orgIncarnation }, { guard: () => {} }]) {
assert.throws(() => f.writer.write('demo', forged, { revision, actor: 'jason', ...options }), { code: 'organization-bindings-protected' });
}
assert.throws(() => f.writer.write('demo', old => ({ ...old, copyBindings: forged.copyBindings }), { revision, actor: 'jason' }), { code: 'organization-bindings-protected' });
assert.deepEqual(f.bytes(), before);
// Once a record exists, a generic write can neither remove nor edit it.
const p = await f.prepare(); await f.service.apply('demo', p.id, { ...OWNER, digest: p.digest });
const now = f.writer.read('demo');
const { copyBindings: _, ...stripped } = now.document;
assert.throws(() => f.writer.write('demo', stripped, { revision: now.revision, actor: 'jason' }), { code: 'organization-bindings-protected' });
const promoted = structuredClone(now.document); promoted.copyBindings.rev.state = 'bound';
assert.throws(() => f.writer.write('demo', promoted, { revision: now.revision, actor: 'jason' }), { code: 'organization-bindings-protected' });
// An unrelated write keeps the record.
const edited = structuredClone(now.document); edited.vars['tracker.pollSeconds'] = 61;
f.writer.write('demo', edited, { revision: now.revision, actor: 'jason' });
assert.deepEqual(f.writer.read('demo').document.copyBindings, now.document.copyBindings);
});
test('CB1 stale revision, digest or generation, expiry, replay and a wrong incarnation activate nothing', async t => {
const f = setup(t);
// Digest.
const p = await f.prepare();
const before = f.bytes();
await assert.rejects(f.service.apply('demo', p.id, { ...OWNER, digest: '0'.repeat(64) }), { code: 'decision-stale' });
// Expiry.
await assert.rejects(f.service.apply('demo', p.id, { ...OWNER, digest: p.digest, now: () => Date.parse('2099-06-01T00:00:00Z') }), { code: 'decision-expired' });
assert.deepEqual(f.bytes(), before);
// Revision: any change to the file after the proposal.
const q = await f.prepare(f.bind(), ID2);
const r = f.writer.read('demo'), edited = structuredClone(r.document); edited.vars['tracker.pollSeconds'] = 61;
f.writer.write('demo', edited, { revision: r.revision, actor: 'jason' });
await assert.rejects(f.service.apply('demo', q.id, { ...OWNER, digest: q.digest }), { code: 'decision-stale' });
assert.equal(f.writer.read('demo').document.copyBindings, undefined);
// Replay: applying the same approval twice changes nothing the second time.
const s = await f.prepare(f.bind(), ID3);
await f.service.apply('demo', s.id, { ...OWNER, digest: s.digest });
const once = f.bytes();
await assert.rejects(f.service.apply('demo', s.id, { ...OWNER, digest: s.digest }), { code: 'decision-stale' });
const full = { ...JSON.parse(readFileSync(join(f.dataRoot, 'authority-proposals', `${s.id}.json`), 'utf8')), digest: s.digest };
// Generation: the writer refuses a proposal at an old generation even with a fresh revision.
assert.throws(() => f.writer.applyCopyBindingDecision('demo', { ...full, revision: f.writer.read('demo').revision }, { actor: 'jason', owner: true }), { code: 'credential-binding-changed' });
assert.deepEqual(f.bytes(), once);
// Wrong incarnation: the organization was recreated under the same id after approval.
const u = await f.prepare(f.bind(null), '44444444-4444-4444-8444-444444444444');
withRegistryWriter(f.root, (registry, publish) => { registry.orgs.demo.incarnation = 'f'.repeat(32); publish(); });
await assert.rejects(f.service.apply('demo', u.id, { ...OWNER, digest: u.digest }), { code: 'decision-stale' });
const uf = { ...JSON.parse(readFileSync(join(f.dataRoot, 'authority-proposals', `${u.id}.json`), 'utf8')), digest: u.digest };
assert.throws(() => f.writer.applyCopyBindingDecision('demo', uf, { actor: 'jason', owner: true }), { code: 'decision-stale' });
assert.deepEqual(f.bytes(), once);
// A changed definition pin refuses too.
assert.throws(() => f.writer.applyCopyBindingDecision('demo', { ...uf, binding: { ...uf.binding, orgIncarnation: 'f'.repeat(32), definition: { ...uf.binding.definition, sha256: '0'.repeat(64) } } }, { actor: 'jason', owner: true }), { code: 'credential-role-changed' });
// A binding is never reversed through the AU inverse, and never adjusted.
await assert.rejects(f.service.reverse('demo', s.id, OWNER), { code: 'decision-stale' });
await assert.rejects(f.service.check('demo', u.id, { ...OWNER, digest: u.digest, adjust: { role: {} } }), { code: 'invalid-request' });
});
test('CB1 a legacy organization gets its incarnation at the first proposal, under the registry lock', async t => {
const f = setup(t, { incarnation: false });
await f.ready;
assert.equal(orgIncarnation(await readRegistry(f.ctx), 'demo'), null);
const p = await f.prepare();
const inc = orgIncarnation(await readRegistry(f.ctx), 'demo');
assert.match(inc, /^[0-9a-f]{32}$/);
assert.equal(p.binding.orgIncarnation, inc);
assert.equal(ensureOrgIncarnation(f.root, 'demo'), inc, 'minted once');
assert.equal(ensureOrgIncarnation(f.root, 'nobody'), null);
});
test('CB1 a pending or partial binding never launches the copy', async t => {
const f = setup(t);
const p = await f.prepare();
await f.service.apply('demo', p.id, { ...OWNER, digest: p.digest });
const business = loadWithCopies('demo', { dir: f.root, rolesDir: f.roles, snapshot: f.snapshot });
assert.equal(copyRefusal(business, 'rev').code, 'credential-unbound');
assert.equal(Object.hasOwn(business.roles, 'rev'), false);
const rec = business.copyBindings.rev;
assert.equal(rec.state, 'pending');
const pin = definitionPin('reviewer', { rolesDir: f.roles, readTemplate });
const role = JSON.parse(readFileSync(join(f.roles, 'reviewer.json'), 'utf8'));
const args = { orgId: 'demo', incarnation: rec.orgIncarnation, snapshot: f.snapshot, instance: 'rev', pin, role, projects: ['stack'] };
assert.equal(resolveCopyBinding({ ...args, record: rec }).code, 'credential-unbound');
// Even forced to bound, gitea alone is partial for a role that also needs vikunja.
assert.deepEqual(resolveCopyBinding({ ...args, record: { ...rec, state: 'bound' } }), { ok: false, code: 'credential-unbound', service: 'vikunja' });
// A pending record that covers every service the role needs is still not a binding; bound, it is.
const whole = { ...rec, services: { ...rec.services, vikunja: { slot: 'self', scopes: { tasks: ['read_one'] }, resources: { projects: ['stack'] } } } };
assert.equal(resolveCopyBinding({ ...args, record: { ...whole, state: 'bound' } }).ok, true);
assert.deepEqual(resolveCopyBinding({ ...args, record: whole }), { ok: false, code: 'credential-unbound', service: 'gitea' });
});
test('CB1 no proposal, record, receipt or refusal names a token or a path', async t => {
const f = setup(t);
const p = await f.prepare();
await f.service.apply('demo', p.id, { ...OWNER, digest: p.digest });
const texts = [JSON.stringify(p), readFileSync(join(f.root, 'businesses/.history/demo/changes.jsonl'), 'utf8'),
...readdirSync(join(f.dataRoot, 'authority-proposals')).map(n => readFileSync(join(f.dataRoot, 'authority-proposals', n), 'utf8'))];
for (const text of texts) {
assert.ok(!text.includes(SECRET)); assert.ok(!text.includes(join(f.root, 'secrets')));
}
const refusals = [];
for (const args of [[f.bind({ gitea: { scopes: ['write:organization'], projects: ['stack'] } }), ID2], [{ ...f.bind(), name: 'gone' }, ID3]]) {
await f.prepare(...args).catch(e => refusals.push(e));
}
const t2 = { ...JSON.parse(readFileSync(join(f.dataRoot, 'authority-proposals', `${p.id}.json`), 'utf8')) };
try { f.writer.applyCopyBindingDecision('demo', { ...t2, revision: f.writer.read('demo').revision }, { actor: 'chief', owner: true }); } catch (e) { refusals.push(e); }
assert.equal(refusals.length, 3);
for (const e of refusals) assert.ok(!e.message.includes('/') && !e.message.includes(SECRET), e.message);
assert.equal(refusals[2].message, 'Only the organization owner can approve credential binding.');
void sha;
});
test('CB1 fix: the free-text reason is never written to a binding proposal', async t => {
const f = setup(t);
const leak = '/home/someone/.config/mosaic-dev/secrets/gitea tok-fragment-9f8e';
const p = await f.prepare(f.bind(GITEA, { reason: `Bind; see ${leak}` }));
const stored = readFileSync(join(f.dataRoot, 'authority-proposals', `${p.id}.json`), 'utf8');
assert.ok(!stored.includes(leak) && !stored.includes('tok-fragment') && !stored.includes('"reason"'), 'no reason text on disk');
assert.deepEqual(Object.keys(JSON.parse(stored).request).sort(), ['binding', 'level', 'name', 'op']);
assert.ok(!JSON.stringify(p).includes('tok-fragment'));
// The proposal still applies on its digest.
await f.service.apply('demo', p.id, { ...OWNER, digest: p.digest });
assert.equal(f.writer.read('demo').document.copyBindings.rev.state, 'pending');
assert.ok(!readFileSync(join(f.root, 'businesses/.history/demo/changes.jsonl'), 'utf8').includes('tok-fragment'));
});
test('CB1 fix: a root copy removed or replaced since boot refuses at prepare, at apply and before publication', async t => {
const receipts = root => join(root, 'agents-receipts.jsonl');
const add = (root, row) => writeFileSync(receipts(root), JSON.stringify(row) + '\n', { flag: 'a', mode: 0o600 });
// Removed since boot: prepare refuses instance-fired; replaced: copy-stale.
for (const [verb, code] of [['root.agent.remove', 'instance-fired'], ['root.agent.set', 'copy-stale']]) {
const f = setup(t); await f.ready;
add(f.root, { agent: 'rev', verb, at: FUTURE });
await assert.rejects(f.prepare(), { code }, verb);
let saved = []; try { saved = readdirSync(join(f.dataRoot, 'authority-proposals')); } catch (e) { if (e.code !== 'ENOENT') throw e; }
assert.deepEqual(saved, [], verb);
}
// Prepared while fresh, then the root copy is removed: apply refuses and writes nothing.
{
const f = setup(t); const p = await f.prepare(); const before = f.bytes();
add(f.root, { agent: 'rev', verb: 'root.agent.remove', at: FUTURE });
await assert.rejects(f.service.apply('demo', p.id, { ...OWNER, digest: p.digest }), { code: 'instance-fired' });
assert.deepEqual(f.bytes(), before);
}
// The change lands after checkBinding, under the lock: the publication fence still refuses.
{
const f = setup(t); const p = await f.prepare(); const before = f.bytes();
let wrote = false;
const late = () => { if (!wrote) { wrote = true; add(f.root, { agent: 'rev', verb: 'root.agent.set', at: FUTURE }); } };
await assert.rejects(f.service.apply('demo', p.id, { ...OWNER, digest: p.digest, beforeWrite: late }), { code: 'copy-stale' });
assert.equal(wrote, true);
assert.deepEqual(f.bytes(), before);
}
// A receipt for another root agent leaves this copy fresh; an unreadable receipts file refuses.
{
const f = setup(t); await f.ready;
add(f.root, { agent: 'other', verb: 'root.agent.remove', at: FUTURE });
const p = await f.prepare();
writeFileSync(receipts(f.root), 'not json\n', { mode: 0o600 });
await assert.rejects(f.service.apply('demo', p.id, { ...OWNER, digest: p.digest }), { code: 'root-policy-unreadable' });
}
// A project copy does not read root receipts.
{
const f = setup(t); await f.ready;
add(f.root, { agent: 'checker', verb: 'root.agent.remove', at: FUTURE });
const p = await f.prepare({ op: 'change', level: 'project', project: 'stack', name: 'checker', reason: 'Bind', binding: { services: GITEA } });
await f.service.apply('demo', p.id, { ...OWNER, digest: p.digest });
assert.equal(f.writer.read('demo').document.copyBindings['checker.stack'].state, 'pending');
}
});
test('CB1 the org service refuses copyBindings on create and on a whole-document update', async t => {
const root = mkdtempSync(join(tmpdir(), 'cb1-svc-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const c = { configDir: join(root, 'config'), dataRoot: join(root, 'data'), instance: 'test', principal: { provider: 'local-dev', iss: 'local-dev', sub: 'local-dev-owner' } };
mkdirSync(c.configDir, { mode: 0o700 }); mkdirSync(c.dataRoot, { mode: 0o700 });
const forged = { rev: { ...record(), state: 'bound' } };
await assert.rejects(createOrg(c, 'alpha', { ...await templateRender('alpha'), copyBindings: forged }), { code: 'organization-bindings-protected' });
await createOrg(c, 'alpha', await templateRender('alpha'));
const current = await showOrg(c, 'alpha');
await assert.rejects(updateOrg(c, 'alpha', { ...current.doc, copyBindings: forged }, { ifRevision: current.revision }), { code: 'organization-bindings-protected' });
assert.equal((await showOrg(c, 'alpha')).doc.copyBindings, undefined);
await updateOrg(c, 'alpha', current.doc, { ifRevision: current.revision });
});
test('CB1 fix: a root copy binds only projects whose union still admits it', async t => {
const f = setup(t, { edit: doc => { doc.projects.web = { root: doc.projects.stack.root }; doc.projects.stack.agents.rev = { exclude: true }; } });
// stack excludes rev: a request naming it refuses whole, never trimmed to web.
await assert.rejects(f.prepare(f.bind({ gitea: { scopes: ['read:repository'], projects: ['stack', 'web'] } })), { code: 'credential-scope-exceeded' });
await assert.rejects(f.prepare(f.bind({ gitea: { scopes: ['read:repository'], projects: ['stack'] } }, {}), ID2), { code: 'credential-scope-exceeded' });
// web still admits it.
const p = await f.prepare(f.bind({ gitea: { scopes: ['read:repository'], projects: ['web'] } }), ID3);
assert.deepEqual(p.binding.after.gitea.resources.projects, ['web']);
});
test('CB1 fix: a named instance added after boot shadows the copy at proposal time', async t => {
const f = setup(t); await f.ready;
const r = f.writer.read('demo'), edited = structuredClone(r.document);
const like = Object.values(edited.roles).find(e => e.definition === 'reviewer');
edited.roles.rev = { ...structuredClone(like), tracker: { bot: 'bot-demo-rev', botId: 977 } };
f.writer.write('demo', edited, { revision: r.revision, actor: 'jason' });
await assert.rejects(f.prepare(), { code: 'copy-unavailable' });
});
test('CB1 fix: the loader refuses a copyBindings record holding a token, naming neither the value nor a path', t => {
const root = mkdtempSync(join(tmpdir(), 'cb1-load-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const roles = rolesCopy(root);
const doc = { ...businessDoc(root, 'demo'), copyBindings: { rev: { ...record(), state: 'pending', token: SECRET } } };
const file = join(root, 'businesses/demo.json');
assert.throws(() => validateBusinessDocument(doc, file, { rolesDir: roles }), e => e.name === 'BusinessError' && !e.message.includes(SECRET) && !e.message.includes(root));
// The same document without the token loads.
const { token: _, ...clean } = doc.copyBindings.rev;
validateBusinessDocument({ ...doc, copyBindings: { rev: clean } }, file, { rolesDir: roles });
});
@@ -0,0 +1,215 @@
// #1873 CB2 (design section 5 steps 4 to 6, section 8 row CB2): the grant metadata a verified copy
// credential leaves on its binding record, and the writer's two writes around the remote work. The
// bus side (verifier, slot, broker) is packages/bus/tests/copy-grant.test.mjs.
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { hireDecisions } from '../src/hire-decisions.mjs';
import { organizationWriter } from '../src/organization.mjs';
import { checkRootAgents } from '../src/agent-levels.mjs';
import { materializeCopies } from '../src/agent-copies.mjs';
import { readTemplate } from '../src/definitions.mjs';
import { activateOrg, reserveOrg } from '../src/org-registry.mjs';
import * as bindings from '../src/copy-bindings.mjs';
import { businessDoc, rolesCopy, writeJson } from './helpers.mjs';
const { definitionPin, validateCopyBindings } = bindings;
const OWNER = { actor: 'jason', agent: false, rights: ['org.hire'], owner: true, instanceOwner: true, subject: { provider: 'test', iss: 'https://test', sub: 'owner' } };
const FUTURE = '2099-01-01T00:00:00.000Z';
const ID1 = '11111111-1111-4111-8111-111111111111', ID2 = '22222222-2222-4222-8222-222222222222', ID3 = '33333333-3333-4333-8333-333333333333';
const GITEA = { gitea: { scopes: ['read:repository', 'read:user'], projects: ['stack'] } };
const SECRET = 'sk-test-NOT-A-SECRET-1234';
const repoRoles = join(new URL('../../..', import.meta.url).pathname, 'roles');
const INC = 'a'.repeat(32);
const granted = (over = {}) => ({
copyId: 'c'.repeat(32), orgIncarnation: INC, source: { level: 'root', name: 'rev', project: null },
definition: definitionPin('reviewer', { rolesDir: repoRoles, readTemplate }), generation: 3, state: 'bound',
services: {
gitea: { slot: 'self', scopes: ['read:repository'], resources: { projects: ['stack'] }, rotateBy: '2099-01-01', grantId: 'gitea:5:7', grantGeneration: 1 },
vikunja: { slot: 'self', scopes: { tasks: ['read_one'] }, resources: { projects: ['stack'] }, rotateBy: '2099-01-01', grantId: 'vikunja:2:9', grantGeneration: 2 },
},
decision: ID1, ...over,
});
const withGitea = (patch, over = {}) => { const r = granted(over); r.services.gitea = { ...r.services.gitea, ...patch }; for (const k of Object.keys(patch)) if (patch[k] === undefined) delete r.services.gitea[k]; return r; };
test('CB2 schema: grant fields come together, name their own service and are ids only', () => {
assert.doesNotThrow(() => validateCopyBindings({ rev: granted() }));
// After a stage: pending, the grant gone, its generation kept.
assert.doesNotThrow(() => validateCopyBindings({ rev: withGitea({ rotateBy: undefined, grantId: undefined }, { state: 'pending' }) }));
const refused = [
withGitea({ grantId: undefined }),
withGitea({ rotateBy: undefined }),
withGitea({ grantGeneration: undefined }),
withGitea({ grantId: 'vikunja:5:7' }),
withGitea({ grantId: 'gitea:5' }),
withGitea({ grantId: 'gitea:0:7' }),
withGitea({ grantId: `gitea:${SECRET}` }),
withGitea({ grantId: 'gitea:5:7:' + 'f'.repeat(40) }),
withGitea({ grantGeneration: 0 }),
withGitea({ grantGeneration: 1.5 }),
withGitea({ rotateBy: '2099-02-30' }),
withGitea({ rotateBy: '2099-1-01' }),
withGitea({ token: SECRET }),
withGitea({ account: 'mosaic-copy-x' }),
withGitea({ sha256: 'f'.repeat(64) }),
];
for (const r of refused) assert.throws(() => validateCopyBindings({ rev: r }), e => e.name === 'BusinessError' && !e.message.includes(SECRET) && !e.message.includes('/'), JSON.stringify(r.services.gitea));
});
test('CB2 schema: grant fields are optional per service; one grant id per organization', () => {
// The schema keeps CB0's shape: a service without a grant is valid in any state. Only the
// verifier's commit writes bound, and only when every service holds a grant (bus copy-grant tests).
assert.doesNotThrow(() => validateCopyBindings({ rev: withGitea({ rotateBy: undefined, grantId: undefined }) }));
assert.throws(() => validateCopyBindings({ rev: granted({ services: {} }) }));
const other = { ...granted({ copyId: 'd'.repeat(32), source: { level: 'root', name: 'other', project: null } }) };
other.services = structuredClone(other.services);
other.services.vikunja.grantId = 'vikunja:3:3';
assert.throws(() => validateCopyBindings({ rev: granted(), other }), /grant another binding uses/);
other.services.gitea.grantId = 'gitea:5:8';
assert.doesNotThrow(() => validateCopyBindings({ rev: granted(), other }));
});
test('CB2 the login a copy grant must belong to is per organization, copy and service', () => {
const { copyBotLogin } = bindings;
assert.equal(typeof copyBotLogin, 'function');
const a = copyBotLogin(INC, 'c'.repeat(32), 'gitea');
assert.match(a, /^mosaic-copy-[0-9a-f]{24}$/);
assert.ok(a.length <= 40);
assert.equal(copyBotLogin(INC, 'c'.repeat(32), 'gitea'), a);
for (const other of [copyBotLogin('b'.repeat(32), 'c'.repeat(32), 'gitea'), copyBotLogin(INC, 'd'.repeat(32), 'gitea'), copyBotLogin(INC, 'c'.repeat(32), 'vikunja')])
assert.notEqual(other, a);
for (const bad of [[null, 'c'.repeat(32), 'gitea'], [INC, 'x', 'gitea'], [INC, 'c'.repeat(32), 'host.ops'], [INC, 'c'.repeat(32), 'model']])
assert.equal(copyBotLogin(...bad), null);
});
function setup(t) {
const root = mkdtempSync(join(tmpdir(), 'cb2o-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const roles = rolesCopy(root);
const doc = { ...businessDoc(root, 'demo'), plugins: { harness: ['pi'] } };
doc.projects.stack.agents = { checker: { definition: 'reviewer' } };
const file = writeJson(join(root, 'businesses/demo.json'), doc);
const dataRoot = join(root, 'data'); mkdirSync(dataRoot, { mode: 0o700 });
const ctx = { configDir: root, dataRoot, instance: 'test' };
const ready = (async () => { await reserveOrg(ctx, 'demo'); await activateOrg(ctx, 'demo'); })();
const snapshot = materializeCopies({ orgId: 'demo', doc, root: { state: 'ok', ...checkRootAgents({ agentsVersion: 1, agents: { rev: { definition: 'reviewer' } } }, { rolesDir: roles }) }, rolesDir: roles });
const writer = organizationWriter({ dir: root, rolesDir: roles });
const service = hireDecisions({ writer, rolesDir: roles, configDir: root, dataRoot, assertClean: () => {}, copies: id => snapshot.org === id ? snapshot : null });
const bind = async (input, id) => {
await ready;
const p = await service.prepare('demo', { op: 'change', reason: 'Bind', binding: { services: GITEA }, ...input }, { id, expiresAt: FUTURE });
await service.apply('demo', p.id, { ...OWNER, digest: p.digest });
};
const rec = name => writer.read('demo').document.copyBindings[name];
const opFor = (name, service = 'gitea') => { const r = rec(name); return { instance: name, service, copyId: r.copyId, generation: r.generation, decision: r.decision, incarnation: r.orgIncarnation }; };
const log = () => readFileSync(join(root, 'businesses/.history/demo/changes.jsonl'), 'utf8');
return { root, roles, file, writer, bind, rec, opFor, log, revision: () => writer.read('demo').revision };
}
const AS = { actor: 'jason', owner: true };
test('CB2 writer: the stage deactivates, the commit binds; the receipts name the verbs, never a grant', async t => {
const f = setup(t);
await f.bind({ level: 'root', name: 'rev' }, ID1);
const op = f.opFor('rev');
assert.equal(f.rec('rev').state, 'pending');
const staged = f.writer.stageCopyGrant('demo', op, { revision: f.revision(), ...AS });
assert.equal(staged.generation, op.generation + 1);
assert.deepEqual({ state: f.rec('rev').state, generation: f.rec('rev').generation }, { state: 'pending', generation: op.generation + 1 });
const done = f.writer.commitCopyGrant('demo', { ...op, generation: staged.generation }, { grantId: 'gitea:5:9', rotateBy: '2099-01-01', revision: staged.revision, ...AS });
assert.equal(done.state, 'bound');
const r = f.rec('rev');
assert.deepEqual({ state: r.state, generation: r.generation, gitea: r.services.gitea }, {
state: 'bound', generation: op.generation + 2,
gitea: { slot: 'self', scopes: ['read:repository', 'read:user'], resources: { projects: ['stack'] }, rotateBy: '2099-01-01', grantId: 'gitea:5:9', grantGeneration: 1 },
});
const log = f.log();
assert.ok(log.includes('binding.stage') && log.includes('binding.grant'));
assert.ok(!log.includes('gitea:5:9') && !log.includes('2099-01-01'), 'the receipts are paths only');
// A new issue stages again: use closes before any remote work, the grant generation stays.
f.writer.stageCopyGrant('demo', f.opFor('rev'), { revision: f.revision(), ...AS });
assert.deepEqual({ state: f.rec('rev').state, gitea: f.rec('rev').services.gitea }, {
state: 'pending', gitea: { slot: 'self', scopes: ['read:repository', 'read:user'], resources: { projects: ['stack'] }, grantGeneration: 1 },
});
const again = f.writer.commitCopyGrant('demo', f.opFor('rev'), { grantId: 'gitea:5:10', rotateBy: '2099-02-01', revision: f.revision(), ...AS });
assert.equal(again.state, 'bound');
assert.equal(f.rec('rev').services.gitea.grantGeneration, 2);
});
test('CB2 writer: a stale generation, revision, decision, copy or incarnation, and a second commit, write nothing', async t => {
const f = setup(t);
await f.bind({ level: 'root', name: 'rev' }, ID1);
const op = f.opFor('rev');
const rev0 = f.revision();
const staged = f.writer.stageCopyGrant('demo', op, { revision: rev0, ...AS });
const after = { ...op, generation: staged.generation };
const grant = { grantId: 'gitea:5:9', rotateBy: '2099-01-01', ...AS };
const before = readFileSync(f.file);
for (const [bad, code] of [
[{ ...after, generation: op.generation }, 'credential-binding-changed'],
[{ ...after, copyId: 'e'.repeat(32) }, 'credential-binding-changed'],
[{ ...after, decision: ID3 }, 'credential-binding-changed'],
[{ ...after, instance: 'checker.stack' }, 'credential-binding-changed'],
[{ ...after, service: 'vikunja' }, 'credential-binding-changed'],
[{ ...after, incarnation: 'b'.repeat(32) }, 'decision-stale'],
]) assert.throws(() => f.writer.commitCopyGrant('demo', bad, { ...grant, revision: staged.revision }), { code }, JSON.stringify(bad));
assert.throws(() => f.writer.commitCopyGrant('demo', after, { ...grant, revision: rev0 }), { code: 'revision-conflict' });
assert.deepEqual(readFileSync(f.file), before);
f.writer.commitCopyGrant('demo', after, { ...grant, revision: staged.revision });
const bound = readFileSync(f.file);
assert.throws(() => f.writer.commitCopyGrant('demo', after, { ...grant, revision: f.revision() }), { code: 'credential-binding-changed' });
assert.throws(() => f.writer.commitCopyGrant('demo', { ...after, generation: after.generation + 1 }, { ...grant, grantId: 'gitea:5:10', revision: f.revision() }), { code: 'credential-binding-changed' });
assert.deepEqual(readFileSync(f.file), bound);
// A new binding decision between the stage and the commit wins: the commit refuses.
f.writer.stageCopyGrant('demo', f.opFor('rev'), { revision: f.revision(), ...AS });
const pinned = f.opFor('rev');
await f.bind({ level: 'root', name: 'rev', binding: { services: { gitea: { scopes: ['read:user'], projects: ['stack'] } } } }, ID2);
assert.throws(() => f.writer.commitCopyGrant('demo', pinned, { ...grant, grantId: 'gitea:5:11', revision: f.revision() }), { code: 'credential-binding-changed' });
assert.deepEqual(f.rec('rev').services.gitea, { slot: 'self', scopes: ['read:user'], resources: { projects: ['stack'] } });
assert.equal(f.rec('rev').state, 'pending');
});
test('CB2 writer: only the owner stages or commits; no agent, tester or member identity', async t => {
const f = setup(t);
await f.bind({ level: 'root', name: 'rev' }, ID1);
const op = f.opFor('rev');
const before = readFileSync(f.file);
for (const who of [{ actor: 'jason', owner: false }, { actor: 'tester', owner: true }, { actor: 'rev', owner: true }, { actor: 'chief', owner: true }, { actor: 'checker.stack', owner: true }, { actor: 'reviewer.checker.stack', owner: true }]) {
assert.throws(() => f.writer.stageCopyGrant('demo', op, { revision: f.revision(), ...who }), { code: 'human-required' }, who.actor);
assert.throws(() => f.writer.commitCopyGrant('demo', op, { grantId: 'gitea:5:9', rotateBy: '2099-01-01', revision: f.revision(), ...who }), { code: 'human-required' }, who.actor);
}
assert.throws(() => f.writer.stageCopyGrant('demo', { ...op, generation: 'x' }, { revision: f.revision(), ...AS }), { code: 'invalid-request' });
assert.deepEqual(readFileSync(f.file), before);
});
test('CB2 writer: another copy\'s grant id never moves here; a definition change refuses the commit', async t => {
const f = setup(t);
await f.bind({ level: 'root', name: 'rev' }, ID1);
await f.bind({ level: 'project', project: 'stack', name: 'checker' }, ID2);
const a = f.writer.stageCopyGrant('demo', f.opFor('rev'), { revision: f.revision(), ...AS });
f.writer.commitCopyGrant('demo', { ...f.opFor('rev') }, { grantId: 'gitea:5:9', rotateBy: '2099-01-01', revision: f.revision(), ...AS });
assert.ok(a);
f.writer.stageCopyGrant('demo', f.opFor('checker.stack'), { revision: f.revision(), ...AS });
const before = readFileSync(f.file);
assert.throws(() => f.writer.commitCopyGrant('demo', f.opFor('checker.stack'), { grantId: 'gitea:5:9', rotateBy: '2099-01-01', revision: f.revision(), ...AS }), { code: 'credential-identity-refused' });
assert.deepEqual(readFileSync(f.file), before);
// The reviewer template changes between the stage and the commit: its pin no longer matches.
const template = join(f.roles, 'reviewer.json');
const role = JSON.parse(readFileSync(template, 'utf8'));
writeFileSync(template, JSON.stringify({ ...role, title: role.title + ' changed' }, null, 2));
assert.throws(() => f.writer.commitCopyGrant('demo', f.opFor('checker.stack'), { grantId: 'gitea:6:1', rotateBy: '2099-01-01', revision: f.revision(), ...AS }), { code: 'credential-role-changed' });
assert.deepEqual(readFileSync(f.file), before);
});
test('CB2 writer: a generic write never adds or changes grant fields', async t => {
const f = setup(t);
await f.bind({ level: 'root', name: 'rev' }, ID1);
const { document, revision } = f.writer.read('demo');
const next = structuredClone(document);
next.copyBindings.rev.services.gitea = { ...next.copyBindings.rev.services.gitea, rotateBy: '2099-01-01', grantId: 'gitea:5:9', grantGeneration: 1 };
next.copyBindings.rev.state = 'bound';
assert.throws(() => f.writer.write('demo', next, { revision, actor: 'jason' }), { code: 'organization-bindings-protected' });
assert.equal(f.rec('rev').state, 'pending');
});
@@ -267,3 +267,35 @@ test('a role can add a legacy model when the model-provider module is off', asyn
const saved=settings.set('acme',{revision:view.revision,models,instances:{coder:'legacy-new'}},'chief-of-staff.chief');
assert.equal(saved.instances.coder.modelKey,'legacy-new');
});
test('D3 P0-4: a thinking-only change on a provider model saves with no catalogue, and a model or harness change still refuses', async () => {
const f=fixture();const {settings}=await routeSettings(f);const doc=JSON.parse(readFileSync(f.file,'utf8'));
doc.plugins['model-provider']=['fixture','cliproxy'];doc.launch.models['fixture-deterministic']={provider:'proxy',model:'fixture-deterministic',module:'fixture',upstream:'fixture'};
doc.roles.coder.vars={...doc.roles.coder.vars,harness:'pi',model:'fixture-deterministic',thinking:'off'};writeJson(f.file,doc);
const pick=(thinking,extra={})=>({coder:{harness:'pi',model:'fixture-deterministic',thinking,fallback:null,...extra}});
for(const catalogue of [null,{status:'unavailable',models:[],notices:[]}]){
const view=settings.routes('acme'),next=view.instances.coder.thinking==='low'?'high':'low';
settings.setRoutes('acme',{revision:view.revision,routes:pick(next)},'jason',catalogue);
assert.equal(JSON.parse(readFileSync(f.file,'utf8')).roles.coder.vars.thinking,next);
}
// A level the harness doesn't apply still refuses on a thinking-only change.
assert.throws(()=>settings.setRoutes('acme',{revision:settings.routes('acme').revision,routes:pick('medium')},'jason',null),code('thinking-unsupported'));
// A harness or model change truly needs the catalogue.
const rev=settings.routes('acme').revision,before=readFileSync(f.file,'utf8');
assert.throws(()=>settings.setRoutes('acme',{revision:rev,routes:pick('low',{harness:'claude-code'})},'jason',{status:'unavailable',models:[],notices:[]}),code('model-catalogue-unavailable'));
assert.throws(()=>settings.set('acme',{revision:rev,instances:{reviewer:'fixture-deterministic'}},'jason',{status:'unavailable',models:[],notices:[]}),code('model-provider-unavailable'));
assert.equal(readFileSync(f.file,'utf8'),before);
});
test('D3 review: a fallback thinking-only change on a provider model saves with no catalogue, and a fallback harness change still refuses', async () => {
const f=fixture();const {settings}=await routeSettings(f);const doc=JSON.parse(readFileSync(f.file,'utf8'));
doc.plugins['model-provider']=['fixture','cliproxy'];doc.launch.models['fixture-deterministic']={provider:'proxy',model:'fixture-deterministic',module:'fixture',upstream:'fixture'};
doc.roles.coder.vars={...doc.roles.coder.vars,harness:'pi',model:'fixture-deterministic',thinking:'off','fallback.harness':'pi','fallback.model':'fixture-deterministic','fallback.thinking':'off'};writeJson(f.file,doc);
const unavailable={status:'unavailable',models:[],notices:[]};
const pick=fallback=>({coder:{harness:'pi',model:'fixture-deterministic',thinking:'off',fallback:{harness:'pi',model:'fixture-deterministic',...fallback}}});
settings.setRoutes('acme',{revision:settings.routes('acme').revision,routes:pick({thinking:'low'})},'jason',unavailable);
assert.equal(JSON.parse(readFileSync(f.file,'utf8')).roles.coder.vars['fallback.thinking'],'low');
const before=readFileSync(f.file,'utf8');
assert.throws(()=>settings.setRoutes('acme',{revision:settings.routes('acme').revision,routes:pick({harness:'claude-code',thinking:'low'})},'jason',unavailable),code('model-catalogue-unavailable'));
assert.equal(readFileSync(f.file,'utf8'),before);
});
@@ -25,6 +25,11 @@ const BUNDLES = {
approver: ['board.read', 'decide', 'org.view', 'task.write', 'thread.read', 'thread.write'],
};
const VERBS = {
'autonomy.get.console': null,
'autonomy.set.console': null,
'autonomy.review.console': null,
'decision.confirm.console': null,
'decision.reverse.console': null,
'project.list': 'org.view',
names: 'org.view',
'thread.list': 'thread.read',
+3 -1
View File
@@ -251,7 +251,9 @@ export returns:
session credential (`no-session-credential`) and `MOSAIC_CLAUDE_BIN`.
- `version({env})` is what the manifest records as `harnessVersion`.
- `countsAccount()` says whether its launches spend `launch.account` and get
the row 126 account checks.
the row 126 account checks. The same answer decides whether an account
quota covers authority hires (#1873); the bus reads it once at start, so
return a constant.
- `mounts`, `builtinWeb`, `builtins`, `typedName`, `reliesOn`,
`gateTimeout`, `bundle`, `env`, `turnEnd`, `outputPolicy` and `transcript` give its
sandbox binds, web, tool names, S0 lines, bundle files, adapter variables,
+7 -1
View File
@@ -181,10 +181,16 @@ test('PL1 runtime discovers installed harness levels from the instance root',asy
const {KINDS}=await import('../src/kinds.mjs');
const f=fixture(t);f.manifest.kinds=['harness'];f.manifest.interfaceVersions={harness:1};
mkdirSync(join(f.src,'harness'));
writeFileSync(join(f.src,'harness/index.mjs'),KINDS.harness.exports.map(name=>`export function ${name}(){return ${name==='levels'?"['high']":'null'};}`).join('\n')+'\nexport const interfaceVersion=1;');
writeFileSync(join(f.src,'harness/index.mjs'),KINDS.harness.exports.map(name=>`export function ${name}(){return ${name==='levels'?"['high']":name==='countsAccount'?'true':'null'};}`).join('\n')+'\nexport const interfaceVersion=1;');
const hash=f.seal();f.install(f.src,{expectedHash:hash});
const levels=await harnessLevels({dataRoot:f.dataRoot,log:()=>{}});
assert.deepEqual(levels('acmeplug'),['high']);assert.ok(levels.names.includes('pi'));
// #1873: the module's countsAccount answer, until the lock pins another package for that name.
assert.equal(levels.countsAccount('acmeplug'),true);
const lock=join(f.dataRoot,'plugins.lock.json'),doc=JSON.parse(readFileSync(lock,'utf8'));
doc.plugins.acmeplug.sha256='f'.repeat(64);writeFileSync(lock,JSON.stringify(doc));
assert.equal(levels.countsAccount('acmeplug'),null,'a replaced pin is not covered until restart');
assert.equal(levels.countsAccount('pi'),false,'a tree module keeps its answer');
});
test('PL1 invalid UTF-8 manifest bytes refuse instead of normalizing outside the pin',t=>{
+2
View File
@@ -62,6 +62,8 @@ export const RUNNER_FILES = Object.freeze([
"packages/bus/src/broker.mjs",
"packages/bus/src/refusals.mjs",
"packages/organization/src/rails.mjs",
"packages/organization/src/autonomy.mjs",
"packages/bus/src/autonomy-state.mjs",
"packages/bus/src/proxy-page-vocabulary.mjs",
// Row 331 r5: parsing only; common.mjs and PIN remain outside the sandbox.
"packages/model-proxy/src/validation.mjs",
+24
View File
@@ -583,3 +583,27 @@ export async function publishRemote({ repo, fork, user, source, dst, token, tls
if (!pushed.ok) refuse(/\((non-fast-forward|fetch first)\)/.test(pushed.out) ? "non-fast-forward" : "push-failed");
return { ref: dst, commit: source, previous };
}
// AU: a reverse is a new child of main, prepared under its ordinary repository lock.
// The caller runs the normal land gate, then finishLand's compare-and-swap and receipt.
export function prepareRevert({dataRoot,business,project,commit,dir,name,decision,env=process.env,limits=GIT_LIMITS}) {
if(!PROJECT.test(project??'') || !OID.test(commit??'') || !/^[a-z0-9][a-z0-9-]{0,62}$/.test(name??'') || !/^[a-zA-Z0-9-]{1,100}$/.test(decision??''))refuse('invalid-request');
const repo=workingRepo(dataRoot,business,project),g=bound(env,limits,dataRoot,repo),worktree=join(dir,name);
return locked(repo,async()=>{
const at=[`--git-dir=${repo}`],old=await tip(repo,'refs/heads/main',g);
if(!old || !(await g([...at,'merge-base','--is-ancestor',commit,old])).ok)refuse('ref-moved');
const opened=await g([...at,'worktree','add','-q','--detach',worktree,old]);
if(!opened.ok)refuse('git-failed');
try {
const wt=['-C',worktree];
if(!(await g([...wt,'revert','--no-commit',commit],{pre:LAND_IDENTITY})).ok)refuse('base-conflict');
const tree=await g([...wt,'write-tree']);if(!tree.ok)refuse('git-failed');
const file=join(dir,'reverse-message');writeFileSync(file,`Revert ${commit}\n\nAutonomy-Reverse: ${decision}\n`,{mode:0o600});
const made=await g([...wt,'commit-tree',tree.out.trim(),'-p',old,'-F',file],{pre:LAND_IDENTITY});
if(!made.ok || !OID.test(made.out.trim()))refuse('git-failed');
const next=made.out.trim();
if(!(await g([...wt,'reset','-q','--soft',next])).ok)refuse('git-failed');
return {worktree,old,commit:next,reverted:commit};
} catch(error) {await withoutProcessing(()=>removeWorktree(repo,worktree,g));throw error;}
});
}
+16
View File
@@ -654,3 +654,19 @@ test('halted push receipt rolls back only its unrecorded ref and cleans temporar
} })), /not recorded after halt/);
assert.equal(w.refs(), `refs/heads/main ${w.first}\nrefs/heads/root ${w.first}`);
});
test('AU prepareRevert makes a new commit, finishLand preserves history and rechecks the reverse window',async t=>{
const w=world(t);await initializeWorking({dataRoot:w.dataRoot,business:'acme',project:'stack',root:w.root});
git(w.clone,'checkout','-q','-b','feat');const changed=commit(w.clone,'two');await w.go(w.bundle());
const dir=join(w.dir,'land');mkdirSync(dir);
const landed=await prepareLand({dataRoot:w.dataRoot,business:'acme',project:'stack',root:w.root,taskRef:TASK,commit:changed,dir,name:'original',rootHits:()=>[],message:()=> 'Change'});
await finishLand({dataRoot:w.dataRoot,business:'acme',project:'stack',land:landed});await dropLand({dataRoot:w.dataRoot,business:'acme',project:'stack',worktree:landed.worktree});
const {prepareRevert}=await import('../src/working.mjs');
const reverse=await prepareRevert({dataRoot:w.dataRoot,business:'acme',project:'stack',commit:landed.commit,dir,name:'reverse',decision:'decision-1'});
assert.equal(git(w.dir,`--git-dir=${w.repo}`,'show',`${reverse.commit}:README`),'one');
assert.equal(git(w.dir,`--git-dir=${w.repo}`,'rev-parse',`${reverse.commit}^`),landed.commit);
await assert.rejects(finishLand({dataRoot:w.dataRoot,business:'acme',project:'stack',land:reverse,record:()=>{throw Object.assign(new Error('closed'),{code:'reverse-window-closed'});}}),{code:'reverse-window-closed'});
assert.equal(git(w.dir,`--git-dir=${w.repo}`,'rev-parse','main'),landed.commit);
await finishLand({dataRoot:w.dataRoot,business:'acme',project:'stack',land:reverse});
assert.match(git(w.dir,`--git-dir=${w.repo}`,'log','-1','--format=%B','main'),/decision-1/);
await dropLand({dataRoot:w.dataRoot,business:'acme',project:'stack',worktree:reverse.worktree});
});
+4 -1
View File
@@ -21,7 +21,10 @@ baseline CLI.
| Inspect or clear a dead lifecycle lock | `mosaic org lock show <id>` / `mosaic org lock clear <id>` | CLI only |
| Start, stop or restart a bus | `mosaic bus start <id>` / `mosaic bus stop <id>` / `mosaic bus restart <id>` | S6 integration hook |
| Restart (or first start) an organization's bus | `mosaic bus restart <id>`, or `mosaic bus start <id>` for a bus never started | Enabled (F3): the owner's **Restart bus** control, offered where a page says the bus is down or a change needs a restart; trailed |
| Restart the Console | Stop and run `mosaic console start` again | Enabled (F3) when `mosaic console start` runs it in the foreground: Settings > Console; the owner only; trailed. Other Consoles answer `not-supervised` |
| Restart the Console | Stop and run `mosaic console start` again | Enabled (F3) when `mosaic console start` runs it in the foreground, or with `--dev-local-owner --detach` under a detached supervisor (D1): Settings > Console; the owner only; trailed. Other Consoles answer `not-supervised` |
| Save, review, confirm or reverse an organization's autonomy | Read, save and review: `mosaic org autonomy ORG`, `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|""]`, `mosaic org autonomy ORG review CLASS` (E2, through this route and its revision check); confirm and reverse act on inbox decisions and have no verb yet | Enabled (D1) on a `--dev-local-owner` Console: the owner only, on the selected organization's bus, behind a fence that passes only the four console verbs `autonomy.set.console`, `autonomy.review.console`, `decision.confirm.console` and `decision.reverse.console` |
| Hire, fire or change an agent; read role templates | `mosaic org role-templates ORG`; `mosaic org hire|change ORG --name NAME --template TEMPLATE --harness H --model M [--thinking T] [--level org|project|root] [--project P] --reason TEXT`; `mosaic org fire ORG --name NAME [--level ...] --reason TEXT` (E2, through this route) | Enabled (D4) for the owner on a selected organization's bus: one decision raised and approved through `hire.start.console`, with the bus's hire checks |
| Create, edit or archive a board task | `mosaic tasks create --title T --requirement REQ [--project P] [--state S] [--assignees R,...] [--priority 0-5] [--due ISO|none]`; `mosaic tasks edit task:N --expect DIGEST ...`; `mosaic tasks archive task:N --expect DIGEST` (E2: the board's own check, then the person's `tasks.create|edit|archive` on the bus) | Enabled for the owner on an organization with the built-in task store |
| Create chat project linked to a configured repository | `mosaic project create <name> --repo <project> --org <id>` | Enabled |
| Create a thread | `mosaic thread create <project> <name> --org <id>` | Planned enabled |
| Open a thread | `mosaic thread open <thread> --org <id>` | Planned enabled |
+31
View File
@@ -0,0 +1,31 @@
// AU: owner Console transport only. Identity and organization never come from a JSON body.
export const AUTONOMY_ROUTE=/^\/api\/autonomy(?:\/(confirm|reverse|review))?$/;
// D1 (#1873): the owner writes this route makes, and a fence that passes only those to a console transport.
export const AUTONOMY_WRITE_VERBS=Object.freeze(['autonomy.set.console','autonomy.review.console','decision.confirm.console','decision.reverse.console']);
export const autonomyCaller=call=>async(verb,args,options)=>{if(!AUTONOMY_WRITE_VERBS.includes(verb))throw new Error(`the autonomy writer does not call ${verb}`);return call(verb,args,options);};
const one=(req,k)=>req.headersDistinct[k]?.length===1?req.headersDistinct[k][0]:null;
const json=(res,status,value)=>{res.writeHead(status,{'content-type':'application/json','cache-control':'no-store'});res.end(JSON.stringify(value));};
export async function autonomyHTTP(req,res,{path,read,write,session,live,origin}) {
if(!session)return json(res,401,{error:'sign-in-required'});
if(!read)return json(res,503,{error:'not-configured'});
if(!['GET','POST'].includes(req.method))return json(res,405,{error:'method-not-allowed'});
const step=AUTONOMY_ROUTE.exec(path)?.[1]??null;
if(step && req.method!=='POST')return json(res,405,{error:'method-not-allowed'});
const post=req.method==='POST';
if(post && !write)return json(res,403,{error:'read-only'});
if(post && (one(req,'origin')!==origin || one(req,'sec-fetch-site')!=='same-origin'))return json(res,403,{error:'cross-origin'});
let args={};
if(post) {
if(one(req,'content-type')?.split(';')[0].trim()!=='application/json')return json(res,400,{error:'invalid-request'});
const chunks=[];let size=0;for await(const chunk of req){size+=chunk.length;if(size>4096)return json(res,413,{error:'invalid-request'});chunks.push(chunk);}
try{args=JSON.parse(Buffer.concat(chunks).toString('utf8'));}catch{return json(res,400,{error:'invalid-request'});}
if(!args || typeof args!=='object' || Array.isArray(args))return json(res,400,{error:'invalid-request'});
const keys=step==='confirm'?['id','confirm','reason']:step==='reverse'?['id']:step==='review'?['decisionClass']:['revision','autonomy'];
if(Object.keys(args).some(k=>!keys.includes(k)))return json(res,400,{error:'invalid-request'});
}
if(live()!==session)return json(res,401,{error:'sign-in-required'});
const subject={provider:session.provider,iss:session.iss,sub:session.sub};
const verb=step==='confirm'?'decision.confirm.console':step==='reverse'?'decision.reverse.console':step==='review'?'autonomy.review.console':post?'autonomy.set.console':'autonomy.get.console';
try{return json(res,200,{...await(post?write:read)(verb,args,{subject}),writable:!!write});}
catch(e){const allowed=['authority-limits-unavailable','decision-closed','confirmation-required','not-found','storage-refused','human-required','invalid-request','invalid-autonomy','policy-reverse-unavailable','autonomy-precondition','revision-conflict','decision-not-eligible','decision-stale','decision-reversed','reverse-window-closed','reversal-unavailable','not-resolver','gate-missing','gate-failed','base-conflict','not-configured'];const code=allowed.includes(e?.code)?e.code:'outcome-unknown';return json(res,code==='human-required'?403:code==='invalid-request'||code==='invalid-autonomy'?400:409,{error:code});}
}
+82
View File
@@ -0,0 +1,82 @@
// D2 (P0-3): when a dev-local-owner Console starts, it reattaches to the organization buses that
// still run and brings back the ones a Console unit stop killed. The unit's KillMode=mixed sends
// SIGKILL to the bus hosts the Console started; a host killed that way leaves its host.json behind,
// which a host stopped on purpose (mosaic bus stop, Stop in the page) removes as it closes.
//
// For each active organization:
// - a bus whose state checks out (running, or stopped cleanly) is left as it is;
// - a bus whose state does not check out goes to busRevive, whose busRecover clears the state only
// when the host is proven dead (no live pid with that start time, the lock is the dead host's, no
// live writer, no socket that answers). Anything else refuses and the state stays for a person.
// The chief is not relaunched (pm: false): no agent starts without a person's press.
// Every attempt is a trail line before anything changes; a trail that cannot be written skips it.
// A Console whose own environment carries an agent run's markers revives nothing (the human-only
// rule `mosaic bus recover` and Restart bus's `mosaic org reload` follow).
// While an organization's revive runs, revivePending(id) holds it, so a Restart bus press waits for
// it instead of racing it for the lifecycle lock.
import { DEV_OWNER } from '../../cli/src/dev-local-owner.mjs';
import { AGENT_MARKERS } from '../../cli/src/transport.mjs';
const isCliError = e => e?.name === 'CliError' && Number.isInteger(e.exitCode);
const BY = Object.freeze({ ...DEV_OWNER, name: 'Console start' });
const pending = new Map();
export const revivePending = id => pending.get(id) ?? null;
export async function reviveBuses({ ctx, listOrgs, busStatus, busRevive, trail, log = () => {}, env = process.env }) {
const results = [];
if (AGENT_MARKERS.some(k => env?.[k])) { log('bus revive: this Console runs inside an agent run; no bus was checked'); return results; }
let orgs;
try { orgs = await listOrgs(ctx); }
catch { log('bus revive: the organization list could not be read; no bus was checked'); return results; }
const active = (orgs ?? []).filter(org => org?.state === 'active').map(org => org.id);
const settle = new Map();
for (const id of active) {
let done;
const wait = new Promise(resolve => { done = resolve; });
pending.set(id, wait);
settle.set(id, () => { if (pending.get(id) === wait) pending.delete(id); done(); });
}
try {
for (const id of active) {
try { results.push(await reviveOne({ ctx, id, busStatus, busRevive, trail, log })); }
finally { settle.get(id)(); }
}
} finally { for (const release of settle.values()) release(); }
return results;
}
async function reviveOne({ ctx, id, busStatus, busRevive, trail, log }) {
let status = null;
try { status = await busStatus(ctx, id); }
catch (e) {
if (!isCliError(e)) { log(`bus revive: ${id}: bus state could not be read; left as it is`); return { id, outcome: 'uncertain' }; }
}
if (status) {
const outcome = status.healthy ? 'running' : Number.isSafeInteger(status.pid) ? 'not-answering' : 'stopped';
if (outcome === 'running') log(`bus revive: ${id}: bus answers; reattached`);
return { id, outcome };
}
try { trail({ by: BY, action: 'bus.revive', org: id, outcome: 'requested' }); }
catch { log(`bus revive: ${id}: the action trail could not be written; nothing was recovered`); return { id, outcome: 'trail-unavailable' }; }
const note = outcome => { try { trail({ by: BY, action: 'bus.revive', org: id, outcome, ...(outcome === 'failed' ? { recovered: true } : {}) }); } catch { log(`bus revive: ${id}: trail write failed after ${outcome}`); } };
try {
const receipt = await busRevive(ctx, id, { pm: false });
note('done');
log(`bus revive: ${id}: dead bus host cleared; bus started (pid ${receipt.pid})`);
if (receipt.pmDropped) log(`bus revive: ${id}: the dead host was started with --pm; the chief was not relaunched. mosaic bus stop ${id}; mosaic bus start ${id} --pm brings it back`);
return { id, outcome: 'revived', pid: receipt.pid };
} catch (e) {
// The reason names the organization id and pids only, never a host path (busRecover's and
// busStart's messages); an error that is not a CliError shows its code, not its text.
const reason = isCliError(e) ? e.message : `unexpected error${typeof e?.code === 'string' ? ` ${e.code}` : ''}`;
if (e?.recovered) {
note('failed');
log(`bus revive: ${id}: dead bus host cleared, but the bus did not start (${reason}); see mosaic bus status ${id}`);
return { id, outcome: 'failed' };
}
note('refused');
log(`bus revive: ${id}: refused (${reason}); left for mosaic bus status ${id}`);
return { id, outcome: 'refused' };
}
}
+36 -3
View File
@@ -7,6 +7,7 @@ import { busCaller, busBusiness, userCaller, consoleCall } from './bus.mjs';
import { organizationConsole } from './organizations.mjs';
import { consoleBus, userOptionsFor, agentCaller, TESTER_AGENT_VERBS } from './console-bus.mjs';
import { SYSTEM_TYPE_VERBS } from './system-types-http.mjs';
import { autonomyCaller } from './autonomy-http.mjs';
import { consoleAccess } from './access.mjs';
import { accessContextFor } from '../../cli/src/access.mjs';
import { accessFilePath, loadAccess } from '../../organization/src/access.mjs';
@@ -14,7 +15,7 @@ import { startTesterSocket } from './tester-socket.mjs';
import { TESTER_CLI } from '../../cli/src/transport.mjs';
import { views } from '../../bus/src/views.mjs';
import { loadSystem, socketPath, orgPaths, REPO, rolesDir } from '../../cli/src/config.mjs';
import { busStatus as orgBusStatus } from '../../cli/src/org-bus.mjs';
import { busStatus as orgBusStatus, busRevive as orgBusRevive } from '../../cli/src/org-bus.mjs';
import { listOrgs, resolveOrg } from '../../organization/src/org-registry.mjs';
import { showOrg } from '../../organization/src/org-service.mjs';
import { configDir } from '../../organization/src/organization-config.mjs';
@@ -28,6 +29,9 @@ import { readFileSync, statSync } from 'node:fs';
import { fileURLToPath } from 'node:url';
import { isAbsolute, join } from 'node:path';
import { actionTrail } from './action-trail.mjs';
import { reviveBuses } from './bus-revive.mjs';
import { createProxyAdmin, orgCatalogue } from './proxy-admin.mjs';
import { busesStopped } from '../../cli/src/proxy-admin-host.mjs';
import { agentLevelsWriteRoutes } from './agent-levels-write.mjs';
import { RESTART_EXIT } from '../../cli/src/org-console.mjs';
import { ID_PATTERN } from '../../organization/src/vocabulary.mjs';
@@ -47,7 +51,7 @@ if (process.platform === 'linux') {
}
}
const args = process.argv.slice(2);
const usage = 'node packages/webui/src/cli.mjs serve [--port N] [--tester-socket] [--checkout PATH] [--board http://127.0.0.1:7331] [--business ID] [--sso | --dev-local-owner [--dev-insecure-bind ADDR --dev-allow-host NAME[,NAME...]]] [--send] [--supervised] [--public-host NAME --auth-header HEADER --allow-user ID [--proof-header NAME --proof-file PATH]]';
const usage = 'node packages/webui/src/cli.mjs serve [--port N] [--tester-socket] [--checkout PATH] [--board http://127.0.0.1:7331] [--business ID] [--sso | --dev-local-owner [--dev-insecure-bind ADDR --dev-allow-host NAME[,NAME...]]] [--send] [--supervised] [--proxy-unit-dir PATH] [--public-host NAME --auth-header HEADER --allow-user ID [--proof-header NAME --proof-file PATH]]';
try {
if (args.length === 1 && ['--help', '-h'].includes(args[0])) { console.log(usage); process.exit(0); }
if (args.shift() !== 'serve') throw new Error(usage);
@@ -98,6 +102,11 @@ try {
if (devBind !== null) throw new Error('--dev-insecure-bind takes one address');
devBind = value;
} else if (flag === '--dev-allow-host') devNames.push(value);
// #1873: where the Proxy page installs the proxy user unit; proxy-service.sh's default otherwise.
else if (flag === '--proxy-unit-dir') {
if (!isAbsolute(value)) throw new Error('--proxy-unit-dir must be an absolute path');
options.proxyUnitDir = value;
}
else throw new Error(`unknown option: ${flag}`);
}
const stamp = assertBuildStart({ serve: true, sso: ssoOn, devOwner, devBind, devNames });
@@ -182,12 +191,33 @@ try {
// Phase P2 (#1873): the owner's System types writes, the organization's enable switch (plugins.set
// with its revision) and a project's selection, over the owner's human transport under --send only.
systemTypesWrite: session.user === undefined && sendOn ? agentCaller(owner, SYSTEM_TYPE_VERBS) : null,
// D1 (#1873): the owner's autonomy writes (save, review, confirm, reverse) on this organization's bus.
// These options exist only on a --dev-local-owner Console (`organizations` above). The fence passes
// those four verbs and nothing else the console transport carries.
autonomyWrite: session.user === undefined && sendOn ? autonomyCaller(consoleCall({ dataRoot: root, socket: paths.socket, business: id })) : null,
reads: consoleReads({ root: REPO, queueRoot: options.checkout ?? REPO, system, configDir: configDir(), rolesDir: rolesDir(), business: id, orgRoot: root }),
};
},
}) : null;
// #1873 A1: the root agent level, read fresh on each request; the Agents grid's only new read.
const agentLevels = organizations ? { loadRoot: () => loadRootAgents(configDir(), { rolesDir: rolesDir() }), rolesDir: rolesDir(), ctx: { configDir: configDir(), dataRoot: system.dataRoot, instance } } : null;
// #1873: proxy setup for the instance owner (dev local owner, or the root owner under --sso).
// Instance-wide: one pinned release, one proxy.json, one unit. Each organization uses it (shared).
const proxyAdmin = system && (devOwner || ssoOn) ? createProxyAdmin({
dataRoot: system.dataRoot, configDir: configDir(), instance, unitDir: options.proxyUnitDir ?? null,
buses: () => busesStopped({ dataRoot: system.dataRoot, configDir: configDir() }),
orgs: async () => {
const catalogue = orgCatalogue;
if (!organizations) return [{ id: options.business ?? null, proxy: 'shared', catalogue: await catalogue(ownerCall ?? (async () => { throw new Error(); })) }];
const out = [];
for (const org of await listOrgs({ configDir: configDir(), dataRoot: system.dataRoot, instance })) {
if (org.state !== 'active') continue;
let paths; try { paths = orgPaths(org.root); } catch { out.push({ id: org.id, proxy: 'shared', catalogue: { status: 'bus-unavailable', models: null } }); continue; }
out.push({ id: org.id, proxy: 'shared', catalogue: await catalogue(busCaller({ dataRoot: org.root, socket: paths.socket, business: org.id, timeoutMs: 5000 })) });
}
return out;
},
}) : null;
let server, testerServer = null;
// F3: the restart route closes every listener and connection, then exits RESTART_EXIT for the
// supervisor to relaunch. Without --supervised the route answers not-supervised.
@@ -201,7 +231,7 @@ try {
// #1873 A2: Settings > Agents writes for the root and project levels, beside the levels read.
const agentLevelRoutes = organizations ? [agentLevelsWriteRoutes({ configDir: configDir(), rolesDir: rolesDir(), trail })] : [];
try {
server = await startServer({ restart, trail, ...(lan ? { host: lan.bind, devAllowHosts: lan.allowHosts } : {}), board: options.board, port: options.port, bus, reads, preview: options.preview, sso, proof, send, settingsWrite, projectFiles, projectFile, libraryFile, libraryStatus, librarySync, libraryRevert, librarySave, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, proxyCall, processing, decisionWrite, requestRead, requestWrite, userOptions, federation: system?.federation ?? null, organizations, agentLevels, routes: agentLevelRoutes, preselect: options.business ?? null });
server = await startServer({ restart, trail, ...(lan ? { host: lan.bind, devAllowHosts: lan.allowHosts } : {}), board: options.board, port: options.port, bus, reads, preview: options.preview, sso, proof, send, settingsWrite, projectFiles, projectFile, libraryFile, libraryStatus, librarySync, libraryRevert, librarySave, projectSave, chat, nameWrite, assistantStop, instancesWrite, routesWrite, agentWrite, proxyCall, proxyAdmin, processing, decisionWrite, requestRead, requestWrite, userOptions, federation: system?.federation ?? null, organizations, agentLevels, routes: agentLevelRoutes, preselect: options.business ?? null });
} catch (err) { sso?.close(); throw err; }
if (testerSocket) {
const call = busCaller({ dataRoot: system.dataRoot, socket: socketPath(system.dataRoot), business: options.business ?? null, cli: TESTER_CLI });
@@ -235,6 +265,9 @@ try {
console.log(`Listening: ${lan.bind}:${port}, allowed Host names ${lan.allowHosts.map(name => `${name}:${port}`).join(', ')}`);
}
console.log(`Mosaic Console: http://${urlHost}:${port}/\nBoard: ${options.board}\n${busLine}${queueLine}${previewLine}${ssoLines}${proofLine}${testerServer ? `\nTester Console: unix:${testerServer.address()} (binds the tester)` : ''}\nCtrl-C stops this server.`);
// D2 (P0-3): the dev Console reattaches to the buses that still run and brings back the ones its
// unit stop killed. It runs after the Console answers, so a slow bus never delays the page.
if (organizations && trail) reviveBuses({ ctx: { configDir: configDir(), dataRoot: system.dataRoot, instance }, listOrgs, busStatus: orgBusStatus, busRevive: orgBusRevive, trail, log: line => console.log(line) }).catch(() => console.log('bus revive: stopped on an unexpected error'));
for (const signal of ['SIGINT', 'SIGTERM']) process.once(signal, () => { sso?.close(); server.close(); testerServer?.close(); });
} catch (err) {
console.error(`refused: ${err.message}`);
+3 -1
View File
@@ -10,7 +10,9 @@ import { views } from '../../bus/src/views.mjs';
export const AGENT_VERBS = ['instance.launch', 'instance.regenerate', 'instance.end', 'veto.set', 'instance.request'];
// P4 owner transport only; the HTTP boundary and broker both refuse users/testers.
export const PROXY_VERBS = Object.freeze(['proxy.accounts', 'proxy.login.start', 'proxy.login.submit',
'proxy.login.status', 'proxy.login.cancel', 'proxy.apikey.add', 'proxy.priority.set', 'proxy.group.models.set', 'proxy.keys', 'plugins', 'plugins.set']);
'proxy.login.status', 'proxy.login.cancel', 'proxy.apikey.add', 'proxy.priority.set', 'proxy.group.models.set', 'proxy.keys', 'plugins', 'plugins.set',
// #1873: proxy setup from the page. The broker gates both to the human owner.
'proxy.init', 'proxy.fence.clear']);
export const TESTER_AGENT_VERBS = ['instance.launch', 'instance.end', 'instance.request'];
// U4b (#1873): the task board's writes, the person's built-in store verbs (broker tasks.*). The
// caller refuses any other, so the board's taskWrite never reaches unarchive or delete.
+22 -1
View File
@@ -42,6 +42,21 @@ const BUS_CODES = {
'decision-closed': 'decision-closed', 'decision-stale': 'decision-stale', 'invalid-choice': 'decision-stale',
'invalid-request': 'invalid-request', 'hire-owner-required':'hire-owner-required','right-required':'right-required','decision-expired':'decision-expired','hire-resolution-required':'hire-resolution-required',
};
// D4 (#1873, P1-2): a hire, fire or change start the bus definitely refused keeps its own code,
// but only for codes the broker raises before it records the decision: the proposal checks in
// hire-decisions.mjs prepare(), which #raiseHire runs before its INSERT. hire.start.console then
// resolves the decision and applies it in #hireConsole, and those steps can raise other codes (and
// some of the same generic ones) after the decision exists or is consumed, so every code not listed
// here stays outcome-unknown. Review P3: an allowlist, not a pass-through of any well-formed code.
const HIRE_START_REFUSALS = {
'model-provider-unavailable': [409, 'The model proxy does not offer this model, or its catalogue is not ready. Pick a model it offers, or ask the owner to check Settings > Model proxy. Nothing was applied.'],
'model-catalogue-unavailable': [503, 'The model catalogue could not be read, so this model could not be checked. Check Settings > Model proxy, then try again. Nothing was applied.'],
'model-harness-incompatible': [409, 'This model does not run on the chosen harness. Pick another model or harness. Nothing was applied.'],
'thinking-unsupported': [409, 'The chosen harness does not apply this thinking level. Clear Thinking or pick a level it applies. Nothing was applied.'],
'definition-not-hireable': [409, 'This organization does not hire agents from this role template. Pick another template. Nothing was applied.'],
'definition-shared': [409, 'Another agent uses the same role definition, so this change cannot replace it. Nothing was applied.'],
'no-chief-of-staff': [409, 'This organization has no chief of staff, so a hire, fire or change cannot be checked against it. Nothing was applied.'],
};
const BODY_MAX = 4096;
const TOKEN = /^[a-f0-9]{64}$/;
const DIGEST = /^[a-f0-9]{64}$/;
@@ -136,7 +151,13 @@ export async function decisionHTTP(req, res, { path, decide, session, live, conf
if(step==='templates' && !Array.isArray(result?.templates))return refuse('confirmation-unavailable');
line(step==='start'?'hire=recorded':'templates=read');
return json(res,200,step==='start'?{id:result.id,op:result.op,choice:result.choice}:result);
} catch(err) {return refuse(busCode(err,step==='start'?'outcome-unknown':'confirmation-unavailable'));}
} catch(err) {
if(step!=='start' || !(err instanceof BusReadError) || !Object.hasOwn(HIRE_START_REFUSALS,err.code))
return refuse(busCode(err,step==='start'?'outcome-unknown':'confirmation-unavailable'));
const [status,message]=HIRE_START_REFUSALS[err.code];
line(`error=${err.code}`);
return json(res,status,{error:err.code,message});
}
}
if (step === 'confirmation') {
if (Object.keys(value).some(k=>!['choice','adjust'].includes(k)) || typeof value.choice !== 'string') return refuse('invalid-request');
+150 -4
View File
@@ -16,7 +16,10 @@
// POST /api/orgs/ID/setup/queue C2 queueInit mosaic org queue init
// POST /api/orgs/ID/setup/project/P C2 projectRepoInit mosaic org project repo init
// POST /api/orgs/ID/setup/bus C3 busStart mosaic bus start --detach
// POST /api/orgs/ID/restart/bus C3 busRestart mosaic bus restart (F3; busStart when stopped)
// POST /api/orgs/ID/restore C2 restoreOrg, then C3 busStart (E1: the answer's `bus`)
// POST /api/orgs/ID/restart/bus C3 busRestart mosaic bus restart (F3; busStart when stopped;
// busRevive for a host that died, dev-local owner only, D2)
// POST /api/orgs/ID/stop/bus C3 busStop mosaic bus stop (a stopped bus answers state stopped)
//
// GET /api/orgs (the list) and other /api/orgs/ID/... routes are S5's; this
// handler returns false for them.
@@ -27,12 +30,14 @@ import { checkProject, initProject, projectRepo, recordContext } from '../../pro
import { refuseInsideAgent } from '../../cli/src/transport.mjs';
import { join } from 'node:path';
import { actor } from './action-trail.mjs';
import { DEV_OWNER } from '../../cli/src/dev-local-owner.mjs';
import { revivePending } from './bus-revive.mjs';
const ID = /^[a-z0-9][a-z0-9._-]{0,63}$/;
const KEY = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
const ENV_NAME = /^[A-Z][A-Z0-9_]{0,63}$/;
const MAX_BODY = 64 * 1024;
const ROUTE = /^\/api\/orgs\/([^/]+)\/(admin|setup|delete|archive|restore|restart)(?:\/(queue|bus|project\/([^/]+)|records\/([^/]+)))?$/;
const ROUTE = /^\/api\/orgs\/([^/]+)\/(admin|setup|delete|archive|restore|restart|stop)(?:\/(queue|bus|project\/([^/]+)|records\/([^/]+)))?$/;
// The org-template placeholder (packages/organization/src/org-service.mjs templateRender).
export const placeholderRoot = (id, project) => `/__mosaic_org__/${id}/projects/${project}`;
// Project fields the server owns. The browser never sees or sends them. `agents` (#1873 A1) is
@@ -51,6 +56,8 @@ export function verbFor(step, id, opts = {}) {
if (step === 'records') return `mosaic project init ${id} ${opts.project}`;
if (step === 'bus') return `mosaic bus start ${id} --dev-local-owner --detach`;
if (step === 'bus-restart') return `mosaic bus restart ${id}`;
if (step === 'bus-stop') return `mosaic bus stop ${id}`;
if (step === 'bus-revive') return `mosaic bus recover ${id}; mosaic bus start ${id} --dev-local-owner --detach`;
throw new Error(`unknown step ${step}`);
}
@@ -242,6 +249,10 @@ export function toCanonical(id, doc, stored = null) {
};
for (const [name, role] of Object.entries(stored?.roles ?? {})) if (out.roles?.[name] && role?.credentials) { out.roles[name].credentials ??= {}; restore(out.roles[name].credentials, role.credentials); }
if (stored?.tracker?.sync?.credentials && out.tracker?.sync) { out.tracker.sync.credentials ??= {}; restore(out.tracker.sync.credentials, stored.tracker.sync.credentials); }
// #1873 CB1: copy bindings are server-held on an update. Only an owner-approved binding decision
// changes them, so the stored ones go back whatever the browser sent. A create passes the
// browser's value through and the service refuses any binding there.
if (stored) { if (Object.hasOwn(stored, 'copyBindings')) out.copyBindings = structuredClone(stored.copyBindings); else delete out.copyBindings; }
return out;
}
@@ -455,7 +466,9 @@ async function route(req, res, { path, search, org, admin, principal, write, log
const fn = kind === 'archive' ? service.archiveOrg : service.restoreOrg;
if (typeof fn !== 'function') return json(res, 503, { error: 'not-configured', step: kind });
const receipt = await fn(ctx, id, { ifRevision: body.revision, ...(busStop ? { busStop } : {}) });
return json(res, 200, { outcome: 'done', step: kind, receipt: forReceipt(ctx, receipt), verb: verbFor(kind, id, { revision: body.revision }) });
const done = { outcome: 'done', step: kind, receipt: forReceipt(ctx, receipt), verb: verbFor(kind, id, { revision: body.revision }) };
if (kind === 'archive') return json(res, 200, done);
return json(res, 200, { ...done, bus: await restoreBus({ id, ctx, admin, log, trail, by }) });
} catch (e) { return refusal(res, e, { step: kind, id, unknownOk: true }); }
}
if (kind === 'delete' && !sub && write) {
@@ -514,6 +527,7 @@ async function route(req, res, { path, search, org, admin, principal, write, log
try { return json(res, 200, await readSetup(admin, ctx, id)); } catch (e) { return refusal(res, e, { step: 'setup', id }); }
}
if (kind === 'restart') return restartBus(req, res, { sub, write, id, ctx, service, admin, log, trail, by });
if (kind === 'stop') return stopBus(req, res, { sub, write, id, ctx, service, admin, log, trail, by });
if (kind !== 'setup' || !sub || !write) return json(res, 404, { error: 'not found' });
// Every setup write reads the step back first and never overwrites.
@@ -560,10 +574,51 @@ async function route(req, res, { path, search, org, admin, principal, write, log
return json(res, 404, { error: 'not found' });
}
// E1 (P1-4): Restore brings the organization back running. After a good restore the bus starts as
// Restart bus starts a stopped one: the owner, origin and --send checks were made for the restore,
// the trail line is written first, and a trail that cannot be written starts nothing. The restore
// stands whatever happens here; the answer's `bus` says what did, and the page offers Restart bus
// when the bus did not start. Never throws.
async function restoreBus({ id, ctx, admin, log, trail, by }) {
const verb = verbFor('bus', id);
const notStarted = (error, message) => ({ outcome: 'not-started', error, message, verb });
let bus = null;
try { bus = await admin.bus(); } catch { /* reported as not configured */ }
if (!bus || typeof bus.busStart !== 'function' || typeof bus.busStatus !== 'function') return notStarted('not-configured', 'bus control is not available on this Console, so the bus was not started');
if (!trail) return notStarted('trail-unavailable', 'this Console has no action trail, so it does not start buses');
let root = null;
try {
const status = await bus.busStatus(ctx, id);
if (status?.healthy) return { outcome: 'done', existing: true, healthy: true, receipt: forReceipt(ctx, status), verb };
root = status?.root ?? null;
} catch (e) {
if (!isCliError(e)) { log(`org-admin: ${id} restore: bus status: ${e?.code ?? e?.name ?? 'error'}`); return notStarted('bus-refused', 'the bus state could not be read, so the bus was not started'); }
return notStarted('bus-refused', `${plainMessage(ctx, e.message)}. Inspect it with mosaic bus status ${id}; mosaic bus recover ${id} clears a host that died.`);
}
try { trail({ by, action: 'bus.start', org: id, outcome: 'requested', after: 'restore' }); }
catch { return notStarted('trail-unavailable', 'the action trail could not be written, so the bus was not started'); }
const note = outcome => { try { trail({ by, action: 'bus.start', org: id, outcome, after: 'restore' }); } catch { log(`org-admin: ${id} restore bus start: trail write failed after ${outcome}`); } };
let receipt;
try { receipt = await bus.busStart(ctx, id, { detach: true, devLocalOwner: by?.provider === 'local-dev', pm: false }); }
catch (e) {
const failed = isCliError(e) && /^bus failed to start\b/.test(e.message);
note(failed ? 'failed' : 'refused');
if (!isCliError(e)) { log(`org-admin: ${id} restore bus start: ${e?.code ?? e?.name ?? 'error'}`); return { outcome: 'failed', error: 'bus-failed', message: 'the bus did not start', verb }; }
const said = failed ? await hostRefusal(root) : null;
return { outcome: 'failed', error: failed ? 'bus-failed' : 'bus-refused', message: plainMessage(ctx, said ? `${e.message}. The bus host said: ${said}` : e.message), verb };
}
note('done');
log(`org-admin: ${id} bus started after restore`);
let healthy = null;
try { healthy = (await bus.busStatus(ctx, id)).healthy; } catch { /* the receipt still stands */ }
return { outcome: 'done', healthy, receipt: forReceipt(ctx, receipt), verb };
}
// F3 (REQ-WEB-3): one control restarts an organization's bus and answers with a done receipt. A
// running host restarts on the options it was started with (C3 busRestart keeps --dev-local-owner);
// a stopped one starts as setup starts it. The trail line is written before anything changes, and
// a trail that cannot be written refuses the restart. Stale host state is never cleared here.
// a trail that cannot be written refuses the restart. Stale host state is cleared only for the
// dev-local owner, and only through busRevive (D2), whose busRecover refuses a host it cannot prove dead.
async function restartBus(req, res, { sub, write, id, ctx, service, admin, log, trail, by }) {
const step = 'bus-restart';
if (sub !== 'bus' || !write) return json(res, 404, { error: 'not found' });
@@ -571,11 +626,14 @@ async function restartBus(req, res, { sub, write, id, ctx, service, admin, log,
try { await service.showOrg(ctx, id); } catch (e) { return refusal(res, e, { step, id }); }
const bus = await need(admin, 'bus', res, step); if (!bus) return;
if (!trail) return json(res, 503, { error: 'trail-unavailable', step, message: 'this Console has no action trail, so it does not restart buses' });
// D2: the dev Console's start-up revive may be bringing this bus back; the press acts on the result.
await revivePending(id);
let status;
try { status = await bus.busStatus(ctx, id); }
catch (e) {
if (['org-unknown', 'org-mismatch', 'org-unavailable', 'org-archived'].includes(e?.code)) return refusal(res, e, { step, id });
if (isCliError(e)) {
if (isDevOwner(by) && typeof bus.busRevive === 'function') return reviveBus(res, { id, ctx, bus, log, trail, by, step });
// Refused before anything changed, but the owner asked: the trail records the attempt.
try { trail({ by, action: 'bus.restart', org: id, outcome: 'refused', running: null }); } catch { log(`org-admin: ${id} bus restart: trail write failed after refused`); }
return json(res, 409, { error: 'bus-refused', step, message: `${plainMessage(ctx, e.message)}. Inspect it with mosaic bus status ${id}; mosaic bus recover ${id} clears a host that died.` });
@@ -600,3 +658,91 @@ async function restartBus(req, res, { sub, write, id, ctx, service, admin, log,
try { healthy = (await bus.busStatus(ctx, id)).healthy; } catch { /* the receipt still stands */ }
return json(res, 200, { outcome: 'done', step, restarted: running, healthy, receipt: forReceipt(ctx, receipt), verb: running ? verbFor('bus-restart', id) : verbFor('bus', id) });
}
// Stop bus: the same owner, origin, --send and trail rules as Restart bus. C3 busStop sends SIGTERM
// to the proven host and waits for it to exit; the host's close() removes host.json and host.lock,
// and the broker's store removes writer.lock, so a clean stop leaves no lock behind (an offline
// migrate-schema run can follow). The bus state is read back after the stop: a host or lock still
// there, or state that does not check out, is a failure, not a done receipt. A host whose state
// cannot be proven refuses before anything changes; there is no recovery path here, unlike Restart
// bus on the dev Console. A bus that is already stopped answers done with state stopped.
async function stopBus(req, res, { sub, write, id, ctx, service, admin, log, trail, by }) {
const step = 'bus-stop';
if (sub !== 'bus' || !write) return json(res, 404, { error: 'not found' });
const body = await readBody(req); strictKeys(body, []);
try { await service.showOrg(ctx, id); } catch (e) { return refusal(res, e, { step, id }); }
const bus = await need(admin, 'bus', res, step); if (!bus) return;
if (typeof bus.busStop !== 'function') return json(res, 503, { error: 'not-configured', step, message: 'bus control cannot stop a bus on this Console yet' });
if (!trail) return json(res, 503, { error: 'trail-unavailable', step, message: 'this Console has no action trail, so it does not stop buses' });
// The dev Console's start-up revive may be bringing this bus back; the press acts on the result.
await revivePending(id);
let status;
try { status = await bus.busStatus(ctx, id); }
catch (e) {
if (['org-unknown', 'org-mismatch', 'org-unavailable', 'org-archived'].includes(e?.code)) return refusal(res, e, { step, id });
if (isCliError(e)) {
// Refused before anything changed, but the owner asked: the trail records the attempt.
try { trail({ by, action: 'bus.stop', org: id, outcome: 'refused', running: null }); } catch { log(`org-admin: ${id} bus stop: trail write failed after refused`); }
return json(res, 409, { error: 'bus-refused', step, message: `${plainMessage(ctx, e.message)}. Inspect it with mosaic bus status ${id}; mosaic bus recover ${id} clears a host that died.` });
}
return refusal(res, e, { step, id });
}
const running = Number.isSafeInteger(status?.pid);
try { trail({ by, action: 'bus.stop', org: id, outcome: 'requested', running }); }
catch { return json(res, 500, { error: 'trail-unavailable', step, message: 'the action trail could not be written, so nothing was stopped' }); }
const note = outcome => { try { trail({ by, action: 'bus.stop', org: id, outcome }); } catch { log(`org-admin: ${id} bus stop: trail write failed after ${outcome}`); } };
if (!running) {
note('done');
return json(res, 200, { outcome: 'done', step, stopped: false, state: 'stopped', receipt: { org: id, stopped: false }, verb: verbFor('bus-stop', id) });
}
let receipt;
try { receipt = await bus.busStop(ctx, id); }
catch (e) {
// Only exit 3 comes before the signal; a timeout or an unexpected error may follow SIGTERM.
note(isCliError(e) && e.exitCode === 3 ? 'refused' : 'failed');
if (!isCliError(e)) return refusal(res, e, { step, id, unknownOk: true });
return json(res, 409, { error: e.exitCode === 3 ? 'bus-refused' : 'bus-failed', step, message: `${plainMessage(ctx, e.message)}. Inspect it with mosaic bus status ${id}.` });
}
// Read the state back: the stop is done only when no host and no lock is left.
let after = null, left = 'a bus host still runs';
try { after = await bus.busStatus(ctx, id); }
catch (e) { if (!isCliError(e)) { note('failed'); return refusal(res, e, { step, id, unknownOk: true }); } left = plainMessage(ctx, e.message); }
if (!after || Number.isSafeInteger(after.pid)) {
note('failed');
return json(res, 409, { error: 'bus-failed', step, message: `the bus host was stopped, but ${left}. Inspect it with mosaic bus status ${id}; mosaic bus recover ${id} clears a host that died.` });
}
note('done');
log(`org-admin: ${id} bus stopped`);
// A host that exited on its own between the read and the stop was not stopped by this press.
return json(res, 200, { outcome: 'done', step, stopped: receipt?.stopped === true, state: 'stopped', receipt: forReceipt(ctx, { org: id, ...receipt }), verb: verbFor('bus-stop', id) });
}
// The dev-local owner exactly (packages/cli/src/dev-local-owner.mjs). A signed-in session carries
// its provider's issuer, so no SSO user matches.
const isDevOwner = by => by?.provider === DEV_OWNER.provider && by.iss === DEV_OWNER.iss && by.sub === DEV_OWNER.sub;
// D2 (P0-3): the dev Console's Restart bus on a bus whose state did not check out. A Console unit
// stop kills the hosts the Console started and leaves their state behind; busRevive clears that
// state only when busRecover proves the host dead, then starts the bus on its recorded options.
// A live or unprovable host, or a lock that is not the dead host's, refuses and nothing changes.
async function reviveBus(res, { id, ctx, bus, log, trail, by, step }) {
try { trail({ by, action: 'bus.restart', org: id, outcome: 'requested', running: false, recover: true }); }
catch { return json(res, 500, { error: 'trail-unavailable', step, message: 'the action trail could not be written, so nothing was recovered' }); }
const note = outcome => { try { trail({ by, action: 'bus.restart', org: id, outcome, recover: true }); } catch { log(`org-admin: ${id} bus recover: trail write failed after ${outcome}`); } };
let receipt;
try { receipt = await bus.busRevive(ctx, id); }
catch (e) {
// busRevive marks a failure after busRecover cleared the dead host's state (e.recovered).
const cleared = Boolean(e?.recovered);
note(cleared ? 'failed' : 'refused');
if (!cleared && e?.code === 'org-locked') return json(res, 409, { error: 'bus-busy', step, message: `another bus or lifecycle action on ${id} is running, and nothing was changed. Press Restart bus again when it ends.` });
if (cleared) return json(res, 409, { error: 'bus-failed', step, message: isCliError(e) ? plainMessage(ctx, `The dead host's state was cleared, but ${e.message}`) : `The dead host's state was cleared, but the bus did not start. Check it with mosaic bus status ${id}.` });
if (!isCliError(e)) return refusal(res, e, { step, id, unknownOk: true });
return json(res, 409, { error: 'bus-refused', step, message: `${plainMessage(ctx, e.message)}. Inspect it with mosaic bus status ${id}.` });
}
note('done');
log(`org-admin: ${id} bus recovered and started`);
let healthy = null;
try { healthy = (await bus.busStatus(ctx, id)).healthy; } catch { /* the receipt still stands */ }
return json(res, 200, { outcome: 'done', step, restarted: false, recovered: true, healthy, receipt: forReceipt(ctx, receipt), verb: verbFor('bus-revive', id) });
}
+114
View File
@@ -0,0 +1,114 @@
// #1873 model proxy setup from the Console: the owner-only HTTP routes. Same checks as
// proxy-http.mjs. The controller (packages/model-proxy/src/admin-control.mjs) runs host
// actions; init and the fence clear go to the bus, which decides them. No key, token, path or
// upstream text reaches a response, receipt or log line.
import { isIP } from 'node:net';
import { validManagementKey, proxyTiming } from '../../model-proxy/src/common.mjs';
import { createProxyAdmin, proxyCode } from '../../model-proxy/src/admin-control.mjs';
export { createProxyAdmin };
// #1873: one organization's catalogue row for the setup page. A count is shown only when the bus
// answered with a model list; otherwise models is null and the page shows a dash, never a made-up 0.
export async function orgCatalogue(call) {
try {
const value = await call('routes');
return { status: value?.catalogue?.status ?? 'unknown', models: Array.isArray(value?.catalogue?.models) ? value.catalogue.models.length : null };
} catch { return { status: 'bus-unavailable', models: null }; }
}
const ROUTES = Object.freeze({
'/api/proxy/admin/status': ['GET', []],
'/api/proxy/admin/unit/render': ['GET', []],
'/api/proxy/admin/fetch': ['POST', []],
'/api/proxy/admin/init': ['POST', null],
'/api/proxy/admin/unit/install': ['POST', []],
'/api/proxy/admin/unit/start': ['POST', []],
'/api/proxy/admin/unit/stop': ['POST', []],
'/api/proxy/admin/unit/restart': ['POST', []],
'/api/proxy/admin/fence/clear': ['POST', []],
'/api/proxy/admin/init-lock/clear': ['POST', []],
});
export const PROXY_ADMIN_PATHS = Object.freeze(Object.keys(ROUTES));
const INIT_KEYS = ['generate', 'key', 'confirm', 'port', 'rotateGraceMinutes', 'firstOutputSeconds'];
const json = (res, status, value) => {
res.writeHead(status, { 'content-type': 'application/json', 'cache-control': 'no-store' }); res.end(JSON.stringify(value));
};
const loopbackName = name => name === 'localhost' || name === '[::1]' || name === '::1' || (isIP(name) === 4 && name.startsWith('127.'));
const loopbackAddress = address => typeof address === 'string' && (address === '::1' || /^(::ffff:)?127\./.test(address));
// A pasted management key crosses only HTTPS or loopback. A plain-HTTP LAN page gets the generated key.
export function keyTransportOk(origin, remoteAddress) {
let url; try { url = new URL(origin); } catch { return false; }
if (url.protocol === 'https:') return true;
return url.protocol === 'http:' && loopbackName(url.hostname) && loopbackAddress(remoteAddress);
}
const STATUS = { 'human-required': 403, 'proxy-key-transport': 403, 'not-configured': 503, 'proxy-down': 503, 'outcome-unknown': 503, 'proxy-admin-busy': 409 };
// `owner` is true only for the instance owner's session (the dev local owner, or the root owner
// signed in under --sso). `call` is the owner's transport to the selected bus, or null.
export async function proxyAdminHTTP(req, res, { path, admin, call, named, tester, granted, owner, send, origin, user, log }) {
const route = ROUTES[path];
const done = (status, value) => {
// Path, method and status only: never a body, key, evidence or exception text.
log(`${new Date().toISOString()} ${route?.[0] ?? req.method} ${route ? path : '/api/proxy/admin/unknown'} ${status} user=${JSON.stringify(user ?? '')}`);
return json(res, status, value);
};
const refuse = code => done(STATUS[code] ?? 400, { error: code });
if (tester || granted || !named || owner !== true) return refuse('human-required');
if (!route) return done(404, { error: 'not found' });
if (req.method !== route[0]) return done(405, { error: 'method not allowed' });
if (req.method === 'POST') {
if (!send) return done(403, { error: 'read-only' });
if (req.headersDistinct.origin?.length !== 1 || req.headers.origin !== origin) return done(403, { error: 'cross-origin request refused' });
if (req.headersDistinct['sec-fetch-site']?.length !== 1 || req.headers['sec-fetch-site'] !== 'same-origin') return done(403, { error: 'cross-site request refused' });
}
if (!admin) return refuse('not-configured');
let args = {};
if (req.method === 'POST') {
try {
if ((req.headers['content-type'] ?? '').split(';')[0].trim().toLowerCase() !== 'application/json') throw Error();
const chunks = []; let size = 0;
for await (const chunk of req) { size += chunk.length; if (size > 16384) throw Error(); chunks.push(chunk); }
args = JSON.parse(Buffer.concat(chunks));
if (!args || typeof args !== 'object' || Array.isArray(args)) throw Error();
const allowed = route[1] ?? INIT_KEYS;
if (Object.keys(args).some(k => !allowed.includes(k))) throw Error();
} catch { return refuse('invalid-request'); }
}
try {
switch (path) {
case '/api/proxy/admin/status': return done(200, await admin.status());
case '/api/proxy/admin/unit/render': return done(200, await admin.render());
case '/api/proxy/admin/fetch': return done(202, admin.fetchStart());
case '/api/proxy/admin/init': {
const timing = {};
for (const field of ['port', 'rotateGraceMinutes', 'firstOutputSeconds']) if (Object.hasOwn(args, field)) timing[field] = args[field];
if (Object.hasOwn(timing, 'port') && (!Number.isInteger(timing.port) || timing.port < 1024 || timing.port > 65535 || timing.port === 8317)) return refuse('proxy-config-invalid');
try { proxyTiming(timing); } catch (error) { return refuse(proxyCode(error, 'proxy-config-invalid')); }
let initArgs;
if (args.generate === true && !Object.hasOwn(args, 'key') && !Object.hasOwn(args, 'confirm')) initArgs = { generate: true, ...timing };
else if (!Object.hasOwn(args, 'generate') && Object.hasOwn(args, 'key')) {
// The transport check comes before the key is examined, so a refused key is never judged.
if (!keyTransportOk(origin, req.socket?.remoteAddress)) return refuse('proxy-key-transport');
if (typeof args.key !== 'string' || args.key !== args.confirm) return refuse('proxy-key-mismatch');
if (!validManagementKey(args.key)) return refuse('proxy-key-invalid');
initArgs = { key: args.key, ...timing };
} else return refuse('invalid-request');
if (!call) return refuse('not-configured');
return done(200, await admin.init(call, initArgs));
}
case '/api/proxy/admin/unit/install': return done(200, await admin.unitAction('install'));
case '/api/proxy/admin/unit/start': return done(200, await admin.unitAction('start'));
case '/api/proxy/admin/unit/stop': return done(200, await admin.unitAction('stop'));
case '/api/proxy/admin/unit/restart': return done(200, await admin.unitAction('restart'));
case '/api/proxy/admin/fence/clear':
if (!call) return refuse('not-configured');
return done(200, await admin.fenceClear(call));
case '/api/proxy/admin/init-lock/clear': return done(200, await admin.initLockClear());
}
} catch (error) {
const code = ['human-required', 'not-configured', 'invalid-request'].includes(error?.code) ? error.code : proxyCode(error, 'outcome-unknown');
return refuse(code);
}
return done(404, { error: 'not found' });
}
+10
View File
@@ -0,0 +1,10 @@
[data-autonomy], [data-autonomy-decision]{margin-top:16px}
.autonomy-fields{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:16px;margin:16px 0}
.autonomy-fields label,[data-autonomy-decision] label{display:flex;flex-direction:column;align-items:stretch;gap:6px;min-width:0}
[data-autonomy] :is(select,input:not([type=checkbox])),[data-autonomy-decision] input{width:100%;box-sizing:border-box;min-width:0;min-height:44px;padding:8px 12px;border:1px solid var(--border);border-radius:var(--r);background:var(--canvas);color:var(--text);font:inherit}
[data-autonomy] .autonomy-checkbox{display:flex;align-items:center;gap:10px;min-height:44px;margin:12px 0}
.autonomy-checkbox input{width:18px;height:18px;margin:0;flex:0 0 auto;accent-color:var(--action)}
[data-autonomy] :is(button,select,input,summary):focus-visible,[data-autonomy-decision] :is(button,input):focus-visible{outline:2px solid var(--focus);outline-offset:2px}
[data-autonomy] [role=status],[data-autonomy-decision] [role=status]{overflow-wrap:anywhere}
[data-autonomy] details button{margin:8px 8px 0 0}
@media(max-width:759px){.autonomy-fields{grid-template-columns:1fr}}
+65
View File
@@ -0,0 +1,65 @@
// Owner settings and decision notices. Server and broker establish identity and eligibility.
(() => {
'use strict';
const esc=x=>String(x??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
const classes=['cross-role','merge','policy','authority'],levels=['in the loop','near the loop','out of the loop'];
const labels={'cross-role':'Cross-role work',merge:'Merge',policy:'Policy',authority:'Plain hire, fire and role change'};
const states=new Map(),path=(org,step='')=>`${org?`/api/orgs/${encodeURIComponent(org)}`:'/api'}/autonomy${step?`/${step}`:''}`;
const guidance={
'policy-reverse-unavailable':'Policy has no effect executor or reverse yet. Choose in the loop or out of the loop.',
'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 an actual near-loop decision followed by your review of its trail. If a reverse stopped an agent, inspect the trail before relaunching it.',
'authority-limits-unavailable':'Choose near the loop with a budget covering new agents. Authority out of the loop waits for per-definition agent limits.',
'decision-closed':'This decision is closed. Refresh the inbox; raise a new decision if needed.',
'confirmation-required':'Confirm the arbiter resolution in the Console before using the decision.',
'not-found':'This decision could not be found. Refresh the inbox.',
'storage-refused':'The trail write was refused. Inspect the decision trail before retrying.',
'reverse-window-closed':'The reverse window has closed. Raise an ordinary new decision.',
'revision-conflict':'The organization changed. Reload before saving.',
'not-resolver':'The resolver no longer holds the required right. Withdraw and raise a fresh decision under current policy.',
'reversal-unavailable':'This effect cannot be reversed through this path. Use an ordinary new decision.',
};
async function call(org,step,body) {
const r=await fetch(path(org,step),body===undefined?{cache:'no-store'}:{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(body)});
const value=await r.json();if(!r.ok)throw Error(guidance[value.error]??`The bus refused: ${value.error??'outcome unknown'}. Refresh before retrying.`);return value;
}
async function load(org) {try{states.set(org,await call(org,''));}catch(error){states.set(org,{error:error.message});}}
function section(org) {
const s=states.get(org);
if(!s || s.error)return `<section class="panel" data-autonomy><h3>Autonomy</h3><p>${esc(s?.error??'Autonomy could not be read.')}</p></section>`;
const v=s.autonomy??{};
return `<section class="panel" data-autonomy data-autonomy-org="${esc(org)}"><h3>Autonomy</h3><p>Choose how closely you follow each class of decision. Hard waits, flagged grants and credentials always need a person.</p><form data-autonomy-form>
<div class="autonomy-fields">${classes.map(k=>`<label>${labels[k]}<select name="${k}"${s.writable?'':' disabled'}>${levels.map(l=>`<option${s.levels[k]===l?' selected':''}>${l}</option>`).join('')}</select></label>`).join('')}</div>
<div class="autonomy-fields"><label>Reverse window (hours)<input type="number" name="reverseWindowHours" min="1" max="168" value="${v.reverseWindowHours??24}"${s.writable?'':' disabled'}></label><label>Authority resolver<input name="authorityResolver" maxlength="64" value="${esc(v.authorityResolver??'')}" autocomplete="off"${s.writable?'':' disabled'}></label></div>
<label class="autonomy-checkbox"><input name="notices" type="checkbox"${v.notices===true?' checked':''}${s.writable?'':' disabled'}> Console notices for decisions near the loop</label>
<p>In the loop, the arbiter resolves and you confirm before work takes effect. Near the loop, work takes effect and you can reverse it within the window. Out of the loop, the trail records it without a notice.</p>
${s.writable?'<button class="btn" type="submit">Save autonomy</button>':'<p>Only the owner can change autonomy.</p>'}
<p data-autonomy-status role="status"></p></form>
${s.writable?`<details><summary>Review reversals before moving out of the loop</summary><p>Read the decision trail and its reversals before recording your review.</p>${classes.map(k=>`<button class="btn" type="button" data-autonomy-review="${k}">Record review: ${labels[k]}</button>`).join('')}</details>`:''}</section>`;
}
function notice(d) {
if(!d?.autonomy)return '';
const a=d.autonomy,org=d.business??window.MosaicOrg?.id;
return `<section class="panel" data-autonomy-decision="${esc(d.id)}" data-autonomy-org="${esc(org??'')}"><h2>${a.pending?'Confirm the arbiter resolution':'Decision near the loop'}</h2>
${a.pending?'<p>The arbiter resolved this decision. It has no effect until you confirm.</p><label>Reason for returning to the arbiter<input data-autonomy-reason maxlength="500"></label><p><button class="btn" data-autonomy-confirm="yes">Confirm resolution</button> <button class="btn" data-autonomy-confirm="no">Return to arbiter</button></p>':`<p>The agent resolved this decision. Reverse is available until ${esc(a.reverseUntil)}. A reverse cannot undo messages, work already done or code already shipped.</p><button class="btn" data-autonomy-reverse>Reverse decision</button>`}
<p role="status" data-autonomy-status></p></section>`;
}
document.addEventListener('submit',async e=>{
if(!e.target.matches('[data-autonomy-form]'))return;e.preventDefault();
const form=e.target,section=form.closest('[data-autonomy-org]'),org=section.dataset.autonomyOrg,s=states.get(org),fields=new FormData(form);
const autonomy=Object.fromEntries(classes.map(k=>[k,fields.get(k)]));autonomy.notices=fields.has('notices');autonomy.reverseWindowHours=Number(fields.get('reverseWindowHours'));
if(fields.get('authorityResolver'))autonomy.authorityResolver=fields.get('authorityResolver');
const button=form.querySelector('button[type="submit"]'),status=form.querySelector('[data-autonomy-status]');button.disabled=true;
try{const r=await call(org,'',{revision:s.revision,autonomy});s.revision=r.revision;s.autonomy=autonomy;s.levels=Object.fromEntries(classes.map(k=>[k,autonomy[k]]));status.textContent=`Saved. Receipt ${r.transaction}.`;}
catch(error){status.textContent=error.message;}finally{button.disabled=false;}
});
document.addEventListener('click',async e=>{
const button=e.target.closest('[data-autonomy-confirm],[data-autonomy-reverse],[data-autonomy-review]');if(!button)return;
const section=button.closest('[data-autonomy-org]'),org=section.dataset.autonomyOrg,status=section.querySelector('[data-autonomy-status]');
const review=button.dataset.autonomyReview,reverse=button.hasAttribute('data-autonomy-reverse');
const body=review?{decisionClass:review}:reverse?{id:section.dataset.autonomyDecision}:{id:section.dataset.autonomyDecision,confirm:button.dataset.autonomyConfirm==='yes',...(button.dataset.autonomyConfirm==='no'?{reason:section.querySelector('[data-autonomy-reason]').value}:{})};
button.disabled=true;
try{const r=await call(org,review?'review':reverse?'reverse':'confirm',body);status.textContent=review?'Trail review recorded.':reverse?(r.restart||r.trailPending?'Reversal committed. Completion needs recovery: inspect the decision trail and the stopped agent before requesting relaunch.':'Reversal recorded.'):(body.confirm?'Confirmation recorded. Refresh the inbox for current state.':'Returned to arbiter. Refresh the inbox for current state.');}
catch(error){status.textContent=error.message;}finally{button.disabled=false;}
});
window.MosaicAutonomy={load,section,notice};
})();
+4
View File
@@ -60,6 +60,10 @@ table.s1-roles td,table.s1-roles th { overflow-wrap:normal; word-break:normal; }
table.s1-roles { min-width: 0; width: 100%; table-layout: fixed; }
table.s1-roles td, table.s1-roles th { overflow-wrap: anywhere; }
}
/* D4 (#1873): the hire form on the org-mode workspace inbox. */
html[data-organizations] #hire-start.ws-hire{margin-top:22px}
html[data-organizations] #hire-start.ws-hire h2{margin:0 0 4px;font-size:16px}
html[data-organizations] #hire-start.ws-hire select{min-height:40px;padding:6px 10px;border:1px solid var(--border);border-radius:6px;background:var(--surface);color:var(--text);font:inherit;max-width:100%}
/* A4: use the Console's controls for authority proposals and keep grant labels beside their checkboxes. */
#hire-start form,#hire-adjust{max-width:680px}
#hire-start form>label,#hire-adjust>label{display:grid;gap:5px;margin:12px 0;color:var(--text)}
+49 -21
View File
@@ -112,7 +112,7 @@
const mids = new Set(of(tr, 'messages').filter(m => m.decision === id).map(m => m.id));
const dm = of(tr, 'deliveries').find(x => mids.has(x.message) && x.transport === 'discord-dm' && x.op === 'delivered');
return {
id, at: d.at, class: d.class, action: d.action, routeTo: d.route_to, question: d.question,
id, business:d.business, autonomy:row?.autonomy??null, at: d.at, class: d.class, action: d.action, routeTo: d.route_to, question: d.question,
options: Array.isArray(d.options) ? d.options : [], recommendation: d.recommendation, taskRef: d.task_ref, requirementRef: d.requirement_ref,
blocking: !!d.blocking, raisedBy: { role: d.raised_by_role, run: d.raised_by_run },
authorization: row ? row.authorization : raise ? { action: d.action, target: raise.body.target, approvalChoice: raise.body.approvalChoice } : null,
@@ -302,7 +302,7 @@
// Inbox
function decisionCard(d) {
return `<li class="wait-item s1-dec${d.blocking ? ' is-attn' : ''}"><div class="wait-head"><a href="#/inbox/${enc(d.id)}" class="s1-dec-open">${txt(first(d.question))}</a></div>
<p class="s1-badges">${d.blocking ? blockChip('blocking') : ''}${chip(d.class, d.action)}${canDecide && d.console ? ` ${badge(['decision','hire'].includes(d.console.kind) ? 'Answer here' : 'Approve or Deny here', 'ok')}` : ''}</p>
<p class="s1-badges">${d.blocking ? blockChip('blocking') : ''}${chip(d.class, d.action)}${d.autonomy?badge(d.autonomy.pending?'Awaiting your confirmation':'Near the loop','attn'):''}${canDecide && d.console ? ` ${badge(['decision','hire'].includes(d.console.kind) ? 'Answer here' : 'Approve or Deny here', 'ok')}` : ''}</p>
<p class="small s1-auth">${approves(d.authorization)}</p>
${window.MosaicInbox?.cardMeta?.(d) ?? `<p class="small muted">Raised by ${txt(d.raised_by_role)} ${when(d.at)}</p>`}<p class="small muted">${d.task_ref ? ` · task ${taskLink(d.task_ref)}` : ''}${d.requirement_ref ? ` · ${txt(d.requirement_ref)}` : ''}</p></li>`;
}
@@ -427,20 +427,26 @@
route:{harness:$('hire-route-harness').value.trim(),model:$('hire-route-model').value.trim(),...(thinking?{thinking}:{})}};
}
const HIRE_START_LABEL='Record and approve';
let hireDraft={op:'hire',level:'org'},hireTemplates=[],hireBusy=false,hireStatus='';
let hireDraft={op:'hire',level:'org'},hireTemplates=[],hireBusy=false,hireStatus='',hireCode='';
// D4 (#1873, P0-1): an org Console shows this form on the workspace inbox. Its posts go to the
// current organization's bus (display.js), so the organization is that one and is not editable.
const hireOrg=()=>window.MosaicOrg?.id ?? null;
function hireStartHtml() {
if(!canDecide)return '';
const d=hireDraft,off=hireBusy?' disabled':'';
const input=(key,label,extra='')=>`<label>${label} <input data-hire-field="${key}" value="${esc(d[key]??'')}" maxlength="${key==='reason'?500:200}" ${extra}${off}></label>`;
return `<section class="panel" id="hire-start"><h2>Hire, fire or change an agent</h2><form id="hire-start-form">
${input('business','Organization','required')}<button type="button" class="btn" data-hire-load${off}>Read role templates</button>
<label>Operation <select data-hire-field="op"${off}>${['hire','fire','change'].map(v=>`<option${d.op===v?' selected':''}>${v}</option>`).join('')}</select></label>
<label>Level <select data-hire-field="level"${off}>${['org','project','root'].map(v=>`<option${d.level===v?' selected':''}>${v}</option>`).join('')}</select></label>
const org=hireOrg(),d=hireDraft,off=hireBusy?' disabled':'';
const input=(key,label,extra='')=>`<label>${label} <input data-hire-field="${key}" data-focus-key="hire:${key}" value="${esc(d[key]??'')}" maxlength="${key==='reason'?500:200}" ${extra}${off}></label>`;
const select=(key,label,options)=>`<label>${label} <select data-hire-field="${key}" data-focus-key="hire:${key}"${off}>${options}</select></label>`;
const business=org?`<label>Organization <input value="${esc(org)}" readonly aria-describedby="hire-start-org"></label><p class="small muted" id="hire-start-org">This hire goes to ${txt(org)}'s bus. Switch organization to hire elsewhere.</p>`:input('business','Organization','required');
const status=`${txt(hireStatus)}${hireCode?` (<code>${txt(hireCode)}</code>)`:''}`;
return `<section class="${org?'ws-panel ws-hire':'panel'}" id="hire-start" aria-labelledby="hire-start-h"><h2 id="hire-start-h">Hire, fire or change an agent</h2><form id="hire-start-form">
${business}<button type="button" class="btn" data-hire-load data-focus-key="hire:load"${off}>Read role templates</button>
${select('op','Operation',['hire','fire','change'].map(v=>`<option${d.op===v?' selected':''}>${v}</option>`).join(''))}
${select('level','Level',['org','project','root'].map(v=>`<option${d.level===v?' selected':''}>${v}</option>`).join(''))}
${input('project','Project (project level only)')}${input('name','Agent name','required')}
<label>Role template <select data-hire-field="template"${off}><option value="">Choose a template</option>${hireTemplates.map(t=>`<option value="${esc(t.name)}"${d.template===t.name?' selected':''}>${txt(t.name)}</option>`).join('')}</select></label>
${select('template','Role template',`<option value="">Choose a template</option>${hireTemplates.map(t=>`<option value="${esc(t.name)}"${d.template===t.name?' selected':''}>${txt(t.name)}</option>`).join('')}`)}
${input('harness','Harness')}${input('model','Model')}${input('thinking','Thinking (optional)')}${input('reason','Reason','required')}
<p>Submitting raises and approves one decision. Flagged grants and credentials require the owner; root agents require the instance owner.</p>
<button type="submit" class="btn primary"${off}>${hireBusy?'Working…':HIRE_START_LABEL}</button><p id="hire-start-status" role="status">${txt(hireStatus)}</p></form></section>`;
<button type="submit" class="btn primary" data-focus-key="hire:submit"${off}>${hireBusy?'Working…':HIRE_START_LABEL}</button><p id="hire-start-status" role="status">${status}</p></form></section>`;
}
function paintHireStart() {const e=$('hire-start');if(e)e.outerHTML=hireStartHtml();}
document.addEventListener('input',e=>{const f=e.target.closest?.('[data-hire-field]');if(f)hireDraft[f.dataset.hireField]=f.value;});
@@ -448,15 +454,15 @@
async function hireSubmit(templatesOnly=false) {
if(hireBusy || out() || !canDecide)return;
const d={...hireDraft},template=hireTemplates.find(t=>t.name===d.template);
if(!templatesOnly && d.op!=='fire' && !template){hireStatus='Read and choose a role template first.';paintHireStart();return;}
if(!templatesOnly && d.op!=='fire' && !template){hireStatus='Read and choose a role template first.';hireCode='';paintHireStart();return;}
const thinking=d.thinking?.trim(),hire={op:d.op,level:d.level,name:d.name,reason:d.reason,...(d.level==='project'?{project:d.project}:{}),...(d.op==='fire'?{}:{role:{template:template?.name,sha256:template?.sha256},route:{harness:d.harness,model:d.model,...(thinking?{thinking}:{})}})};
hireBusy=true;hireStatus='';paintHireStart();
const {res,json}=await decidePost(`/api/decisions/hire/${templatesOnly?'templates':'start'}`,{business:d.business,...(templatesOnly?{}:{hire})});
hireBusy=true;hireStatus='';hireCode='';paintHireStart();
const {res,json}=await decidePost(`/api/decisions/hire/${templatesOnly?'templates':'start'}`,{business:hireOrg() ?? d.business,...(templatesOnly?{}:{hire})});
hireBusy=false;
if(res?.type==='opaqueredirect'){signOut();return;}
if(res?.ok && templatesOnly && Array.isArray(json?.templates)){hireTemplates=json.templates;hireStatus=hireTemplates.length?'Templates read.':'No templates available.';}
else if(res?.ok && !templatesOnly && json?.op==='resolved'){hireStatus=`Recorded decision ${json.id}.`;}
else hireStatus=decideRefusal(res,json,templatesOnly?'confirmation-unavailable':'outcome-unknown').message;
else {const r=decideRefusal(res,json,templatesOnly?'confirmation-unavailable':'outcome-unknown');hireStatus=r.message;hireCode=r.code;}
paintHireStart();
}
document.addEventListener('click',e=>{if(e.target.closest?.('[data-hire-load]'))hireSubmit(true);});
@@ -615,6 +621,7 @@
<section aria-labelledby="b-q" class="panel"><h2 id="b-q">Question</h2><p class="s1-q">${txt(d.question)}</p>${window.MosaicInbox?.detailMeta?.(d) ?? ''}
<dl class="kv"><dt>Class</dt><dd>${chip(d.class, d.action)}${d.blocking ? ` ${blockChip('blocking')}` : ''}</dd><dt>Approval</dt><dd class="s1-auth">${approves(d.authorization)}</dd><dt>Routed to</dt><dd>${txt(d.routeTo)}</dd><dt>Raised by</dt><dd>${txt(d.raisedBy.role)} <span class="source">${txt(d.raisedBy.run)}</span></dd><dt>Raised</dt><dd>${when(d.at)}</dd>${d.taskRef ? `<dt>Task</dt><dd>${taskLink(d.taskRef)} <span class="source">${txt(d.taskRef)}</span></dd>` : ''}${d.requirementRef ? `<dt>Requirement</dt><dd>${txt(d.requirementRef)}</dd>` : ''}<dt>DM</dt><dd>${d.dm ? `sent ${when(d.dm.at)}` : d.blocking ? 'not sent' : 'not needed (not blocking)'}</dd><dt>Id</dt><dd><code>${txt(d.id)}</code></dd></dl></section>
${answer?.id === d.id ? decideHtml() : ''}
${canDecide?window.MosaicAutonomy?.notice(d)??'':''}
<section aria-labelledby="b-o"><h2 id="b-o">Options</h2>${help}${options}<p class="small muted" id="copy-status"></p></section>
<section aria-labelledby="b-c"><h2 id="b-c">Earlier versions</h2>${earlier.length ? `<ul class="s1-plain">${earlier.map(x => `<li><a href="#/inbox/${enc(x.id)}">${txt(short(x.id))}</a> ${badge(x.closed ? x.closed.op : 'open')} ${txt(first(x.question))}${x.closed?.note ? `<span class="source">${txt(x.closed.note)}</span>` : ''}</li>`).join('')}</ul>` : '<p class="muted">None.</p>'}</section>
<section aria-labelledby="b-h"><h2 id="b-h">History</h2>${history.length ? `<ul class="s1-plain">${history.join('')}</ul>` : '<p class="muted">Not seen yet.</p>'}<p><a href="#/trail/decision/${enc(d.id)}">Full trail for this decision</a></p></section>`;
@@ -1183,8 +1190,13 @@
return select(name,key,[...new Set([...legacy,...offered.map(m=>m.key)])],c[key],(v.providerModels?.[c[key]]||enabled&&v.models.includes(c[key]))?'unavailable':'not in the model table',labels);};
const thinking=(key,h)=>select(name,key,levelsOf(c[h]),c[key],c[h]?`not applied by ${c[h]}`:'no harness');
return `<tr class="route-row${['fallback.harness','fallback.model','fallback.thinking'].some(k=>c[k])?' fallback-open':''}"><th scope="row">${txt(name)}${instanceInfo?.instances?.[name]?.definition?`<small class="route-definition">${txt(instanceInfo.instances[name].definition)}</small>`:''}</th><td data-label="Harness">${harness('harness')}</td><td data-label="Model">${model('model')}</td><td data-label="Thinking">${thinking('thinking','harness')}</td><td class="fallback-cell" data-label="Fallback harness"><button type="button" class="fallback-toggle">Set fallback</button>${harness('fallback.harness')}</td><td class="fallback-cell" data-label="Fallback model">${model('fallback.model')}</td><td class="fallback-cell" data-label="Fallback thinking">${thinking('fallback.thinking','fallback.harness')}</td></tr>`;}).join('');
const catalogueInfo=v.catalogue?.status==='ready'?`<p class="small muted">Model proxy catalogue, observed ${txt(window.MosaicLocalTime(v.catalogue.observedAt))}.</p>${(v.catalogue.notices??[]).map(n=>`<p class="small warn">${txt(n)}</p>`).join('')}`:v.catalogue?.status==='unavailable'?'<p class="small warn">Model catalogue unavailable.</p>':'<p class="small muted">Model catalogue not enabled.</p>';
const providerControl=canSend&&!isTester?`<button type="button" id="provider-switch" data-enabled="${v.catalogue?.status!=='disabled'}">${v.catalogue?.status!=='disabled'?'Disable':'Enable'} model proxy catalogue</button><p role="status" id="provider-switch-status"></p>`:'';
// D3 (P0-4): the switch adds or removes the cliproxy module only, so its label follows that
// module in the organization's model-provider list, not the whole catalogue's status (a
// fixture-only catalogue is ready with no model proxy). Before the setting is read it falls
// back to the catalogue status, as before.
const proxyOn=harnessSettings?(Array.isArray(harnessSettings.plugins?.['model-provider'])?harnessSettings.plugins['model-provider']:[]).includes('cliproxy'):v.catalogue?.status!=='disabled';
const catalogueInfo=v.catalogue?.status==='ready'?`<p class="small muted">Model catalogue, observed ${txt(window.MosaicLocalTime(v.catalogue.observedAt))}.</p>${(v.catalogue.notices??[]).map(n=>`<p class="small warn">${txt(n)}</p>`).join('')}`:v.catalogue?.status==='unavailable'?`<p class="small warn">Model catalogue unavailable. A change to thinking alone still saves.${proxyOn&&canSend&&!isTester?' A new model needs the catalogue: set up the model proxy, or, when this organization has none, press Disable model proxy catalogue.':''}</p>`:'<p class="small muted">Model catalogue not enabled.</p>';
const providerControl=canSend&&!isTester?`<button type="button" id="provider-switch" data-enabled="${proxyOn}">${proxyOn?'Disable':'Enable'} model proxy catalogue</button><p role="status" id="provider-switch-status"></p>`:'';
const harnessControl=canSend&&!isTester?`<fieldset class="harness-control"><legend>Harnesses</legend>${harnessSettings ? [...new Set([...harnessSettings.available.harness,...(harnessSettings.plugins.harness??[])])].map(name=>`<label><input type="checkbox" data-harness="${esc(name)}"${(harnessDraft??harnessSettings.plugins.harness??[]).includes(name)?' checked':''}${harnessBusy?' disabled':''}> ${txt(name)}</label>`).join('')+` <button type="button" id="harness-save"${harnessBusy?' disabled':''}>Save harnesses</button>` : '<p>Harnesses unavailable.</p><button type="button" id="harness-read">Read harnesses again</button>'}<p role="status" id="harness-status">${txt(harnessReceipt)}</p></fieldset>`:'';
const off=v.harnesses.length?'':'<p class="small warn">No harness is switched on in the business file\'s plugins.harness, so every launch refuses harness-off and a save that changes a harness refuses too. Switch one on under Harnesses.</p>';
return `<section class="panel routing-panel" aria-labelledby="routes-title"><span class="sr-only">${title}</span>${catalogueInfo}${providerControl}${harnessControl}${off}<form id="routes-form"><div class="table-wrap" tabindex="0" role="region" aria-label="Routes, scroll for all columns"><table id="routes-table"><thead><tr><th scope="col" rowspan="2">Agent</th><th scope="colgroup" colspan="3">Primary</th><th scope="colgroup" colspan="3">Fallback</th></tr><tr>${ROUTE_CELLS.map(([,l])=>`<th scope="col">${l.replace('Fallback ','')}</th>`).join('')}</tr></thead><tbody>${rows}</tbody></table></div><p class="small muted">${canSend || isTester?'Changes are checked and logged with your identity.':'Saving requires signed-in send mode.'}</p><div class="settings-actions route-actions${routesDraft?' has-draft':''}"><button type="submit"${disabled}>${routesBusy?'<span class="settings-spinner" aria-hidden="true"></span> Saving…':'Save routes'}</button> <button type="button" id="routes-reload"${routesDraft?'':' hidden'}${routesBusy||!routesDraft?' disabled':''}>Discard draft</button><p role="status" id="routes-receipt" class="${routesReceipt.startsWith('Outcome unknown')?'banner ref':routesReceipt.includes('Draft kept')?'banner err':routesReceipt.includes('saved')?'settings-success':''}">${txt(routesReceipt)}</p></div>${routesApplyMarkup()}</form></section>`;
@@ -1201,6 +1213,19 @@
return {revision:routesView.revision,routes};
}
const unknownRoutes='Outcome unknown. Read the routes again before trying. Draft kept.';
// D3 (P0-4): after a catalogue refusal, Disable is named only when it can make this save apply:
// cliproxy is listed and every model the change moves to comes from another listed module.
// A viewer without the switch is sent to the owner.
function catalogueStep(code,routes){
if(!['model-catalogue-unavailable','model-provider-unavailable'].includes(code))return '';
if(!canSend||isTester)return ' Ask the organization\'s owner to set up the model proxy, or to disable the model proxy catalogue when the change needs no proxy.';
const names=Array.isArray(harnessSettings?.plugins?.['model-provider'])?harnessSettings.plugins['model-provider']:[];
if(!names.includes('cliproxy'))return '';
const moved=Object.entries(routes).flatMap(([name,r])=>{const was=routesView?.instances?.[name]??{};
return [[r.harness,r.model,was.harness,was.model],[r.fallback?.harness,r.fallback?.model,was.fallback?.harness,was.fallback?.model]].filter(([h,m,wh,wm])=>m&&(h!==wh||m!==wm)).map(([,m])=>m);});
const module=key=>routesView?.providerModels?.[key]?.module;
return moved.length&&moved.every(key=>module(key)&&module(key)!=='cliproxy'&&names.includes(module(key)))?' This change needs no model proxy: press Disable model proxy catalogue, then save again.':'';
}
const routesAnswer=r=>/^[a-f0-9]{64}$/.test(r?.revision??'')&&Array.isArray(r.harnesses)&&Array.isArray(r.models)&&[r.levels,r.instances].every(o=>o&&typeof o==='object'&&!Array.isArray(o));
for(const kind of ['input','change'])document.addEventListener(kind,e=>{const form=e.target?.closest?.('#routes-form');if(!form||!routesView)return;routesDraft=routesFields(form);routesEditVersion++;markSettingsDraft('routing',true);form.querySelector('.settings-actions')?.classList.add('has-draft');const discard=$('routes-reload');if(discard){discard.hidden=false;discard.disabled=false;}
// A harness change redraws the row so its thinking cell offers that harness's levels.
@@ -1223,7 +1248,9 @@
const button=e.target,status=$('provider-switch-status');if(routesDraft){status.textContent='Save or discard the route draft first.';return;}button.disabled=true;
try {const read=await fetch('/api/proxy/plugins',{cache:'no-store'});const settings=await read.json();if(!read.ok||!/^[a-f0-9]{64}$/.test(settings.revision??''))throw Error('Could not read provider settings.');
const names=Array.isArray(settings.plugins?.['model-provider'])?settings.plugins['model-provider']:[];
const next=button.dataset.enabled==='true'?names.filter(n=>n!=='cliproxy'):[...new Set([...names,'cliproxy'])];
// The fresh read decides: a stale label never turns the proxy module on when it meant off.
const on=names.includes('cliproxy');if(String(on)!==button.dataset.enabled){button.dataset.enabled=String(on);button.textContent=`${on?'Disable':'Enable'} model proxy catalogue`;button.disabled=false;status.textContent='The switch now shows the current setting. Press it again to change it.';return;}
const next=on?names.filter(n=>n!=='cliproxy'):[...names,'cliproxy'];
const response=await fetch('/api/proxy/plugins/set',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({revision:settings.revision,plugins:{'model-provider':next}}),cache:'no-store',redirect:'manual'});
if(!response.ok)throw Error('Provider setting was refused.');harnessSettings=null;routesView=null;last.delete('routes');render(true);
}catch(error){status.textContent=error.message;button.disabled=false;}
@@ -1236,7 +1263,7 @@
routesBusy=true;routesReceipt='';routesApply=null;render(false);
try{const response=await fetch('/api/routes',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify(payload),cache:'no-store',redirect:'manual'});if(response.type==='opaqueredirect'){routesReceipt='Sign in again. Draft kept.';signOut();}else{const result=await response.json();
if(response.ok&&routesAnswer(result)){routesDraft=null;routesView=result;harnessSettings=null;const prior=last.get('routes');if(prior)last.set('routes',{...prior,rows:[result]});routesReceipt=`Routes saved for ${Object.keys(payload.routes).join(', ')}. The next launch of each uses the new route.`;routesApply=await routesApplyRead(Object.keys(payload.routes));}
else routesReceipt=response.ok||result?.sent==='unknown'?unknownRoutes:`${result?.message||'Refused.'} Draft kept.`;}}
else routesReceipt=response.ok||result?.sent==='unknown'?unknownRoutes:`${result?.message||'Refused.'}${catalogueStep(result?.error,payload.routes)} Draft kept.`;}}
catch{routesReceipt=unknownRoutes;}finally{routesBusy=false;if(!out())render(false);}
});
// Row 194 (#1692, D7): the Agents section lives in agents.js (window.MosaicAgents) and draws from
@@ -1523,7 +1550,7 @@
if (n === 0 && window.MosaicOrg) return window.MosaicWorkspace.home();
if (a === 'projects' && n === 1 && window.MosaicOrg) return window.MosaicWorkspace.projectsPage(q);
if (a === 'organization' && n === 1 && window.MosaicOrg) return window.MosaicWorkspace.dashboard();
if (a === 'inbox' && n === 1 && window.MosaicOrg) return window.MosaicWorkspace.inboxPage(q);
if (a === 'inbox' && n === 1 && window.MosaicOrg) return configRead.then(() => window.MosaicWorkspace.inboxPage(q, { hire: hireStartHtml }));
if (a === 'tasks' && n === 1 && window.MosaicOrg) return window.MosaicTasksBoard.view();
if (a === 'inbox' && n === 1) return inboxView(get);
if (a === 'inbox' && n === 2) return SAFE.test(b) ? decisionView(get, b, current) : notFound(`${txt(b)} is not a decision id.`);
@@ -1896,7 +1923,8 @@
if (path.startsWith('/chat')||path.startsWith('/projects')||path.startsWith('/inbox')||path.startsWith('/settings')||path.startsWith('/docs/')||wsPage(path)) render(false); else if (window.MosaicOrg ? path === '/board' : path === '/' || path === '') paintHeld(heldErr);
};
configRead = fetch('/api/config', { cache: 'no-store', redirect: 'manual' }).then(r => { if (r.type === 'opaqueredirect') { signOut(); return null; } return r.json(); }).then(applyConfig).catch(() => {});
document.addEventListener('mosaic-config', e => { if (e.detail && typeof e.detail === 'object') applyConfig(e.detail); });
// E1 (P1-5): after Restart bus the organization dashboard draws its bus line and system types now, not at the next poll.
document.addEventListener('mosaic-config', e => { if (e.detail && typeof e.detail === 'object') { applyConfig(e.detail); if (window.MosaicOrg && routeOf().split('?')[0] === '/organization') render(false); } });
// The cross-organization pages ask for a repaint after a filter, sort or selection change,
// and report the inbox count across organizations for the sidebar.
document.addEventListener('mosaic-workspace-render', () => { if (wsPage(routeOf().split('?')[0])) render(false); });
+36 -8
View File
@@ -15,7 +15,7 @@
const sleep = ms => new Promise(r => setTimeout(r, ms));
const clock = () => new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
let config = null, boot = null, reconnecting = false, planned = false;
let restartState = 'idle', restartReceipt = '', busReceipt = '', busBusy = false;
let restartState = 'idle', restartReceipt = '', busReceipt = '', busBusy = false, busAction = 'restart';
const readConfig = () => nativeFetch('/api/config', { cache: 'no-store', redirect: 'manual' })
.then(r => (r.type === 'opaqueredirect' ? null : r.ok ? r.json() : null));
@@ -119,11 +119,17 @@
else if (e.target.id === 'console-restart-go' && restartState === 'confirm') restartConsole();
});
// The org bus restart control, offered on the organization's own pages to its owner.
// The org bus restart control, offered on the organization's own pages to its owner. With
// { stop: true } (the organization header) a Stop bus button sits beside it: one press, like
// Restart bus, sharing its receipt and busy state. glyph is the caller's own icon markup, kept
// inside the restart button so it stays on the button whatever width the receipt takes.
const busAllowed = () => !!window.MosaicOrg && config?.orgSetup === true;
function busButton(label = 'Restart bus') {
function busButton(label = 'Restart bus', { stop = false, glyph = '' } = {}) {
if (!busAllowed()) return '';
return `<span class="bus-restart"><button type="button" class="btn" data-bus-restart data-label="${esc(label)}"${busBusy ? ' disabled' : ''}>${esc(busBusy ? 'Restarting…' : label)}</button> <span role="status" data-bus-restart-receipt>${esc(busReceipt)}</span></span>`;
const off = busBusy ? ' disabled' : '';
const restart = `<button type="button" class="btn" data-bus-restart data-label="${esc(label)}"${stop ? ' data-stop' : ''}${off}>${glyph}${esc(busBusy && busAction === 'restart' ? 'Restarting…' : label)}</button>`;
const buttons = stop ? `<span class="bus-buttons">${restart} <button type="button" class="btn" data-bus-stop${off}>${busBusy && busAction === 'stop' ? 'Stopping…' : 'Stop bus'}</button></span>` : restart;
return `<span class="bus-restart">${buttons} <span role="status" data-bus-restart-receipt>${esc(busReceipt)}</span></span>`;
}
async function attachBus(box, label) {
await ready;
@@ -133,20 +139,24 @@
box.insertAdjacentHTML('beforeend', ` ${busButton(label)}`);
}
const paintBus = () => document.querySelectorAll('.bus-restart').forEach(el => {
const label = el.querySelector('[data-bus-restart]')?.dataset.label;
el.outerHTML = busButton(label);
const button = el.querySelector('[data-bus-restart]');
el.outerHTML = busButton(button?.dataset.label, { stop: button?.hasAttribute('data-stop'), glyph: button?.querySelector('svg')?.outerHTML || '' });
});
document.addEventListener('click', async e => {
const button = e.target.closest?.('[data-bus-restart]');
const button = e.target.closest?.('[data-bus-restart],[data-bus-stop]');
if (!button || button.disabled || busBusy || !busAllowed()) return;
const id = window.MosaicOrg.id;
busAction = button.hasAttribute('data-bus-stop') ? 'stop' : 'restart';
busBusy = true; busReceipt = ''; paintBus();
if (busAction === 'stop') return stopBus(id);
try {
const response = await nativeFetch(`/api/orgs/${encodeURIComponent(id)}/restart/bus`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}', cache: 'no-store', redirect: 'manual' });
if (response.type === 'opaqueredirect') throw new Error('Sign in again, then retry.');
const body = await response.json();
if (!response.ok || body.outcome !== 'done' || body.step !== 'bus-restart') throw new Error(body.message || 'The restart was refused. Nothing was retried.');
busReceipt = `${body.restarted ? 'Bus restarted' : 'Bus started'} at ${clock()}. ${body.healthy ? 'It answers.' : `It has not answered yet. Check it with ${body.verb.replace('restart', 'status')}.`}`;
// D2: a recovered bus's verb is two commands; its check is the status verb.
const check = body.recovered ? `mosaic bus status ${id}` : body.verb.replace('restart', 'status');
busReceipt = `${body.recovered ? 'Dead bus host cleared; bus started' : body.restarted ? 'Bus restarted' : 'Bus started'} at ${clock()}. ${body.healthy ? 'It answers.' : `It has not answered yet. Check it with ${check}.`}`;
// The page's send flags were read while the bus was in its old state; read them again. This
// read goes through the page's fetch, which display.js scopes to the selected organization.
// The organization list goes first, so the view the config event redraws shows the bus as it is now.
@@ -168,5 +178,23 @@
} finally { busBusy = false; paintBus(); }
});
// Stop bus: the bus host stops cleanly and leaves no lock behind. An already stopped bus answers
// done with state stopped. The page reads the list and its send flags again, as after a restart.
async function stopBus(id) {
try {
const response = await nativeFetch(`/api/orgs/${encodeURIComponent(id)}/stop/bus`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: '{}', cache: 'no-store', redirect: 'manual' });
if (response.type === 'opaqueredirect') throw new Error('Sign in again, then retry.');
const body = await response.json();
if (!response.ok || body.outcome !== 'done' || body.step !== 'bus-stop' || body.state !== 'stopped') throw new Error(body.message || 'The stop was refused. Nothing was retried.');
busReceipt = body.stopped ? `Bus stopped at ${clock()}. Restart bus starts it again.` : `The bus was already stopped at ${clock()}. Nothing changed.`;
$('bus-restart-done')?.remove();
await Promise.resolve(window.MosaicOrg.refreshList?.()).catch(() => {});
const fresh = await window.fetch('/api/config', { cache: 'no-store', redirect: 'manual' }).then(r => (r.ok ? r.json() : null)).catch(() => null);
if (fresh) { config = fresh; document.dispatchEvent(new CustomEvent('mosaic-config', { detail: fresh })); }
} catch (error) {
busReceipt = error instanceof SyntaxError || error?.name === 'TypeError' ? 'Outcome unknown. Check the bus with mosaic bus status before retrying.' : error.message;
} finally { busBusy = false; paintBus(); }
}
window.MosaicConsole = Object.freeze({ ready, settingsMarkup, busButton, attachBus, get busRestart() { return busAllowed(); }, get config() { return config; } });
})();
+217 -4
View File
@@ -120,7 +120,31 @@ if (document.documentElement.hasAttribute('data-settings-proxy-document') && !lo
// No browser storage, request/body logging, key reveal, or automatic write retry.
(() => {
'use strict';
const markup = '<button id="proxy-refresh" type="button">Refresh</button><p id="proxy-message" role="status" aria-live="polite">Reading accounts…</p>\n<p id="proxy-readonly" class="muted" hidden>This Console is read-only. Sign-in and account changes are unavailable.</p>\n<section class="panel" aria-labelledby="proxy-accounts-title"><h2 id="proxy-accounts-title">Accounts</h2>\n<p>Higher priority is used first. Account names are stable identifiers; credential values are never displayed.</p>\n<div class="table-wrap" tabindex="0" role="region" aria-label="Accounts"><table><thead><tr><th>Account</th><th>Provider</th><th>Status</th><th>Priority</th><th>Models</th></tr></thead><tbody id="proxy-accounts"></tbody></table></div></section>\n<section class="panel" aria-labelledby="proxy-signin-title"><h2 id="proxy-signin-title">Sign in to an account</h2>\n<form id="proxy-login-start"><fieldset data-write disabled><legend>Subscription account</legend><label>Provider <select name="provider"><option value="claude">Claude</option><option value="codex">Codex</option></select></label><button type="submit">Start sign-in</button></fieldset></form>\n<p id="proxy-login-message" role="status" aria-live="polite"></p>\n<p><a id="proxy-authorize" target="_blank" rel="noopener noreferrer" hidden>Open account sign-in in a new tab</a></p>\n<form id="proxy-login-submit"><fieldset data-write disabled><legend>Finish sign-in</legend><p>If the sign-in tab does not return, copy its full address and paste it here.</p><label>Callback address <input name="redirect_url" type="url" autocomplete="off" spellcheck="false" maxlength="8192" required></label><button type="submit">Submit address</button><button id="proxy-login-cancel" type="button">Cancel sign-in</button></fieldset></form></section>\n<section class="panel" aria-labelledby="proxy-apikey-title"><h2 id="proxy-apikey-title">Add an API key</h2>\n<form id="proxy-apikey"><fieldset data-write disabled><legend>z.ai</legend><label>API key <input name="key" type="password" autocomplete="off" spellcheck="false" maxlength="4096" required></label><p>The key is sent once and cleared from this field. It will never be shown again.</p><button type="submit">Add key</button></fieldset><p id="proxy-apikey-message" role="status"></p></form></section>\n<section class="panel" aria-labelledby="proxy-keys-title"><h2 id="proxy-keys-title">Client keys</h2><p>Only key IDs are shown. Hire and fire manage agent keys.</p>\n<div class="table-wrap" tabindex="0" role="region" aria-label="Client keys"><table><thead><tr><th>Key ID</th><th>Managed by</th><th>Agent</th><th>Issued</th><th>State</th></tr></thead><tbody id="proxy-keys"></tbody></table></div></section>\n';
// #1873: setup from nothing to a working proxy. Shown only to the instance owner (config.proxyAdmin).
const setupMarkup = '<section id="proxy-setup" class="panel" aria-labelledby="proxy-setup-title" hidden><h2 id="proxy-setup-title">Proxy setup</h2>'
+ '<p>Each step is checked on the host. Files are named by label; keys are never shown.</p>'
+ '<dl id="proxy-setup-state" class="proxy-setup-state"></dl>'
+ '<div id="proxy-setup-next" class="proxy-setup-next" role="status" aria-live="polite"></div>'
+ '<p id="proxy-setup-message" role="status" aria-live="polite"></p>'
+ '<p id="proxy-setup-bus"></p>'
+ '<details id="proxy-setup-init-box"><summary>Initialize the proxy</summary><form id="proxy-setup-init"><fieldset data-setup-write disabled><legend>Management key</legend>'
+ '<label><input type="radio" name="keySource" value="generate" checked> Generate a key on the host (recommended). It is stored where init stores keys and never shown.</label>'
+ '<label id="proxy-setup-paste-choice" hidden><input type="radio" name="keySource" value="paste"> Use my own key</label>'
+ '<p id="proxy-setup-paste-note" class="muted" hidden>Your own key can be pasted only over HTTPS or on this machine (loopback). This page uses the generated key.</p>'
+ '<div id="proxy-setup-paste" hidden><label>Management key <input name="key" type="password" autocomplete="off" spellcheck="false" maxlength="4096"></label>'
+ '<label>Confirm key <input name="confirm" type="password" autocomplete="off" spellcheck="false" maxlength="4096"></label></div></fieldset>'
+ '<fieldset data-setup-write disabled><legend>Settings (leave empty for the defaults)</legend><div class="filters">'
+ '<label>Port <input name="port" type="number" min="1024" max="65535" step="1" inputmode="numeric"></label>'
+ '<label>Key rotation grace (minutes) <input name="rotateGraceMinutes" type="number" min="1" max="1440" step="1" inputmode="numeric"></label>'
+ '<label>First output timeout (seconds) <input name="firstOutputSeconds" type="number" min="1" max="3600" step="1" inputmode="numeric"></label>'
+ '</div><button type="submit">Initialize</button></fieldset></form></details>'
+ '<div class="proxy-setup-unit"><h3>Proxy service</h3><p class="muted">Install renders the unit the same way as <code>mosaic proxy unit install</code>.</p>'
+ '<div class="proxy-setup-buttons"><button type="button" data-unit="install" data-setup-write disabled>Install</button><button type="button" data-unit="start" data-setup-write disabled>Start</button><button type="button" data-unit="restart" data-setup-write disabled>Restart</button><button type="button" data-unit="stop" data-setup-write disabled>Stop</button><button type="button" id="proxy-setup-render">Show unit file</button></div>'
+ '<pre id="proxy-setup-unit-file" hidden></pre></div>'
+ '<div class="proxy-setup-orgs"><h3>Organizations</h3><div class="table-wrap" tabindex="0" role="region" aria-label="Organizations using the proxy"><table><thead><tr><th>Organization</th><th>Proxy</th><th>Catalogue</th><th>Models</th></tr></thead><tbody id="proxy-setup-orgs"></tbody></table></div>'
+ '<p id="proxy-setup-own" class="muted"></p></div>'
+ '<div class="proxy-setup-receipts"><h3>Receipts</h3><ol id="proxy-setup-receipts"><li class="muted">No setup actions on this page yet.</li></ol></div></section>\n';
const markup = setupMarkup + '<button id="proxy-refresh" type="button">Refresh</button><p id="proxy-message" role="status" aria-live="polite">Reading accounts…</p>\n<p id="proxy-readonly" class="muted" hidden>This Console is read-only. Sign-in and account changes are unavailable.</p>\n<section class="panel" aria-labelledby="proxy-accounts-title"><h2 id="proxy-accounts-title">Accounts</h2>\n<p>Higher priority is used first. Account names are stable identifiers; credential values are never displayed.</p>\n<div class="table-wrap" tabindex="0" role="region" aria-label="Accounts"><table><thead><tr><th>Account</th><th>Provider</th><th>Status</th><th>Priority</th><th>Models</th></tr></thead><tbody id="proxy-accounts"></tbody></table></div></section>\n<section class="panel" aria-labelledby="proxy-signin-title"><h2 id="proxy-signin-title">Sign in to an account</h2>\n<form id="proxy-login-start"><fieldset data-write disabled><legend>Subscription account</legend><label>Provider <select name="provider"><option value="claude">Claude</option><option value="codex">Codex</option></select></label><button type="submit">Start sign-in</button></fieldset></form>\n<p id="proxy-login-message" role="status" aria-live="polite"></p>\n<p><a id="proxy-authorize" target="_blank" rel="noopener noreferrer" hidden>Open account sign-in in a new tab</a></p>\n<form id="proxy-login-submit"><fieldset data-write disabled><legend>Finish sign-in</legend><p>If the sign-in tab does not return, copy its full address and paste it here.</p><label>Callback address <input name="redirect_url" type="url" autocomplete="off" spellcheck="false" maxlength="8192" required></label><button type="submit">Submit address</button><button id="proxy-login-cancel" type="button">Cancel sign-in</button></fieldset></form></section>\n<section class="panel" aria-labelledby="proxy-apikey-title"><h2 id="proxy-apikey-title">Add an API key</h2>\n<form id="proxy-apikey"><fieldset data-write disabled><legend>z.ai</legend><label>API key <input name="key" type="password" autocomplete="off" spellcheck="false" maxlength="4096" required></label><p>The key is sent once and cleared from this field. It will never be shown again.</p><button type="submit">Add key</button></fieldset><p id="proxy-apikey-message" role="status"></p></form></section>\n<section class="panel" aria-labelledby="proxy-keys-title"><h2 id="proxy-keys-title">Client keys</h2><p>Only key IDs are shown. Hire and fire manage agent keys.</p>\n<div class="table-wrap" tabindex="0" role="region" aria-label="Client keys"><table><thead><tr><th>Key ID</th><th>Managed by</th><th>Agent</th><th>Issued</th><th>State</th></tr></thead><tbody id="proxy-keys"></tbody></table></div></section>\n';
function mount() {
const $ = id => document.getElementById(id);
let canWrite = false, login = null, poll = null;
@@ -138,6 +162,7 @@ if (document.documentElement.hasAttribute('data-settings-proxy-document') && !lo
if (!response.ok) {
if (response.status === 401) throw new Error('Sign in to the Console again.');
if (value.error === 'not-configured') throw new Error('The model proxy is not configured for this instance.');
if (value.error === 'proxy-down') throw Object.assign(new Error('The model proxy is not answering.'), { code: 'proxy-down' });
if (value.error === 'login-state-unknown') throw new Error('This sign-in has ended. Start a new sign-in.');
if (value.error === 'apikey-invalid') throw new Error('Enter a nonempty API key, with no spaces or line breaks.');
throw new Error('The operation could not be confirmed. Nothing was retried. Refresh before trying again.');
@@ -239,9 +264,187 @@ if (document.documentElement.hasAttribute('data-settings-proxy-document') && !lo
catch (error) { message(error.message);receipt('proxy-apikey-message',error.message,'error'); }
finally { busy.delete('apikey');formBusy(form,false); }
});
const cleanup = () => { clearTimeout(poll); login = null; const redirect=$('proxy-login-submit')?.elements.redirect_url, key=$('proxy-apikey')?.elements.key; if(redirect)redirect.value='';if(key)key.value=''; };
// #1873 setup controller. Every action is a POST the server checks again (owner, Origin,
// Fetch Metadata); this code only offers the next step. Pasted keys stay in their fields
// until submit, which clears them before the request is answered.
const setup = (() => {
const box = $('proxy-setup'), form = $('proxy-setup-init');
let state = null, timer = null, enabled = false, sendOk = false, seenFetch = null, loading = false;
const pasteAllowed = location.protocol === 'https:' || ['localhost', '[::1]'].includes(location.hostname) || /^127\./.test(location.hostname);
const STEPS = { start: 'Starting', 'download-archive': 'Downloading the release archive', 'verify-archive': 'Checking the archive hash', 'download-panel': 'Downloading the panel', 'verify-panel': 'Checking the panel hash', 'extract-binary': 'Extracting the executable', 'verify-binary': 'Checking the executable hash', install: 'Installing', done: 'Done' };
const REASONS = {
'human-required': 'Only the instance owner can set up the proxy.',
'read-only': 'This Console is read-only.',
'proxy-admin-busy': 'Another setup action is running. Wait for it to finish.',
'proxy-key-transport': 'A pasted key travels only over HTTPS or loopback. Use the generated key.',
'proxy-key-mismatch': 'The two key fields differ. Type the same key in both.',
'proxy-key-invalid': 'The key must be 1 to 4096 printable characters with no spaces.',
'proxy-config-invalid': 'The port must be 1024 to 65535 and not 8317.',
'proxy-rotate-grace-invalid': 'Key rotation grace must be 1 to 1440 minutes.',
'proxy-first-output-invalid': 'First output timeout must be 1 to 3600 seconds.',
'proxy-already-initialized': 'The proxy is already initialized. Init never overwrites it.',
'proxy-port-in-use': 'That port is in use. Choose another or leave it empty.',
'proxy-init-busy': 'An init lock is held. See Init lock above.',
'proxy-install-missing': 'The pinned release is not fetched yet.',
'proxy-install-invalid': 'The fetched release failed its hash check. Fetch again.',
'proxy-restart-required': 'Restart the proxy from this page first.',
'proxy-restart-unconfirmed': 'The restart could not be confirmed on the host. Restart again.',
'proxy-init-lock-held': 'The init lock cannot be cleared yet.',
'proxy-init-lock-absent': 'There is no init lock.',
'proxy-unit-exists': 'A different proxy unit file is installed. Check it with mosaic proxy unit render.',
'proxy-unit-missing': 'The proxy service is not installed.',
'proxy-unit-unhealthy': 'The proxy service started but does not answer on its port.',
'proxy-unit-failed': 'systemd refused the action.',
'not-configured': 'Proxy setup is not available on this Console.',
'outcome-unknown': 'The outcome is unknown. Check the status before retrying.',
};
const explain = code => `${REASONS[code] ?? 'The action was refused.'} (${code})`;
async function admin(path, args) {
const response = await fetch('/api/proxy/admin/' + path, { cache: 'no-store', credentials: 'same-origin', redirect: 'manual',
method: args === undefined ? 'GET' : 'POST',
...(args === undefined ? {} : { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(args) }) });
if (response.type === 'opaqueredirect' || response.status === 401) throw new Error('Sign in to the Console again.');
let value = null; try { value = await response.json(); } catch { /* refused below */ }
if (!response.ok) { const code = typeof value?.error === 'string' && /^[a-z -]+$/.test(value.error) ? value.error : 'outcome-unknown'; const error = new Error(explain(code)); error.code = code; throw error; }
return value;
}
const say = (text, kind) => receipt('proxy-setup-message', text, kind);
const human = value => value === null || value === undefined ? '—' : typeof value === 'object' ? JSON.stringify(value) : String(value);
function addReceipt(record) {
if (!record || typeof record !== 'object') return;
const list = $('proxy-setup-receipts'); list.querySelector('li.muted')?.remove();
const li = document.createElement('li'), op = document.createElement('strong'); op.textContent = record.op ?? 'action';
const details = Object.entries(record).filter(([k]) => k !== 'op' && k !== 'time').map(([k, v]) => `${k}: ${human(v)}`).join(', ');
li.append(op, ` ${window.MosaicLocalTime(record.time)}${details ? ' — ' + details : ''}`);
list.prepend(li);
}
// #1873: receipts survive a reload: status carries the last few from proxy-admin.jsonl.
function paintReceipts() {
if (!Array.isArray(state.receipts) || !state.receipts.length) return;
$('proxy-setup-receipts').replaceChildren(); state.receipts.forEach(addReceipt);
}
function row(dl, term, text) { const dt = document.createElement('dt'), dd = document.createElement('dd'); dt.textContent = term; dd.textContent = text; dl.append(dt, dd); }
function paintState() {
const { release, init, unit, fence, initLock } = state, dl = $('proxy-setup-state'); dl.replaceChildren();
row(dl, 'Release', !release.fetched ? `Not fetched (pinned ${release.version}, panel ${release.panelVersion})` : release.verified ? `Fetched ${release.version}, panel ${release.panelVersion}, hashes verified` : `Fetched ${release.version}, hashes not verified (${release.problem})`);
const files = init.files.filter(f => f.present).map(f => f.label).join(', ') || 'none';
row(dl, 'Initialized', init.initialized ? `Yes, port ${init.settings?.port ?? '—'}` : init.partial ? `Partly (${init.problem ?? 'incomplete'}). Present: ${files}` : init.problem ? `No (${init.problem})` : 'No');
row(dl, 'Service', `${unit.unit}: ${unit.installed ? 'installed' : 'not installed'}, ${unit.active}${unit.pid ? `, pid ${unit.pid}` : ''}${unit.port ? `, port ${unit.port} ${unit.listening ? 'answering' : 'not answering'}` : ''}${unit.error ? ` (${unit.error})` : ''}`);
row(dl, 'Fence', !fence.set ? 'Clear' : fence.invalid ? 'Set (unreadable fence file)' : `Set after ${fence.op ?? 'a key write'}${fence.org ? ` for ${fence.org}` : ''}${fence.time ? ` at ${window.MosaicLocalTime(fence.time)}` : ''}`);
row(dl, 'Init lock', !initLock.present ? 'None' : initLock.clearable ? 'Stale: the bus is stopped and no proxy files exist' : `Held: ${initLock.blockers.join('; ')}`);
if (state.restart?.confirmed) row(dl, 'Last restart', `Confirmed at ${window.MosaicLocalTime(state.restart.observedAt)}, new pid ${state.restart.pid}`);
}
function paintNext() {
const next = state.next?.[0], box = $('proxy-setup-next');
const steps = {
fetch: ['Fetch the pinned release', `Next: fetch CLIProxyAPI ${state.release.version}. Every file is checked against its pinned SHA-256 before it is installed.`, fetchRelease],
'init-lock-clear': ['Clear the stale init lock', 'Next: an init lock is left from an init that did not finish. The bus is stopped and no proxy files exist, so it can be cleared.', clearLock],
'init-lock-wait': ['Check again', `An init lock is held and cannot be cleared: ${state.initLock.blockers.join('; ')}. Stop the bus and remove partial state as the model-proxy README says, then check again.`, load],
'init-repair': ['Check again', 'Setup is partly done. Init never overwrites existing proxy files; resolve them as the model-proxy README says, then check again.', load],
init: ['Initialize the proxy', 'Next: initialize. The bus generates the management key unless you paste your own.', () => { $('proxy-setup-init-box').open = true; form.elements.port.focus(); }],
'unit-install': ['Install the proxy service', `Next: install ${state.unit.unit}.`, () => unitAction('install')],
'unit-start': ['Start the proxy service', `Next: start ${state.unit.unit}.`, () => unitAction('start')],
'unit-restart': ['Restart the proxy service', state.fence.set ? (fence => fence.invalid || !fence.time ? 'A key write had an uncertain outcome, so key writes are fenced. The fence file has no readable time, so a restart from this page cannot be proven against it: restart the proxy service, confirm it answers, then run mosaic proxy fence clear --proxy-restarted.' : 'A key write had an uncertain outcome, so key writes are fenced. Restart the proxy; after this page confirms the new process, clear the fence.')(state.fence) : 'The service is running but does not answer on its port. Restart it.', () => unitAction('restart')],
'fence-clear': ['Clear the fence', 'The proxy was restarted from this page and the new process answers on its port. Clear the fence with that evidence.', clearFence],
accounts: ['Add an account', 'The proxy is running. Sign in to an account or add an API key below.', () => $('proxy-signin-title')?.scrollIntoView({ block: 'start' })],
};
const [label, text, action] = steps[next] ?? ['Check again', 'Status read.', load];
const p = document.createElement('p'); p.textContent = text;
const button = document.createElement('button'); button.type = 'button'; button.className = 'btn primary'; button.id = 'proxy-setup-go'; button.dataset.next = next ?? '';
button.textContent = state.busy ? 'Working…' : label;
const write = !['init-lock-wait', 'init-repair', 'accounts', 'init'].includes(next);
button.disabled = Boolean(state.busy) || (write && !sendOk);
button.addEventListener('click', () => { if (!button.disabled) action(); });
box.replaceChildren(p, button);
}
function paintControls() {
const { release, init, unit, initLock, busy } = state;
const canInit = sendOk && !busy && release.fetched && release.verified && !init.initialized && !init.partial && !initLock.present;
form.querySelectorAll('[data-setup-write]').forEach(el => { el.disabled = !canInit; });
$('proxy-setup-paste-choice').hidden = !pasteAllowed; $('proxy-setup-paste-note').hidden = pasteAllowed;
const allow = { install: !unit.installed, start: unit.installed && unit.active !== 'active', restart: unit.installed && unit.active === 'active', stop: unit.installed && unit.active === 'active' };
box.querySelectorAll('[data-unit]').forEach(button => { button.disabled = !sendOk || Boolean(busy) || !init.initialized || !allow[button.dataset.unit]; });
const orgs = $('proxy-setup-orgs');
if (!Array.isArray(state.organizations)) orgs.replaceChildren(Object.assign(document.createElement('tr'), { innerHTML: '<td colspan="4">Organizations could not be read.</td>' }));
else if (!state.organizations.length) orgs.replaceChildren(Object.assign(document.createElement('tr'), { innerHTML: '<td colspan="4">No organizations.</td>' }));
else orgs.replaceChildren(...state.organizations.map(org => { const tr = document.createElement('tr'); tr.append(cell(org.id), cell(org.proxy === 'shared' ? 'Shared (this instance)' : org.proxy), cell(org.catalogue?.status ?? org.error ?? 'unknown'), cell(Number.isInteger(org.catalogue?.models) ? String(org.catalogue.models) : '—')); return tr; }));
$('proxy-setup-own').textContent = state.ownProxy?.available ? 'An organization can run its own proxy.' : `Own proxy per organization: ${state.ownProxy?.state ?? 'not built'}. ${state.ownProxy?.reason ?? ''}`;
}
function paint() {
paintState(); paintNext(); paintControls(); paintReceipts();
// An initialized proxy keeps the init form folded after a reload too.
if (state.init.initialized) $('proxy-setup-init-box').open = false;
const job = state.fetch;
if (job.state === 'running') say(`Fetching: ${STEPS[job.step] ?? job.step ?? 'starting'}…`);
else if (job.state === 'failed' && seenFetch !== 'failed') { say(`Fetch failed. ${explain(job.error)}`, 'error'); seenFetch = 'failed'; }
else if (job.state === 'done' && job.receipt && seenFetch !== job.receipt.time) { seenFetch = job.receipt.time; if (!state.receipts?.some(r => r.op === 'fetch' && r.time === job.receipt.time)) addReceipt(job.receipt); say(`Fetched ${job.receipt.version}; archive, panel and executable hashes verified.`, 'success'); }
clearTimeout(timer); timer = null;
if (enabled && (job.state === 'running' || state.busy)) timer = setTimeout(load, 1000);
}
async function load() {
if (!enabled || loading) return; loading = true;
try { state = await admin('status'); paint(); if (state.init.initialized) await accountsReady(); }
catch (error) { say(error.message, 'error'); }
finally { loading = false; }
}
async function act(label, action) {
if (!sendOk || state?.busy) return;
state = { ...state, busy: label }; paintNext(); paintControls(); say('Working…');
try { await action(); } catch (error) { say(error.message, 'error'); } finally { if (state) state = { ...state, busy: null }; await load(); }
}
const fetchRelease = () => act('fetch', async () => { await admin('fetch', {}); seenFetch = null; say('Fetch started.'); });
const clearLock = () => act('init-lock-clear', async () => { const result = await admin('init-lock/clear', {}); addReceipt(result.receipt); say('Stale init lock cleared.', 'success'); });
const unitAction = op => act(`unit-${op}`, async () => {
const result = await admin(`unit/${op}`, {}); addReceipt(result.receipt);
if (op === 'restart' && result.evidence?.confirmed !== true) say('The service restarted but the new process could not be confirmed. The fence stays set.', 'error');
else say({ install: 'Proxy service installed.', start: 'Proxy service started and answering.', stop: 'Proxy service stopped.', restart: 'Proxy service restarted; the new process answers.' }[op], 'success');
});
const clearFence = () => act('fence-clear', async () => { const result = await admin('fence/clear', {}); addReceipt(result.receipt); say(result.cleared ? 'Fence cleared.' : 'The fence was not set.', 'success'); });
form.elements.keySource.forEach?.(radio => radio.addEventListener('change', () => { $('proxy-setup-paste').hidden = form.elements.keySource.value !== 'paste'; }));
form.addEventListener('submit', event => {
event.preventDefault();
const f = form.elements, args = {};
const pasted = pasteAllowed && f.keySource.value === 'paste', key = f.key.value, confirm = f.confirm.value;
f.key.value = ''; f.confirm.value = '';
if (!sendOk || state?.busy) return;
for (const name of ['port', 'rotateGraceMinutes', 'firstOutputSeconds']) if (f[name].value !== '') args[name] = Number(f[name].value);
if (pasted) {
if (!key) return say('Type the key in both fields, or choose the generated key.', 'error');
if (key !== confirm) return say(explain('proxy-key-mismatch'), 'error');
args.key = key; args.confirm = confirm;
} else args.generate = true;
act('init', async () => {
const result = await admin('init', args); addReceipt(result.receipt);
$('proxy-setup-init-box').open = false;
say(`Initialized on port ${result.settings.port}${args.generate ? ' with a generated key' : ''}. Install and start the service next. Other organizations' buses read the proxy settings when they start: restart a bus before its agents use the proxy.`, 'success');
window.MosaicConsole?.attachBus?.($('proxy-setup-bus'), 'Restart this organization\'s bus');
});
});
$('proxy-setup-render').addEventListener('click', async () => {
const pre = $('proxy-setup-unit-file');
if (!pre.hidden) { pre.hidden = true; return; }
try { const result = await admin('unit/render'); pre.textContent = result.text; pre.hidden = false; } catch (error) { say(error.message, 'error'); }
});
box.querySelectorAll('[data-unit]').forEach(button => button.addEventListener('click', () => { if (!button.disabled) unitAction(button.dataset.unit); }));
return {
start(config) { enabled = true; sendOk = config.proxyAdminSend === true; box.hidden = false; return load(); },
stop() { enabled = false; clearTimeout(timer); timer = null; const f = form.elements; f.key.value = ''; f.confirm.value = ''; },
};
})();
// Panels the not-configured state hides come back once the proxy answers.
let accountsShown = true;
async function accountsReady() {
if (accountsShown) return;
try { await refresh(); } catch { return; }
accountsShown = true; $('proxy-message').classList.remove('empty');
document.querySelectorAll('.settings-model-proxy .panel:not(#proxy-setup), #proxy-refresh').forEach(el => { el.hidden = false; });
if (!canWrite) document.querySelectorAll('[aria-labelledby=proxy-signin-title],[aria-labelledby=proxy-apikey-title]').forEach(el => { el.hidden = true; });
$('proxy-readonly').hidden = canWrite;
message('Accounts and client keys loaded.');
}
const cleanup = () => { setup.stop(); clearTimeout(poll); login = null; const redirect=$('proxy-login-submit')?.elements.redirect_url, key=$('proxy-apikey')?.elements.key; if(redirect)redirect.value='';if(key)key.value=''; };
window.addEventListener('pagehide', cleanup, {once:true});
let devOwner = false;
let devOwner = false, proxyAdmin = false;
fetch('/api/config', { cache: 'no-store' }).then(r => r.json()).then(config => {
// F3: an organization's settings writes go with settingsSend (the org Console has no global send).
canWrite = (config.send === true || config.settingsSend === true) && config.tester !== true && config.user !== true;
@@ -249,8 +452,18 @@ if (document.documentElement.hasAttribute('data-settings-proxy-document') && !lo
document.querySelectorAll('[data-write]').forEach(fieldset => { fieldset.disabled = !canWrite; });
$('proxy-readonly').hidden = canWrite;
if (!canWrite) document.querySelectorAll('[aria-labelledby=proxy-signin-title],[aria-labelledby=proxy-apikey-title]').forEach(el=>el.hidden=true);
proxyAdmin = config.proxyAdmin === true;
if (proxyAdmin) setup.start(config);
return refresh();
}).then(() => message('Accounts and client keys loaded.')).catch(error => { message(error.message); if(error.message==='The model proxy is not configured for this instance.') { $('proxy-message').classList.add('empty'); $('proxy-readonly').hidden=true; document.querySelectorAll('.settings-model-proxy .panel, #proxy-refresh').forEach(el=>el.hidden=true);
}).then(() => message('Accounts and client keys loaded.')).catch(error => { message(error.message);
// #1873: on the setup page a stopped proxy hides the account panels like an unconfigured one,
// and the setup load brings them back once the service answers.
// Any failed read hides them, not only proxy-down: a proxy that is initialized but not yet answering
// can fail with another code, and a stale error over an empty table would stay after Start.
if (proxyAdmin && error.message !== 'The model proxy is not configured for this instance.') { accountsShown = false; $('proxy-message').classList.add('empty'); $('proxy-readonly').hidden=true; document.querySelectorAll('.settings-model-proxy .panel:not(#proxy-setup), #proxy-refresh').forEach(el=>el.hidden=true); message(error.code === 'proxy-down' ? 'The model proxy is not running yet. Follow the next step under Proxy setup.' : `Accounts could not be read${error.code ? ` (${error.code})` : ''}. Follow the next step under Proxy setup; the account panels return once the proxy answers.`); return; }
if(error.message==='The model proxy is not configured for this instance.') { accountsShown = false; $('proxy-message').classList.add('empty'); $('proxy-readonly').hidden=true; document.querySelectorAll('.settings-model-proxy .panel:not(#proxy-setup), #proxy-refresh').forEach(el=>el.hidden=true);
// #1873: the owner sets the proxy up on this page (Proxy setup above); no terminal note.
if (proxyAdmin) { message('The model proxy is not set up yet. Follow the next step under Proxy setup.'); return; }
// F3: on a dev instance, say plainly why this page cannot set the proxy up and what does.
// Setup takes a management key typed twice at a terminal and writes the proxy's credentials;
// this page never handles that key. Account sign-in (a provider's own OAuth) then runs here.
Loaded 100 of 122 files, more files were not shown because too many files have changed in this diff. Show more