Compare commits

...
23 Commits
Author SHA1 Message Date
jason.woltje f7813339b5 test(refusals): detect escaped malformed identifiers (#1873) 2026-11-10 23:16:26 -06:00
jason.woltje 3e82bc16c3 fix(refusals): restore validated identifiers already shown by the system (#1873) 2026-11-10 23:16:26 -06:00
jason.woltje 57052c53dd fix(organization): old-path re-export for config-dir-trust.mjs (#1873)
rename-b-identity requires one packages/business/src shim per
packages/organization/src module.
2026-11-10 22:26:46 -06:00
jason.woltje 930182b54a fix(organization): accounts config directory checks every ancestor (#1873)
assertAccountsOwner refused a symlink or non-directory on the config path,
but checked owner and mode only on the leaf. An ancestor owned by another
user, or writable by others without the sticky bit, let that user rename the
directory away and put their own accounts.json in its place. Since M3 that
file decides root authority on a deployed Console.

One shared helper, config-dir-trust.mjs, now holds A2's rule: every ancestor
belongs to root or this uid and is group or other writable only with the
sticky bit; configDir itself is this uid's and not group or other writable.
The accounts store (load, init, lock, locked write, mosaic accounts) and the
root agent file's writer both call it, each with its own refusal code. A2's
messages are unchanged.

Accounts refusals no longer quote a path: the config directory and accounts
file uid messages name the rule and the uid only (redaction rule, #1916).
A missing config directory now refuses accounts-unsafe instead of a raw
ENOENT.
2026-11-10 22:21:06 -06:00
jason.woltje d0cfebe286 fix(cli): M3 re-check: provider denials answer 403; A2 test owner carries rootOwner (#1873)
The rehearsal provider matches routes on the parsed pathname, so a
query string no longer turns authorize or token into a 404. Authorize
and token answer 403 to any method before the GET check, and only
discovery and the JWKS answer 405 to another method. R20 now sends a
POST to token and a GET to authorize with a query, and expects 403 for
both.

The upstream A2 agent-levels write test stubbed an owner session
without rootOwner. M3 sets the hook's owner flag from rootOwner, as
sso.mjs grants it, so the stub now carries rootOwner: true, as
system-types-http does. No check changes.
2026-11-10 21:38:17 -06:00
jason.woltje 9acc378555 fix(cli): M3 review round: re-pin migration, rehearsal provider (#1873)
An installed unit must equal the render but for the copy id, so a
re-pin that adds --sso refused render-refused. case2-unit gains --check
(every render check, nothing written) and --check-new (a first install's
checks). stack-upgrade runs both checks before anything stops, and
--next-args reads root's <instance>.args.next: equal before -- but for
--copy, different after it. After the stage it moves the installed unit
files into a new <instance>.replaced.XXXXXX directory and renders a
first install. A failure moves them back unchanged and restarts the old
copy, and confirm swaps the args file. The run sheet gives the commands,
the rollback past the migration included.

A stamped rehearsal now starts an isolated provider on 127.0.0.1 port 0
with a certificate and keys made for the run. It serves discovery and a
JWKS for the live provider ids and answers 403 to authorize and token.
Only the scratch Console trusts its certificate. The gate requires each
provider ready (sso-ready); provider-discovery requires discovery and
JWKS fetches and no code or token request. The provider stops with the
run, and by itself once the run's shell is gone.

The system-path test helper asserts the log's from copy again. The
pinned-copy sign-in requests are bounded, and cleanup waits for each
Console to exit.
2026-11-10 21:38:17 -06:00
jason.woltje db822c5b9d feat(cli): M3 reconciliation: accounts units, stamped renders, rehearsal (#1873)
stack-release refuses a user-unit activate, rollback or pm-on when the
instance shows any accounts sign (stamped target, accounts.json,
console.sso, .deployed), before anything stops. Root renders the system
units with case2-unit.sh, and the op runs with --units external.

case2-unit renders a stamped copy's units only with --sso, console.sso,
an accounts.json that loads and a valid .deployed, and names each
missing step in run-sheet order before any write. Per Jason's ruling of
2026-11-10, a stamped library unit takes --sso and refuses proxy-preview
admission and its proof. The unstamped and dev paths are unchanged.

rehearse runs a stamped copy against rehearsal.invalid with scratch
slot placeholders, a copy of the live accounts.json and the copy's own
.deployed. It refuses sso-not-prepared and accounts-not-initialized
before any rehearsal directory exists, and sso-isolated before the host
starts.

The tests that turned SSO on through the user unit now pin the refusal
and reach the same end state through the system path. The copy tests
start the Console on real scratch prerequisites and pin each refusal.
The run sheet, TOOLS.md and the accounts design record the ruling and
the migration order.
2026-11-10 21:38:17 -06:00
jason.woltje 554bc5116d feat(cli): require dedicated host uid for accounts system units (#1873) 2026-11-10 21:38:17 -06:00
jason.woltje 6db8fb2dce feat(cli): enforce stamped-copy and deployed-instance boundaries (#1873) 2026-11-10 21:38:17 -06:00
jason.woltje 85122dd2bc feat(webui): separate account sessions from root authority (#1873) 2026-11-10 21:38:17 -06:00
jason.woltje 82dcba2d45 fix(agents): A3 hidden checkouts cover every left-out project and refuse links, final admission binds the organization revision (#1873) 2026-11-10 20:27:33 -06:00
jason.woltje 29f7c3af85 fix(agents): A3 excluded checkouts hidden, launch boundary rechecks unions, reload collisions and changed defines refuse (#1873)
- A checkout an earlier launch left for a project the instance may no longer
  work in is covered by an empty tmpfs inside the sandbox; the host copy stays.
- The launcher rechecks every prepared project against the reloaded document
  after preparation, and the bus's final admission takes the instance and
  checks the broker's current unions.
- A project define whose vars changed since boot refuses its copy until the
  next start.
- Instance-name collisions are recomputed at each reload from the frozen root
  claims and the current project defines; every claimant refuses.
2026-11-10 20:27:33 -06:00
jason.woltje 17a48217f6 fix(agents): A3 project unions reach every agent, copies never collide or shadow (#1873)
Review fix round for slice A3.

- Every project's union is recomputed from the current organization file
  at each load. A refused union refuses the project's own copies and every
  inherited root or org agent there: no mount at launch (checkout-skipped,
  project-refused), no thread and no room, each naming the project and why.
- A project exclude removes that project's mount from the excluded agent;
  a narrow reaches the instance's resolved limits, so its real bundle is
  narrowed. A narrow in any project narrows the instance for every launch,
  since one launch mounts every project it works in.
- Two copies whose instance names collide both refuse, naming each
  claimant; the view lists the claimants. An organization instance of the
  same name replaces a root or project copy, at boot and after it, and the
  copy is gone rather than refused.
- Rooms hold only the thread instances that work in the room's project.
- The grid finds an agent's copy by name and project from the bus view,
  never by rebuilding the instance name.
- Real-boot regressions: inherited agents in a refused union, exclude and
  narrow in the bundle, an org override after boot, two organizations each
  running a copy, a root file change while running, and a grid fed by a
  booted bus.
2026-11-10 20:27:33 -06:00
jason.woltje 9d00f9ad54 feat(agents): A3 root and project agent copies at bus boot (#1873)
Each root agent an organization does not exclude, and each agent a project
defines, becomes a copy when the organization's bus boots. The root file is
read once, through loadRootAgents, at the cli boot step; the snapshot is fixed
until the next boot. A bound copy joins the organization document's roles and
goes through validateBusinessDocument and checkRoles with the organization's
own credential map. A copy whose role needs a credential service is unbound
and refuses at launch by name and service (credential-unbound); a copy the
organization refuses is copy-refused; every agent in a project whose union
refuses is project-refused, naming the project. A project agent launches
only with its own project's mounts and takes a thread only in its project
(project-agent-elsewhere). Refusal events carry the code alone. The bus's
instances read carries the copies with their running runs, and the Agents
grid shows each copy's real status and reason.

channel-proof re-pins webui bus.mjs for four STATUS entries; no proof or
transport change. packages/business gets the agent-copies.mjs re-export.
2026-11-10 20:27:33 -06:00
jason.woltje a3e82efe23 fix(redaction): close independent review findings (#1873) 2026-11-10 19:56:05 -06:00
jason.woltje c8ff8ea128 fix(redaction): omit remaining configuration values from refusals (#1873) 2026-11-10 19:56:05 -06:00
jason.woltje ddf4f895cd fix(rails): close review guidance and coverage gaps (#1873) 2026-11-10 19:42:36 -06:00
jason.woltje 7035ce61b3 feat(harness): expose caller rails and refusal guidance (#1873) 2026-11-10 18:51:15 -06:00
jason.woltje 54bf239e32 test(webui): v1 business golden carries RR's resolve rights (#1873) 2026-11-10 18:31:23 -06:00
jason.woltje a4eed1e219 fix(agents): A2 review round: config check, name rule, drafts and test gaps (#1873)
The CLI agent verbs validate the instance config through loadSystem before
any read or write, and print a fixed line for errors they do not own. The
root writer refuses a config directory with an ancestor another user can
write without the sticky bit. The project HTTP route checks agent names
with the organization ID_PATTERN and answers 400 naming the key.

The Settings level forms take their own class, so the role-instance
handlers no longer catch them, and keep both drafts in module state with
the revision each began at. The organization writer tests now reach the
file's own uid check and test the assistant protection with an assistant
in the fixture.
2026-11-10 18:25:54 -06:00
jason.woltje 1e1bbaa6dd fix(webui): A2 agent level grids scroll inside their card on phones (#1873) 2026-11-10 18:25:54 -06:00
jason.woltje 78bda9d6a4 test(organization): A2 refusal patterns follow var-redact's key-only text (#1873) 2026-11-10 18:25:54 -06:00
jason.woltje 6b39af7888 feat(agents): A2 root and project agent level writers, CLI and Settings forms (#1873)
Root writer for <configDir>/agents.json (owner only, revision-checked, atomic, A1-validated,
receipts name agents and keys only), the project.agents writer verb on the organization
writer, `mosaic agents root` and `mosaic project agents` verbs, and owner-only Settings >
Agents edit forms on GET/POST hooks gated like /api/agents/levels.
2026-11-10 18:25:54 -06:00
153 changed files with 7485 additions and 606 deletions

No files matched your search

+84 -6
View File
@@ -807,13 +807,52 @@ the port and the backup's `SHA256SUMS`. Then it makes
Roles come from the target copy's `roles/`, as the live bus reads them.
- `steps.log` holds the same PASS and FAIL lines the run prints, with times;
`host.log`, `console.log` and, after a migration, `migrate.log` sit beside it.
- When the live config has `console.sso` (M3, #1873), the scratch copy keeps
its provider ids, client ids, algorithms and subjects and moves the public
origin to `rehearsal.invalid`, a name that never resolves. For a stamped
target each issuer becomes `ORIGIN/ID/` at the rehearsal provider below;
otherwise it moves to `rehearsal.invalid` too. Each client secret slot is a 0600 file under `config/secrets/mosaic-stack/`
holding `rehearsal-placeholder-secret`; the live secret is never read.
A stamped target copy (one with `.stack-build.json`) serves only with `--sso`,
so its rehearsal needs the live instance's human-made SSO steps. Without a live
`console.sso` it refuses `sso-not-prepared`; without the live `accounts.json`
beside the config it refuses `accounts-not-initialized`. Both name the run-sheet
step. Otherwise it copies the live `accounts.json` into `config/`, writes
`data/.deployed` with the target copy's own `writeDeployment`, prints
`PASS deployment`, and starts the Console with `--sso`.
Before that, a stamped run starts a rehearsal provider and prints
`PASS provider-start`. It is a node process on `127.0.0.1` port 0 that serves
HTTPS with a certificate made for this run, under `provider/` (mode 0700). For
each live provider id it serves discovery and a JWKS whose public key it made
in memory for the first algorithm it can make from that provider's list. Its
authorize and token routes answer 403 to any method and query: it issues no
code and no token, and
it reads no live secret. It logs each request to `provider/requests.log`,
deletes its TLS key file once it has read it, and exits on SIGTERM or when
the rehearsal's process is gone. `provider-start` fails when it exits early,
writes no origin within the timeout, writes an origin that is not
`https://127.0.0.1:PORT`, or leaves its key file. Only the scratch Console gets
`NODE_EXTRA_CA_CERTS` naming `provider/cert.pem`; the host does not.
The scratch Console runs every startup check against that provider. The gate
adds `sso-ready`: `/healthz` must report each provider `ready`. After the gate,
`provider-discovery` requires a 200 discovery and JWKS request per provider and
no authorize or token request. The end stops the provider with the Console and
host and prints `provider-stopped`. The live `accounts.json` joins the end check
`live-config-unchanged`. The rehearsal adds no subject and no account.
A guard then loads the scratch config with the target's code and refuses
with a named check: `data-root-live` when the scratch data root resolves to
the live one, `sessions-live` when a `sessions.json` there names a running
pid (the run never deletes the file), `data-root-scratch`, `notify-off`,
`tracker-configured` when `bootConfig` still sets a tracker, and
`credential-ref-live` for an env reference or a file outside `placeholders/`.
`tracker-configured` when `bootConfig` still sets a tracker,
`credential-ref-live` for an env reference or a file outside `placeholders/`,
and `sso-isolated` when a `console.sso` origin is not on `rehearsal.invalid`,
an issuer is not `ORIGIN/ID/` at the rehearsal provider (not on
`rehearsal.invalid` for an unstamped target), or a slot is not its scratch
placeholder.
When the backup's schema differs from the target's, the run checks that the
target refuses the store, migrates it, opens it, migrates it again as a
no-op, and that the pointer's copy refuses the migrated store.
@@ -822,7 +861,8 @@ It starts `scripts/mosaic bus start BUSINESS --no-launch` and the Console's
`serve --port N --business BUSINESS` from the target copy, without `--send`.
The gate needs the host's `launches disabled` line, a live `bus status` host
with this run's pid and a boot from this start, the Console's address line,
`/healthz` 200, and both command lines on the target copy. Then it checks
`/healthz` 200 (and `sso-ready` for a stamped target), and both command lines
on the target copy. Then it checks
that the host wrote no `sessions.json` and no `launches`, stops the Console
and the host (SIGTERM, then SIGKILL and a FAIL after the timeout), checks that
the host released `writer.lock`, and compares the backup and the live config
@@ -834,6 +874,21 @@ Usage exits 4. Nothing prunes rehearsal directories.
starts no thread ticker, and leaves an earlier host's `sessions.json` and
its runs alone. No unit passes it, and `--pm` with it is a usage error.
An accounts instance runs system units (M3, #1873). Without `--units external`,
`activate`, `rollback` and `pm-on` refuse `accounts-user-unit`, before
anything stops and before `--plan` prints, when the target copy holds a
`.stack-build.json` (a stamped copy), when `accounts.json` sits beside the
config, when the config holds `console.sso`, or when `<dataRoot>/.deployed`
exists. A config that cannot be read as JSON refuses as well. The message
names the first sign found and says root renders the units with
`case2-unit.sh`. For `activate` and `rollback` it adds that the run takes
`--units external`. For `pm-on` it says the PM is a flag in root's bus unit.
So a user-unit run never turns `--sso` on any more. The `--sso`, `sso-slot`
and `--no-sso` text above describes the pointers that pre-M3 hosts left, and
the Console's own start checks the slot (`readSlot` in
`packages/webui/src/sso.mjs`). A rollback or `--no-sso` from such a pointer
runs once `console.sso` is gone and no other sign is present.
#### Root-owned units (`--units external`, row 232)
On a host whose bus and Console are root's system units, the stack user runs
@@ -894,8 +949,9 @@ the target migrates the store and the run lacks `--migrate` (row 233). Under
```
scripts/stack-upgrade.sh upgrade --instance dev --bundle FILE --sha SHA [--migrate] [--no-rehearsal]
[--rehearse-port N] [--timeout S]
scripts/stack-upgrade.sh rollback --instance dev --to ID [--restore ACT [--events N]] [--timeout S]
[--rehearse-port N] [--next-args] [--timeout S]
scripts/stack-upgrade.sh rollback --instance dev --to ID [--restore ACT [--events N]] [--next-args]
[--timeout S]
```
Root runs it from a root-owned checkout of the reviewed commit, never from a
@@ -935,7 +991,13 @@ user's clone `<home>/src/mosaic-stack`:
(`args-differ`), and
`activate SHA BUSINESS --units external --plan --expect-from OLD` exits 0
(`plan-refused`, which covers `open-activation`, `migrate-required` and
`pointer-differs`).
`pointer-differs`). Then the render checks (#1873 M3):
`case2-unit.sh --check` renders OLD from the args file and compares the
installed units with it, so a rollback could render them, then checks
SHA's render the same way. Either refusal is `render-refused`. A re-pin
that changes the serve flags fails the second check, because an
installed unit must equal the render but for the copy id (row 195); it
takes `--next-args`, below.
4. The rehearsal, `rehearse SHA BUSINESS --backup last --port 7339`
(`--rehearse-port`). Exit 0 goes on. Exit 3 means no backup in the log
and goes on only with `--no-rehearsal`, logging `rehearsal-skipped`;
@@ -950,6 +1012,22 @@ user's clone `<home>/src/mosaic-stack`:
still names the old one, and root moves the new file (the result line
names it) into place by hand.
`--next-args` changes the serve flags, as the SSO migration does
(`docs/guides/host-run-sheet.md` section 7). The run reads
`/etc/mosaic-stack/dev.args.next` as it reads the args file
(`args-unreadable`). It must equal the args file before `--` but for
`--copy`'s value (`next-args-differ`) and differ from it after `--`
(`next-args-same`). The target's check is `case2-unit.sh --check-new`, a
first install's checks from the next args, without the installed units'.
After A3, root moves the installed unit files into a new 0700 directory
`/etc/mosaic-stack/dev.replaced.XXXXXX` (step `units-aside`), so A4 renders
as a first install. A failure from there moves them back unchanged
(`units-back`), runs `daemon-reload` and starts the old units with no render.
After A5 the old args file is copied into the replaced directory, the next
args file is removed (step `next-args`), and the new args file, made from
the next args, moves into place. The result line names the replaced
directory. Nothing removes it.
A failure in A2 or a stage that exits 3 starts the old units again and gates
them (A2f). A later failure stops both units, runs `revert`, renders the old
copy and runs `confirm --rollback OLD --cause A3|A4|A5`. `rollback` is the
+80 -8
View File
@@ -386,10 +386,15 @@ then the bus, then the proxy, re-render all three onto the selected copy, and st
then the Console. Recovery after a failed upgrade restores all three to the old copy. A missing or
disagreeing proxy unit, or an installed proxy without `--proxy` in the args file, refuses before anything stops.
For the `library` profile, the serve argv after `--` keeps the proxy-preview trio and required proof pair
but omits `--send`. That Console is read-only until Case 2 gains real OIDC in #412; `case2-unit.sh`
explicitly refuses `--send`. Proxy preview and proof-protected reads remain available. The `factory`
profile and its private tester socket are unchanged.
A stamped copy, which is every copy `stack-copy.sh` makes, signs in through SSO accounts in both profiles
(M3, #1873; Jason's ruling 2026-11-10). After `--`, the `factory` profile takes `--sso --send` with its
private tester socket. The `library` profile takes a bare `--sso`, still without `--send`, so its Console
stays read-only. A stamped library copy refuses the proxy-preview trio (`--public-host`, `--auth-header`,
`--allow-user`) and the proof pair (`--proof-header`, `--proof-file`): proxy-preview admission does not
apply to a stamped copy. Only an unstamped library copy, which no host install makes, keeps the old
proxy-preview argv and refuses `--sso`. Before writing anything, `case2-unit.sh` refuses a stamped copy
that lacks `--sso` or any prerequisite and names each missing step. The migration below lists them in
order.
A library host installed before #385 has `--send` in two places, because the earlier `case2-unit.sh`
required it: a `--send` line after `--` in the args file, and `--send` in the installed Console unit's
@@ -418,10 +423,77 @@ grep -n '^ExecStart=' /etc/systemd/system/mosaic-webui.service | grep -c -- ' --
lead with the command's output. Removing only the args line does not help: the run then refuses
`console-unit-legacy`.
The Console stays read-only after the upgrade, until #412 (#385).
The library Console stays read-only after the upgrade (#385). From M3 it signs in through SSO
accounts, as the migration below sets up.
Precheck line: none. `stack-upgrade.sh` refuses `args-unreadable` or `console-unit-legacy` on any of these
rules, before it stops a unit.
### Migrating a host to SSO accounts (M3, #1873)
A host re-pinned onto an M3 copy migrates first, in this order. The order is the same for the `factory`
(Case 1 and .53) and `library` (.54) profiles. Each step is a live step that needs Mos's go. Nothing in the
stack picks or invents a subject: the subjects come from `console.sso`, which a human prepares, and
`accounts.json` comes only from the human verb `mosaic accounts init`.
1. Check or create `.deployed` through the installer. The stack's user's `case2-install.sh` (section 6)
writes `<dataRoot>/.deployed` naming the instance at its step 2. It never replaces an existing one, and
it refuses one that is invalid or names another instance. Check it read-only:
`stat -c '%U %a' <dataRoot>/.deployed` prints `mosaic 444`.
2. Prepare SSO. A human adds `console.sso` to `/srv/mosaic/.config/mosaic-dev/config.json`, with its
providers, their subjects and its client-secret slots, per
`docs/plans/2026-11-10_accounts-design.md`. Jason places the client secrets (section 9).
3. Run `mosaic accounts init --name <name>` as `mosaic`. This is a human verb, run from a human shell and
never by an agent. It creates the one operator account from the configured `console.sso` subjects and
refuses if `accounts.json` exists.
4. Provision the dedicated uid. `mosaic` must keep section 1's shape: a non-login shell, the only member
of its primary group, and owner of its config directory and data root. An accounts instance never runs
under a human or development seat uid, and never under the `webui-service.sh` user units.
5. Re-pin with `--next-args`. Leave `/etc/mosaic-stack/dev.args` as it is. Write the new flags to
`/etc/mosaic-stack/dev.args.next`, root's, mode 0640: the same lines before `--`, and after `--` the
serve flags with `--sso` added (`factory`: `--sso --send`). For `library`, also remove the
proxy-preview trio (`--public-host`, `--auth-header`, `--allow-user`) and the proof pair
(`--proof-header`, `--proof-file`). Then run
`stack-upgrade.sh upgrade --instance dev --bundle FILE --sha SHA --next-args`.
A plain re-pin refuses `render-refused` here, because an installed unit must equal the render but for
the copy id. With `--next-args`, before anything stops, the driver checks the installed copy against
`dev.args` and the target as a first install from `dev.args.next`. It refuses `next-args-differ` when
the files differ before `--` (other than `--copy`), and `next-args-same` when they do not differ after
it. After the stage it moves the installed unit files into a new 0700 directory
`/etc/mosaic-stack/dev.replaced.XXXXXX` and renders the target as a first install. A failure from
there moves them back unchanged and starts the old copy again. After `confirm` passes,
`dev.args.next` becomes `dev.args`, and the replaced directory keeps the old unit files and the old
`dev.args`. The result line names the directory. Nothing removes it; root removes it by hand once no
rollback needs it.
To roll back past the migration, put the replaced args back as the next args and roll back with
`--next-args`:
```sh
install -m 0640 -o root -g root /etc/mosaic-stack/dev.replaced.XXXXXX/dev.args /etc/mosaic-stack/dev.args.next
stack-upgrade.sh rollback --instance dev --to <old copy id> --next-args
```
For `library` this is the only rollback to a copy older than M3: `case2-unit.sh` refuses `--sso` for
an unstamped library copy (#416), so the old copy comes back with its old flags, proxy-preview
admission included, as before the migration. A `factory` rollback may instead keep `--sso --send`
with a plain `rollback`.
If a step is missing, `case2-unit.sh` refuses before it writes a unit and prints the missing steps,
numbered in this order, for example:
```text
case2-unit: refusing the units for <sha>: a stamped copy's Console starts only with SSO accounts; missing steps, in order:
1. check or create .deployed: mosaic runs case2-install.sh, whose deployment step writes <dataRoot>/.deployed
2. prepare SSO: console.sso in mosaic's system config /srv/mosaic/.config/mosaic-dev/config.json, with its client secret slots
3. run `mosaic accounts init` as mosaic, a human verb: no /srv/mosaic/.config/mosaic-dev/accounts.json
4. add --sso to the serve flags, then re-pin
```
Without `console.sso`, the render does not check `accounts.json`, because its subjects name providers SSO
has not configured yet. The next render after step 2 checks it. A rollback never removes accounts.json,
its receipts or `.deployed` (`docs/plans/2026-11-10_accounts-design.md`, section 7).
Precheck line: none. `stack-upgrade.sh` refuses `args-unreadable`, `console-unit-legacy`, `render-refused`,
`next-args-differ` or `next-args-same` on any of these rules, before it stops a unit.
## 8. Enrollment, once per host
@@ -450,7 +522,7 @@ file with `stat -c '%U %a %n' PATH`, never `cat`. `<business>` is the host's bus
| Path | File | Mode | Owner | Profile | Precheck line |
|---|---|---|---|---|---|
| `/srv/mosaic/.config/mosaic-dev/secrets/` | `claude-session.env`, one line `ZAI_API_KEY=...` | 0600 | mosaic | library | `<path> sets ZAI_API_KEY` |
| `/srv/mosaic/.config/mosaic-dev/secrets/` | the Console's proof file, the path the args file's `--proof-file` names: one base64url line, at least 32 `[A-Za-z0-9_-]` characters, with one optional trailing LF (`packages/webui/src/serve.mjs:263-264`) | 0600 or 0400 | mosaic | library | none. Render checks metadata only and never opens the file (`scripts/case2-unit.sh:397-408`). Bad bytes refuse at Console start with exit 2 (`packages/webui/src/serve.mjs:267`, `packages/webui/src/cli.mjs:138-140`). Precheck line missing. |
| `/srv/mosaic/.config/mosaic-dev/secrets/` | before M3 only, and an unstamped copy only: the Console's proof file, the path the args file's `--proof-file` names. A stamped copy refuses `--proof-file` (section 7). The file is one base64url line, at least 32 `[A-Za-z0-9_-]` characters, with one optional trailing LF (`packages/webui/src/serve.mjs:263-264`) | 0600 or 0400 | mosaic | library | none. Render checks metadata only and never opens the file (`scripts/case2-unit.sh:397-408`). Bad bytes refuse at Console start with exit 2 (`packages/webui/src/serve.mjs:267`, `packages/webui/src/cli.mjs:138-140`). Precheck line missing. |
| `/srv/mosaic/.config/mosaic-dev/secrets/<business>/` | `library-gitea` | 0600 | mosaic | library | After a unit start, wait for loopback `/healthz` with the bounded retry in section 10 before `case2-precheck.sh --business <business> --library-status`: `library-last-error library-credential-missing` until it's placed, then `library <project> last fetched ...` |
| `/srv/mosaic/.config/mosaic-dev/secrets/agents/<business>/<agent>/` | one file per service | 0600, dirs 0700 | mosaic | both | none. Filled only by `mosaic credentials issue`, or Approve after #436. Never by hand. |
+28 -5
View File
@@ -108,7 +108,10 @@ configured subject (`packages/cli/src/config.mjs:141`), and
`packages/webui/src/cli.mjs serve` from that copy. Its flag set is
`--port`, `--business`, `--board`, `--sso`, `--send` and the header
preview set (`webui-service.sh:98`). It has no dev-owner flag.
`scripts/case2-unit.sh` renders Case 2 units the same way.
`scripts/case2-unit.sh` renders Case 2 units the same way. At BASE its
library profile takes the preview set and refuses `--sso`. Under the
owner ruling of 2026-11-10 a stamped library copy signs in through SSO
accounts instead (section 6, item 5).
### Organizations and who may read them
@@ -1231,7 +1234,13 @@ The executable boundary:
this instance, and accounts.json loads. The Case 1 and Case 2 installers
write `.deployed`.
5. **The unit renderers have no dev flag.** `webui-service.sh` and
`case2-unit.sh` accept no dev flag today (`webui-service.sh:98`). M3 adds
`case2-unit.sh` accept no dev flag today (`webui-service.sh:98`). For a
stamped copy, `case2-unit.sh` renders either profile, factory or library,
only with `--sso`, `console.sso`, a loading accounts.json and a valid
`.deployed`, and refuses before any write, naming each missing step. A
stamped library unit refuses proxy-preview admission and its proof flags:
proxy-preview does not apply to a stamped copy. The dev and unstamped
paths keep the preview set. M3 adds
a test that pins it.
The instance boundary, which also covers a checkout started by mistake on a
@@ -1289,13 +1298,22 @@ business file.
### Rollback
Rollback keeps OIDC. Nothing in it removes `--sso` or turns sign-in off.
Rollback keeps OIDC. Nothing in it removes `--sso` or turns sign-in off,
except the library rollback past the migration below.
- The supported rollback is a pinned code and config rollback: install the
earlier stack copy with the same unit flags, including `--sso`, through
`webui-service.sh install --copy <earlier id>`, as the unit script
already describes. Leave accounts.json, its receipts and `.deployed` in
`stack-upgrade.sh rollback --instance NAME --to <earlier id>` for the
dedicated system units. The accounts instance must not use the
`webui-service.sh` user-unit path. Leave accounts.json, its receipts and `.deployed` in
place. BASE code ignores them, and the next roll-forward needs them.
- A library host rolled back to a copy older than M3 is the exception.
`case2-unit.sh` refuses `--sso` for an unstamped library copy (#416), so
that rollback renders the old copy from the args file the migration
replaced, through `stack-upgrade.sh rollback ... --next-args`
(`docs/guides/host-run-sheet.md` section 7). The old copy comes back with
its old flags, proxy-preview admission included, as before the migration.
accounts.json and `.deployed` stay in place.
- Before the first accounts org exists, that rollback restores today's
behavior completely. `console.sso` owners sign in as before, and FED R6
users keep their root grants. Account-only users and pending invites stop
@@ -1450,6 +1468,9 @@ Acceptance tests, against a fake OIDC provider on port 0:
- `--dev-local-owner` from a checkout refuses with accounts.json present,
with `console.sso`, and with `.deployed`.
- `webui-service.sh render` and `case2-unit.sh` refuse a dev flag.
- For a stamped copy, `case2-unit.sh` renders a library or factory unit
only with `--sso` and the prerequisites, names each missing step before
any write, and refuses the proxy-preview and proof flags.
- `case2-unit.sh` accepts the host run sheet's `mosaic` account fixture only
when its shell is non-login, its primary group has no other members, and
it owns the config directory and data root. Fixtures for a human login and
@@ -1666,6 +1687,8 @@ and draft 3 applies them. No question from draft 2 remains open.
| May an invite bind to an email domain? | "agreed" | No domain binding. Section 4 binding, test in M6. |
| May members read the board and write tasks? | "Similar to 1. grantable right via RBAC. Default owner R/W, member R" | The `board.read` and `task.write` rights. `member` holds `board.read` only. Section 3, slices M1, M5 and M7. |
The library Console signs in through SSO accounts like every deployed Console; proxy-preview admission does not apply to a stamped copy.
### Choices made under the rulings
These are decided in this draft, not open. Each one is a single reviewed
+7
View File
@@ -189,8 +189,15 @@ those messages stranded: no later holder receives or reads them, and `agents`
lists them under `stranded` (Agent state, below). `message.read` is an explicit acknowledgement. `trail(messageId)` lets
an operator inspect content and delivery evidence after an uncertain result.
Broker socket refusals retain `ok:false`, `error` and any scoped `reason`, and add
`rail` and `nextStep` from `src/refusals.mjs`. Guidance is static and never
quotes caller input or file bytes. The runner and typed tools carry the guidance
to the agent. `src/rails.mjs` in the organization package supplies the shared
prompt/live projection; `decisionAutonomy` is the extension point for AU.
| Verb | Arguments and result |
|---|---|
| `my_rails` | No args. Read only the current caller's held actions and routes, resolve rights with fixed autonomy levels, network, credential service/scope names and messaging targets. Recomputed from live policy on every call; never returns credential references or values, writes evidence, or raises a decision. Exposed to every managed role as `my_rails`. |
| `role.claim` | No args; claims capability's role/run. One current holder per business/instance; a revoked run cannot reclaim that role, including after restart. |
| `role.release` | No args; only its current holder releases. |
| `role.revoke` | `{role,decision}`; needs an approved decision for this exact target and holder incarnation, routed as REQ-DEC-2 says: the human's for an uncited gated raise, the arbiter's for one citing the current spec. A role whose file lists `role.end` (the chief of staff, row 123, REQ-COS-1) sends `{role,reason}` instead: no decision, a reason of 1 to 500 characters, logged in the `action.allowed` body. Sending both is `invalid-request`; the caller's own run is `role-end-refused`; any other role sending `reason` gets `decision-required`. An instance whose definition is in `END_PROTECTED` (the assistant) is ended or revoked only by the chief of staff or with a decision the human resolved through the CLI (`via` `cli`); anyone else's reason, or a decision an arbiter resolved, is `role-end-refused` (Jason's ruling Q169, row 139). A target whose definition is `THREAD_DEFINITION` (the assistant) has no role claim: the end revokes the current claim of each of its threads, refuses `not-held` when none is held, and returns `{revoked:true,threads:N}` (row 155, #1649). |
+87 -4
View File
@@ -1,3 +1,5 @@
import { ownRails } from '../../organization/src/rails.mjs';
import { refusalGuidance } from './refusals.mjs';
import { PROXY_PAGE_VERBS, PROXY_PAGE_CODES } from './proxy-page-vocabulary.mjs';
import { randomUUID, randomBytes, createHash } from 'node:crypto';
import { validManagementKey, proxyTiming } from '../../model-proxy/src/validation.mjs';
@@ -245,10 +247,11 @@ const canonical = (x) =>
Number.isFinite(Date.parse(x)) &&
new Date(x).toISOString() === x;
export class BusError extends Error {
// Only explicitly scoped refusals carry a reason; the envelope passes it on.
// Guidance is static policy text; never quote arguments or file bytes here.
constructor(code, reason) {
super(code);
this.code = code;
Object.assign(this, refusalGuidance(code));
if (reason !== undefined) this.reason = reason;
}
}
@@ -1217,6 +1220,11 @@ export class Broker {
this.#businesses[name] = next;
this.#scheduleRequests();
}
// #1873 A3: host-only, for the launch boundary's admission: the code that refuses `instance` in code
// project `repo` under the business's current document (the one the last reload swapped in), or null.
projectBarred(business, instance, repo) {
return this.#projectRefused(this.#business(business), instance, repo)?.code ?? null;
}
// Host-only current role resolver shared by the broker and built-in task tracker.
taskRoles(business) {
const roles = this.#business(business).roles;
@@ -1531,11 +1539,40 @@ export class Broker {
this.#processingWork();
const b = this.#business(s.business);
if ((b.retired ?? []).includes(instance)) fail('instance-retired');
this.#copyRefused(b, instance);
if (!Object.hasOwn(b.roles, instance)) fail('unknown-role');
if (this.#threaded(s.business, instance)) fail(thread);
if (this.#store.get('SELECT state FROM launch_state WHERE business=?', s.business)?.state === 'revoked') fail('launch-revoked');
if (this.#firing(s.business, instance)) fail('instance-firing');
}
// #1873 A3: a root or project copy the boot left unbound or refused isn't in `roles`. Its refusal
// names the agent and the missing services or the reason, never a value (organization
// agent-copies.mjs copyRefusal; inlined because this module also loads in the sandbox client).
#copyRefused(b, instance) {
const copy = b.copies && Object.hasOwn(b.copies, instance) ? b.copies[instance] : null;
if (!copy || copy.state === 'instance') return;
if (copy.state === 'unbound') throw new BusError('credential-unbound', `${instance} has no binding in ${b.id} for ${copy.missing.join(', ')}`);
throw new BusError(copy.project === null ? 'copy-refused' : 'project-refused', `${instance} can't launch in ${b.id}: ${copy.reason}`);
}
// #1873 A3: why `instance` can't work in code project `repo`, as a BusError, or null when it can
// (organization agent-copies.mjs projectRefusal, inlined for the same reason). A project copy works
// only in its own project; in a project whose union refuses no agent works; an instance the
// project excludes doesn't work there. The reason names the instance, the project and why.
#projectRefused(b, instance, repo) {
const copy = b.copies && Object.hasOwn(b.copies, instance) ? b.copies[instance] : null;
if (copy && copy.project !== null && copy.project !== repo)
return new BusError('project-agent-elsewhere', `${instance} works only in project ${copy.project}`);
const union = repo === null || !b.unions || !Object.hasOwn(b.unions, repo) ? null : b.unions[repo];
if (!union) return null;
if (!union.ok) return new BusError('project-refused', `${instance} can't work in project ${repo}: project ${repo} refuses its agents: ${union.reason}`);
if (!union.agents.includes(instance)) return new BusError('project-refused', `${instance} can't work in project ${repo}: the project excludes it`);
return null;
}
// #1873 A3: the union refusal of code project `repo`, as a BusError naming the project and why, or null.
#unionRefused(b, repo) {
const union = repo === null || !b.unions || !Object.hasOwn(b.unions, repo) ? null : b.unions[repo];
return union && !union.ok ? new BusError('project-refused', `project ${repo} refuses its agents: ${union.reason}`) : null;
}
// W-N1 (webby-0014): a fire of I stopped its runs, and no fire of I has finished since.
#firing(business, instance) {
return !!this.#store.get(
@@ -1849,7 +1886,15 @@ export class Broker {
this.#revokeLost('access-expiry', true, doc);
rows = this.#threadRows('t.business=?', business);
}
return rows.map(({ owner, project, ...r }) => ({ ...r, ...(this.#archived(business, project) ? { archived: true } : {}), ...(lost(r, owner, project) ? { revoked: true } : {}) }));
// #1873 A3: a thread whose instance no longer works in its project's code project (the project's
// union refuses, or it excludes the instance) says so, so the launcher refuses its launch.
const b = this.#business(business);
const refusedIn = (instance, project) => {
if (!b.unions && !b.copies) return null;
const e = this.#projectRefused(b, instance, this.#store.get('SELECT repo FROM projects WHERE id=? AND business=?', project, business)?.repo ?? null);
return e ? { projectRefused: { code: e.code, reason: e.reason } } : null;
};
return rows.map(({ owner, project, ...r }) => ({ ...r, ...(this.#archived(business, project) ? { archived: true } : {}), ...(lost(r, owner, project) ? { revoked: true } : {}), ...refusedIn(r.instance, project) }));
}
// Row 121 round 2 (#1615, Dewey's F3 in 28591): the launcher's idle stop. The tick decides
// from a threadsState snapshot, and a message the run took after it would be delivered to a
@@ -6107,6 +6152,12 @@ export class Broker {
entry.running = this.#running(s.business, name);
entry.alive = this.#aliveAfterFire(s.business, name);
}
// #1873 A3: the copies this bus booted with, and each one's running runs. The file's own
// instances above carry no copy; a copy carries its state, missing service names or reason.
if (request.verb === "instances" && result?.instances && typeof result.instances === "object" && this.#business(s.business).copies)
result.copies = Object.fromEntries(Object.entries(this.#business(s.business).copies).map(([name, c]) => [name, {
...structuredClone(c), running: c.state === 'instance' ? this.#running(s.business, name) : [],
}]));
return result;
}
return this.#store.transaction(() => {
@@ -6938,6 +6989,13 @@ 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 '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 });
}
case 'role.claim': {
keys(a, []);
if (s.human) fail('agent-required');
@@ -8023,7 +8081,17 @@ export class Broker {
// #797 MVP3: an archived project takes no new room threads and no new requests; reading it
// (room.read) and every existing record stay open.
if ((opening || sending) && this.#archived(s.business, project)) fail('project-archived');
const members = Object.keys(b.roles).filter((instance) => this.#threaded(s.business, instance)).sort();
// #1873 A3: the room's members are the thread instances that work in its code project. A project
// whose union refuses takes no new room thread or request, naming the project and why; its
// existing threads stay readable.
const repo = this.#store.get('SELECT repo FROM projects WHERE id=? AND business=?', project, s.business)?.repo ?? null;
const unionRefused = this.#unionRefused(b, repo);
if ((opening || sending) && unionRefused) throw unionRefused;
const members = Object.keys(b.roles).filter((instance) => {
if (!this.#threaded(s.business, instance)) return false;
const refused = this.#projectRefused(b, instance, repo);
return !refused || (unionRefused !== null && refused.code === 'project-refused');
}).sort();
if (!members.length) fail('thread-instance-missing');
const owner = this.#owner(s);
// Preflight all collisions/missing threads before open creates or send writes anything.
@@ -8111,7 +8179,13 @@ export class Broker {
if (!s.tester && !s.user) this.#human(s);
keys(a, ['project', 'name', 'instance'], ['project', 'name']);
if (!name(a.name)) fail('invalid-request');
const instances = Object.keys(b.roles).filter((r) => this.#threaded(s.business, r));
// #1873 A3: a root or project copy takes a thread only when the caller names it, so leaving the
// instance out still chooses among the organization file's own thread instances.
const copies = b.copies ?? {};
// #1873 A3: the code project the chat project maps to decides which agents work in it.
const repo = this.#store.get('SELECT repo FROM projects WHERE id=? AND business=?', identifier(a.project), s.business)?.repo ?? null;
const instances = Object.keys(b.roles).filter((r) => this.#threaded(s.business, r) &&
(a.instance !== undefined || (!Object.hasOwn(copies, r) && !this.#projectRefused(b, r, repo))));
const instance = a.instance === undefined
? (instances.length === 1 ? instances[0] : null)
: identifier(a.instance);
@@ -8125,9 +8199,18 @@ export class Broker {
fail('project-not-found');
// #797 MVP3: an archived project refuses new threads (and the room's requests, above).
if (this.#archived(s.business, identifier(a.project))) fail('project-archived');
// #1873 A3: a named copy the boot left unbound or refused says why, and a project whose union
// refuses takes no thread, naming the project and why.
if (a.instance !== undefined && instance) this.#copyRefused(b, instance);
const unionRefused = this.#unionRefused(b, repo);
if (unionRefused) throw unionRefused;
// The human selects an instance; omission is compatible only with one candidate.
if (!instances.length || (a.instance !== undefined && !instances.includes(instance))) fail('thread-instance-missing');
if (a.instance === undefined && instances.length > 1) fail('thread-instance-ambiguous');
// #1873 A3: a project agent works only in its own project, and an agent the project excludes
// doesn't work in it.
const elsewhere = this.#projectRefused(b, instance, repo);
if (elsewhere) throw elsewhere;
if (this.#store.get('SELECT 1 FROM threads WHERE project=? AND name=?', a.project, a.name)) fail('thread-exists');
const tid = randomUUID();
this.#store.run(
@@ -16,6 +16,7 @@ export function busOrganization(business, resolved) {
!r.limits?.authority
)
throw new BusError('invalid-resolution');
out.roles[role].network = r.limits.network;
out.roles[role].authority = structuredClone(r.limits.authority);
// REQ-HARN-2: "web" in the resolved tool limit is the grant for the broker's web verbs.
out.roles[role].web = (r.limits.tools ?? []).includes('web');
+1 -1
View File
@@ -88,7 +88,7 @@ function launchOp(m) {
if (m.op === 'request.release') return runtime.requestRelease(m.cap, m.record);
if (m.op === 'request.manifest') return runtime.requestManifest(m.record);
if (m.op === 'request.chief') return runtime.requestChief(m.business);
if (m.op === 'request.admission') return runtime.admission({ business: m.business, project: m.project, projects: m.projects, thread: m.thread, session: m.session });
if (m.op === 'request.admission') return runtime.admission({ business: m.business, project: m.project, projects: m.projects, instance: m.instance, revision: m.revision, thread: m.thread, session: m.session });
if (m.op === 'credentialStatus') return runtime.credentialStatus(m.business, m.role);
if (m.op === 'modelAdmission') return runtime.modelAdmission(m);
if (m.op === 'modelPreflight') return runtime.modelPreflight(m);
+240
View File
@@ -0,0 +1,240 @@
// Static refusal table: each listed code maps to one rail and next step. These strings
// never interpolate caller arguments, credential references, policy values or file bytes.
const entries = [];
const group = (codes, rail, nextStep) => {
const guidance = Object.freeze({ rail, nextStep });
for (const code of codes.split(' ')) {
if (entries.some(([known]) => known === code)) throw new Error('duplicate refusal guidance');
entries.push([code, guidance]);
}
};
group(
"access-refused access-revoked share-exists share-not-covered share-not-found tester-dev-only tester-off tester-refused tester-required tester-target-not-running thread-not-allowed thread-not-owned",
"R7: caller access and scoped session boundary",
"Use only the caller's own permitted scope. Ask the owner to inspect access or sharing through the supported controls; never borrow another identity."
);
group(
"adapter-failed check-threw duplicate-run malformed-answer no-verdict not-a-merge not-approved not-collected not-human not-merge-option not-resolved not-thread-launch peercred-required reply-limits-unreadable socket-exists store-unreadable target-branch workspace-rename-failed",
"R3: fail closed on an unsatisfied operation precondition",
"Inspect the supported status and report the refusal code to the responsible owner; do not bypass it or retry an uncertain side effect."
);
group(
"addressee-ambiguous invalid-approval-choice invalid-arbiter invalid-authority invalid-business invalid-choice invalid-class invalid-domain invalid-envelope invalid-harness invalid-messaging invalid-options invalid-plugins invalid-recommendation invalid-request invalid-snapshot invalid-spec-citation invalid-state invalid-task-ref invalid-timestamp name-is-instance name-is-role name-taken project-name-is-repo thread-instance-ambiguous unknown-action unknown-addressee unknown-business unknown-commit unknown-model unknown-plugin unknown-repo unknown-request unknown-requirement unknown-role unknown-run unknown-verb",
"R3: validated vocabulary and request shape",
"Check the tool schema and permitted identifiers, correct the request, then retry; do not guess around a refusal."
);
group(
"agent-required",
"Role/session: agent identity required",
"Use this tool only from an authorized managed agent session."
);
group(
"agent-running fire-in-progress instance-fired instance-firing instance-not-listed instance-retired instance-running instance-starting launch-record-mismatch launch-revoked launcher-released not-drained not-drained-run not-exited not-held not-holder not-recipient not-retired revoke-refused revoked role-already-held room-not-open room-thread-name-taken run-ended run-not-ended run-revoked session-clear session-clear-cancelled session-clear-complete session-clearing session-exists session-name-too-long session-not-found session-op-conflict session-retired session-revision-stale thread-already-held thread-exists thread-instance-mismatch thread-instance-missing thread-not-found thread-required thread-required-reply-to-thread-message thread-required-use-thread-send thread-revoked",
"Role/session: current holder and lifecycle fence",
"Read current session/status and ask the launcher or owner to finish the supported lifecycle transition. A stale or ended run must not reclaim another holder's identity."
);
group(
"apikey-invalid callback-url-invalid harness-off harness-refused key-quota-no-model-key login-state-unknown model-catalogue-unavailable model-harness-incompatible model-key-collision model-provider-unavailable models-ambiguous models-invalid models-unconfirmed models-write-refused priority-invalid priority-unconfirmed priority-write-refused provider-not-allowed proxy-config-invalid proxy-down proxy-first-output-invalid proxy-init-failed proxy-key-invalid proxy-key-issue-failed proxy-key-refused proxy-key-revoking proxy-open proxy-revoke-revision-conflict proxy-rotate-grace-invalid proxy-uncertain thinking-unsupported",
"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."
);
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",
"Read my_rails and use only a permitted target and the required reply context. Ask the owner if the permitted route is unavailable."
);
group(
"command-not-granted credential-file credential-revoke-failed credential-unknown credential-leak gitea-reach-unknown gitea-reach-wider gitea-user-admin gitea-user-disabled gitea-user-foreign gitea-user-gone gitea-user-orphan grant-admin-missing grant-admin-nothing-left grant-admin-rotation-due grant-busy grant-chief-only grant-lost grant-revoke-failed grant-revoke-running grant-token-unknown grants-revoking remote-not-granted remote-host-mismatch remote-not-fork scope-changed",
"R1/R8: scoped and revocable access",
"Ask the owner to inspect the scoped access and binding state through the supported access controls; do not supply credentials in a call or widen your own grants."
);
group(
"outcome-unknown outcome-unknown-revoked revision-conflict revision-conflict-revoked",
"R2/R3: current revision and verified effect",
"Read the latest status, revision and receipts before retrying. Verify whether an uncertain effect already happened, and use the current revision for any new attempt."
);
group(
"confirm-required",
"R2/R3: explicit confirmation of the current operation",
"Read the current status and obtain the required confirmation through the supported human path."
);
group(
"decision-closed decision-consumed decision-mismatch decision-not-approved decision-not-eligible decision-not-found decision-not-held decision-stale supersede-refused",
"Role decisions: current, matching, approved and single-use decision",
"Read the current decision, its route and its resolution and consumption receipts. Obtain the operation's matching approval from the permitted resolver before acting; a consumed approval cannot be reused."
);
group(
"decision-not-allowed",
"Role authority: no free-text decision channel",
"Use the role's permitted message path to report the need to a person; do not use another decision channel."
);
group(
"decision-required",
"Role authority and R4: approval before acting",
"Read my_rails. Raise a decision for the action and its permitted arbiter; hard waits go to a person. Use blocking false when there is no task."
);
group(
"decision-route-refused",
"Role messaging: decision close route",
"Ask the owner to inspect the raiser and resolver messaging policy; do not route around the refused close path."
);
group(
"definition-not-hireable instance-kept launch-not-configured no-chief-of-staff organization-busy organization-definition-refused organization-instance-referenced organization-instance-retired role-end-refused staff-request-refused",
"Role configuration and protected instance controls",
"Read the permitted configuration/status and ask the owner or configured chief to repair it through the supported path. Do not edit policy to bypass the refusal."
);
group(
"field-writer task-already-archived task-archived task-conflict task-create-conflict task-create-uncertain task-not-archived task-not-found task-position-full task-project-refused task-scope-required task-subject-required",
"Role task ownership, allowed fields and current task state",
"Read the current task and assigned role. Use the permitted task operation with current evidence; ask the PM for scope or assignment changes through the decision path."
);
group(
"task-required",
"Role decisions: a blocking decision belongs to a task",
"Supply the task_ref for this work, or raise with blocking false when there is no task."
);
group(
"fork-foreign gitea-admin-unavailable gitea-failed gitea-not-configured gitea-not-declared host-busy host-changed host-creative-role host-enrolled host-failed host-in-use host-key-mismatch host-line-left host-not-configured host-not-declared host-not-enrolled host-not-granted host-not-ready host-remove-failed host-unknown publish-busy publish-unknown",
"Role access: declared service, host or remote and verified outcome",
"Check the supported access/status read and ask the owner to repair the declared service or grant. If an effect is uncertain, verify its receipt before retrying."
);
group(
"human-required",
"R4/R8: person-only operation",
"A person must perform or approve this operation through its supported human path; an agent must not retry as a person."
);
group(
"land-unknown no-push not-a-reviewer ref-moved self-review stale-commit verdict-exists verdict-not-approve verdict-not-found verdict-stale work-not-yours work-ref",
"Role review and landing: independent verdict on the current work",
"Read the latest work and review receipts. Obtain the required independent review or matching merge decision for the current commit before retrying."
);
group(
"library-fetch-no-grant library-not-configured library-notice-store-failed library-write-no-grant library-write-refused web-failed web-not-configured web-not-granted",
"Role tools: granted and configured web/library access",
"Read my_rails and the permitted service status. Use only granted tools; ask the owner to repair unavailable service configuration."
);
group(
"message-not-found no-subject not-assistant operation-not-found operation-not-open",
"R7/role scope: visible object and allowed operation",
"Use the caller's permitted reads to locate current context, then retry only the supported operation. Ask the owner if it remains unavailable."
);
group(
"not-authorized",
"Role authority: held action or approved decision",
"Read my_rails and use the applicable decision path; ask the owner to inspect grants if no supported path exists."
);
group(
"not-configured project-already-archived project-archived project-exists project-not-archived project-not-configured project-not-found project-record-invalid project-remote-configured project-remote-invalid project-remote-library project-remote-protected project-remote-unknown project-spec-human-only system-type-not-enabled tracker-not-store usage-not-configured",
"R3/role scope: configured project and service state",
"Read the supported project/service status and ask its owner to repair missing configuration or state before retrying."
);
group(
"not-resolver",
"Role resolve right and decision route",
"Only the routed resolver holding the matching decision.resolve right may resolve. Read my_rails and notify that resolver."
);
group(
"prd-arbiter-unavailable prd-authority-refused prd-changes-requested prd-decision-closed prd-not-approved prd-self-approval request-access-refused request-closed request-evidence-required request-expired request-failed request-not-configured request-not-found request-not-taken request-origin-refused request-project-refused request-revision-conflict request-selection-refused request-tracker-refused request-verification-refused requirement-snapshot-refused requirement-snapshot-required requirement-snapshot-revoked spec-unconfirmed stale-spec",
"Role workflow: current admitted request and approved requirements",
"Read the current request, requirements and decision state. Ask the responsible PM or routed arbiter to supply the missing current evidence before continuing."
);
group(
"processing-audit-conflict processing-control-unsafe processing-uninitialized processing-generation-changed processing-halted processing-state-invalid storage-refused storage-unavailable",
"R2/R3: available audited storage and processing state",
"Stop the refused operation and report the processing or storage state to the owner. Resume only after the supported status shows the condition is repaired."
);
group(
"read-only",
"Role/session: read-only capability",
"Use the permitted reads. Ask the owner or an authorized writer to perform the change."
);
group(
"reserved-event",
"R2: trusted append-only evidence",
"Use the typed operation that records its own evidence; never emit a reserved event directly."
);
group(
"role-not-chief",
"R9: only the configured chief manages hires",
"Send the staffing need to the configured chief through an allowed message path."
);
group(
"unauthenticated",
"R7: authenticated caller identity",
"Ask the launcher to restore the caller's own authenticated session; never borrow another capability."
);
group(
"request-timeout request-too-large incomplete-request invalid-response response-too-large unknown-tool tool-failed capacity-full",
"R3/role tools: bounded protocol and available capacity",
"Check the tool schema and narrow oversized requests. For a timeout, inspect status and receipts before retrying; never assume an uncertain operation did not happen."
);
// Finite error vocabularies forwarded by the project's, library's and request's
// typed adapters are part of the same table, not an unhelpful unknown-code path.
group(
"project-busy library-sync-busy",
"Role storage: exclusive write or sync already in progress",
"Wait for the current operation to finish, read its status, then retry once the lock is clear."
);
group(
"stale-base library-accept-stale",
"R2/R3: current file or served revision",
"Read the latest file or library status and its current revision. Reconcile the intended change before retrying with that base; never overwrite a newer change blindly."
);
group(
"citation-cross-universe citation-invalid citation-missing citation-unavailable citation-unfetched citations-over-limit playground-cited-as-canon reply-invalid",
"Role replies: validated citations, provenance and scope",
"Use permitted library, spec or web reads and supply a valid reply with current citations in the allowed universe and scope. Narrow oversized citation lists; never invent provenance."
);
group(
"invalid-request-admission invalid-request-effect invalid-request-event invalid-request-plan invalid-request-projects artifact-path-invalid artifact-type-refused",
"R3: typed request plan, delivery and evidence schema",
"Check the current request's tool schema and correct the plan, artifact path or evidence. Delivery requires the supported self-contained HTML artifact and matching verification evidence."
);
group(
"prd-admission-revoked prd-author-invalid prd-candidate-invalid prd-candidate-required prd-candidate-size prd-candidate-stale prd-requirement-invalid",
"Role PRD workflow: current candidate, authors and admission",
"Read the current request, candidate, requirements, mode and approval. Ask the PM to correct or submit the current candidate through the supported workflow; never approve your own authorship."
);
group(
"request-question-open request-question-closed request-question-mismatch request-phase-refused request-attachment-mismatch request-delivery-required request-document-refused",
"Role request workflow: current phase, question and selected attachment",
"Read intake_status and follow its current question, wait or delivery step. A pending question needs its answer before another question; use only the operation and attachment valid for the current phase."
);
group(
"request-effect-conflict request-effect-unprepared request-mode-revision-conflict request-operation-conflict request-plan-binding-refused request-plan-path-collision request-plan-path-refused request-project-collision",
"R2/R3: current request revision and bound operation",
"Read the current request status, plan and operation receipts. Recover an existing operation before retrying; use a new operation ID only for a new intended operation, and never bypass a path or project collision."
);
group(
"request-notification-unavailable request-services-unavailable library-credential-missing library-human-missing library-mapping-invalid library-problem-code library-remote-invalid library-state-invalid library-diverged library-accept-invalid",
"R1/R3: owner-configured service, mapping and accepted library state",
"Read the supported service or library status and ask the owner to repair the missing binding, mapping or accepted state. Check existing operation receipts before retrying an uncertain effect."
);
group(
"library-commit library-git library-push-failed project-commit-uncertain project-init-cleanup",
"R2/R3: verified repository effect and recovery",
"Read current repository status and receipts to determine what already happened. Report an uncertain commit, push or cleanup to the owner before any new write; do not retry an uncertain effect blindly."
);
group(
"project-author project-dirty project-git project-id project-invalid project-no-commit project-not-declared project-not-empty project-path project-read-commit project-read-content project-read-missing project-read-path project-record-path project-records-conflict project-records-missing project-remote project-repository tester-project-area",
"Role project scope: declared repository, clean state and allowed paths",
"Read the permitted project status and file metadata. Correct the declared project or allowed path when possible; ask the owner to repair repository state or identity. Preserve existing changes and never bypass repository or path checks."
);
group(
"project-record-unchanged",
"R2: no new record effect for unchanged content",
"Read the current record and its existing commit receipt. If it already matches the intended content, use that evidence instead of retrying the same write."
);
group(
"library-write-story-path library-revert-not-agent",
"Role library writes: supporting paths and permitted agent-owned commits",
"Use only the supporting paths and agent-owned commit reversals permitted by the library tool. Preserve story and canon files; ask the owner when the requested edit is outside that scope."
);
group(
"query-argument-invalid query-chapter-ambiguous query-item-too-large query-universe-unknown",
"Role library reads: known universe, unambiguous selection and bounded result",
"Read the permitted library index, choose a known universe and an unambiguous book or chapter, and narrow the query or requested range before retrying."
);
export const REFUSALS = Object.freeze(Object.fromEntries(entries));
const UNKNOWN = Object.freeze({ rail: "R3: fail closed", nextStep: "Report the refusal code to the responsible owner and inspect supported status; do not bypass the refusal." });
export const refusalGuidance = code => Object.hasOwn(REFUSALS, code) ? REFUSALS[code] : UNKNOWN;
+28 -5
View File
@@ -35,7 +35,7 @@ import { instanceSettings } from '../../organization/src/instance-settings.mjs';
import { organizationWriter, OrganizationError, setReplyLimits } from '../../organization/src/organization.mjs';
import { getProjectRemote } from '../../organization/src/project-remotes.mjs';
import { credentialVerbs } from './credential-verbs.mjs';
import { loadBusiness } from '../../organization/src/organization-config.mjs';
import { loadWithCopies } from '../../organization/src/agent-copies.mjs';
import { loadRole } from '../../organization/src/role.mjs';
import { resolveInstance } from '../../organization/src/resolve.mjs';
import { canonicalJson } from '../../organization/src/util.mjs';
@@ -284,7 +284,14 @@ export async function startBroker({
builtinBinding = null,
modelCatalogue = null, // in-process fixture seam; boot JSON cannot supply a service
makeProviderLoader = createLoader, // in-process fixture seam; boot JSON cannot supply a function
// #1873 A3: the boot's root and project agent copies (cli config.mjs copiesAtBoot), plain data. Every
// reload of the organization file below re-applies this snapshot, never the root file.
copies = null,
}) {
if (copies !== null && (typeof copies !== 'object' || Array.isArray(copies) || typeof copies.org !== 'string'
|| !Object.hasOwn(businesses ?? {}, copies.org) || copies.copies === null || typeof copies.copies !== 'object' || Array.isArray(copies.copies)))
throw new BusError('invalid-host-request');
const loadOrg = id => loadWithCopies(id, { dir: organization.dir, rolesDir: organization.rolesDir, snapshot: copies?.org === id ? copies : null });
if (!Array.isArray(consoleOwners) || consoleOwners.length > 512 || consoleOwners.some((o) => o === null || typeof o !== 'object' || Array.isArray(o) ||
Object.keys(o).length !== 3 || ['provider', 'iss', 'sub'].some((k) => typeof o[k] !== 'string' || !o[k] || o[k].length > 512)))
throw new BusError('invalid-host-request');
@@ -564,7 +571,7 @@ export async function startBroker({
const reloadLog = entry => process.stderr.write(`organization: ${JSON.stringify(entry)}\n`);
function reload(id) {
try {
const b = loadBusiness(id, { dir: organization.dir, rolesDir: organization.rolesDir });
const b = loadOrg(id);
const resolved = {};
for (const instance of Object.keys(b.roles)) resolved[instance] = resolveInstance({ system: organization.system, business: b, instance });
const next = withoutPending(id, busOrganization(b, resolved));
@@ -595,7 +602,7 @@ export async function startBroker({
if (!found || found.after !== organization_revision) refuse('bootstrap-receipt-mismatch');
// Only this bootstrap's own post-add revision may be admitted: any later organization write means the confirmed world moved (#800).
if (revision !== found.after) refuse('bootstrap-revision-stale');
const b = loadBusiness(id, { dir: organization.dir, rolesDir: organization.rolesDir });
const b = loadOrg(id);
const resolved = {};
for (const instance of Object.keys(b.roles)) resolved[instance] = resolveInstance({ system: organization.system, business: b, instance });
const full = busOrganization(b, resolved);
@@ -622,7 +629,11 @@ export async function startBroker({
// product the broker holds is ready only while it has a complete marker and a committed activate receipt: the
// answer is read from both on every call, so a marker removed after the activation closes it again, and the
// broker's reload now follows both readiness records (#802).
function admission({ business: id, project, projects, thread, session }) {
// With `instance` (#1873 A3), the launch boundary also asks whether that instance may work in each
// project as the business stands now: a project that excludes it, or whose union refuses, is not ready.
// `instance` comes with `revision`, the organization file revision the launch resolved its bundle
// from; a file that has changed since (a narrow, a project define's vars, an exclude) is not ready.
function admission({ business: id, project, projects, instance, revision: launched, thread, session }) {
if (projects !== undefined) {
const no = code => ({ ready: false, revision: null, code });
if (typeof id !== 'string' || !Object.hasOwn(businesses, id) || !Array.isArray(projects) || projects.some(p => typeof p !== 'string')) return no('project-unknown');
@@ -631,9 +642,21 @@ export async function startBroker({
if (!row) return no('session-not-found');
if (row.archived) return no('project-archived');
}
if (instance !== undefined && (typeof instance !== 'string' || typeof launched !== 'string')) return no('project-unknown');
if (instance !== undefined) {
let now;
try { now = writer?.read(id).revision; } catch { return no('organization-unreadable'); }
if (now === undefined) return no('organization-unreadable');
if (now !== launched) return no('organization-changed');
}
for (const p of projects) {
const result = admission({ business: id, project: p });
if (!result.ready) return result;
if (instance !== undefined) {
// The broker holds the document the last writer reload swapped in; `businesses` is the boot one.
const barred = broker.projectBarred(id, instance, p);
if (barred) return no(barred);
}
}
return { ready: true, revision: null, code: null };
}
@@ -753,7 +776,7 @@ export async function startBroker({
// plugins.set reloads the business's limit checks in the background.
organization:settings?(proxyOrganization?.wrap ?? (operation => operation))((id,verb,args,actor,context)=>{
if (verb === 'project.remote.get.console' || verb === 'project.remote.set.console') {
const current = loadBusiness(id, { dir: organization.dir, rolesDir: organization.rolesDir });
const current = loadOrg(id);
const resolved = {};
for (const instance of Object.keys(current.roles)) resolved[instance] = resolveInstance({ system: organization.system, business: current, instance });
const live = withoutPending(id, busOrganization(current, resolved));
+10 -9
View File
@@ -9,11 +9,12 @@ const LIMIT = 65536;
const PROJECT_SAVE_AT_START = new RegExp(`"verb":"${PROJECT_SAVE_VERB.replaceAll('.', '\\.')}"`);
const LIBRARY_SAVE_AT_START = /"verb":"library\.file\.save"/;
const SAVE_VERB_AT_START = { test: text => PROJECT_SAVE_AT_START.test(text) || LIBRARY_SAVE_AT_START.test(text) };
// The refusal envelope. C2-06 (sage-3599): `reason` is added only when the error carries one, which
// only explicitly scoped refusals do, so every other envelope is byte-identical to before.
const refusal = (e, other) => e instanceof BusError
? { ok: false, error: e.code, ...(typeof e.reason === 'string' ? { reason: e.reason } : {}) }
: { ok: false, error: other };
// Preserve every code and scoped reason, adding only static rail/next-step guidance.
const refusal = (e, other) => {
const error = e instanceof BusError ? e : new BusError(other);
return { ok: false, error: error.code, rail: error.rail, nextStep: error.nextStep,
...(typeof error.reason === 'string' ? { reason: error.reason } : {}) };
};
// One request per connection. There is deliberately no reconnect/retry or SQL verb.
// `tasks` ({handle, timeout}) is the trusted S3 adapter; only the eight task verbs reach it.
// `web` is the trusted web service (web.mjs), or a function from a business id to one (or null); only
@@ -48,16 +49,16 @@ export async function serve({ broker, path, authenticateHuman = null, authentica
socket.end(JSON.stringify(result) + '\n');
};
socket.setTimeout(timeout, () => {
finish({ ok: false, error: 'request-timeout' });
finish(refusal(new BusError('request-timeout')));
socket.destroySoon();
});
socket.on('data', async (chunk) => {
if (done) return;
if (processing) return finish({ ok: false, error: 'invalid-envelope' });
if (processing) return finish(refusal(new BusError('invalid-envelope')));
const total = input.length + chunk.length;
if (total > LIMIT && !(total <= PROJECT_SAVE_REQUEST_MAX &&
SAVE_VERB_AT_START.test(Buffer.concat([input, chunk]).subarray(0, 1024).toString('latin1')))) {
finish({ ok: false, error: 'request-too-large' });
finish(refusal(new BusError('request-too-large')));
return;
}
input = Buffer.concat([input, chunk]);
@@ -187,7 +188,7 @@ export async function serve({ broker, path, authenticateHuman = null, authentica
}
});
socket.on('end', () => {
if (!done && !processing) finish({ ok: false, error: 'incomplete-request' });
if (!done && !processing) finish(refusal(new BusError('incomplete-request')));
});
});
await new Promise((resolve, reject) => {
+122
View File
@@ -0,0 +1,122 @@
// #1873 Phase A slice A3: the broker half of root and project agent copies. The business carries the
// boot's `copies` view (organization agent-copies.mjs withCopies): a bound copy is also in `roles`,
// an unbound or refused one is not. The cli half, with real runners, is cli agent-copies.test.mjs.
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { Store } from '../src/store.mjs';
import { BusError, Broker } from '../src/broker.mjs';
const shipped = (name) => JSON.parse(readFileSync(fileURLToPath(new URL(`../../../roles/${name}.json`, import.meta.url)), 'utf8'));
const none = { authority: { withinRole: ['message.send'], crossRole: [] } };
const SECRET = 'sk-test-NOT-A-SECRET-1234';
const copy = (name, definition, project, state, extra = {}) => ({ name, level: project === null ? 'root' : 'project', project, definition, state, ...extra });
const demo = () => ({
id: 'demo', human: 'jason', arbiters: { technical: 'pm', delivery: 'chief' },
definitions: { 'chief-of-staff': {}, assistant: { messaging: shipped('assistant').messaging }, companion: { messaging: shipped('companion').messaging } },
projects: { stack: { root: '/nonexistent/stack' }, web: { root: '/nonexistent/web' }, docs: { root: '/nonexistent/docs' } },
roles: {
chief: { definition: 'chief-of-staff', ...none },
pm: { definition: 'pm', ...none },
assistant: { definition: 'assistant', ...none },
gate: { definition: 'merge-gate', ...none },
'companion.stack': { definition: 'companion', ...none },
buddy: { definition: 'companion', ...none },
},
copies: {
gate: copy('gate', 'merge-gate', null, 'instance'),
buddy: copy('buddy', 'companion', null, 'instance'),
'companion.stack': copy('companion', 'companion', 'stack', 'instance'),
researcher: copy('researcher', 'research', null, 'unbound', { missing: ['gitea'] }),
wide: copy('wide', 'merge-gate', null, 'refused', { reason: 'model <value> is not in launch.models' }),
'lint.web': copy('lint', 'security-reviewer', 'web', 'refused', { reason: 'project web refuses its agents: ghost is excluded, but nothing defines it' }),
},
// Each code project's union as withCopies computes it: web excludes buddy, docs refuses.
unions: {
stack: { ok: true, agents: ['assistant', 'buddy', 'chief', 'companion.stack', 'gate', 'pm'] },
web: { ok: true, agents: ['assistant', 'chief', 'gate', 'pm'] },
docs: { ok: false, reason: 'projects.docs.agents.ghost: names no root or organization agent to exclude' },
},
});
function setup(t) {
const root = mkdtempSync(join(tmpdir(), 'bus-copies-'));
const store = new Store(root);
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
const b = new Broker({ store, businesses: { demo: demo() } });
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const call = (verb, args = {}) => b.request(human, { verb, args });
const refusal = (fn) => {
try { fn(); } catch (e) { assert.ok(e instanceof BusError, String(e)); return e; }
assert.fail('no refusal');
};
const trail = () => store.all('SELECT kind, body FROM events').map((e) => `${e.kind} ${e.body}`).join('\n');
return { b, store, call, refusal, trail };
}
test('A3 bus: a bound copy takes a launch request; an unbound or refused one refuses at the verb by name, with the code alone in the trail', (t) => {
const f = setup(t);
assert.match(f.call('instance.launch', { instance: 'gate' }).request, /\S/);
const unbound = f.refusal(() => f.call('instance.launch', { instance: 'researcher' }));
assert.deepEqual([unbound.code, unbound.reason], ['credential-unbound', 'researcher has no binding in demo for gitea']);
const refused = f.refusal(() => f.call('instance.launch', { instance: 'wide' }));
assert.equal(refused.code, 'copy-refused');
assert.match(refused.reason, /^wide can't launch in demo: /);
const project = f.refusal(() => f.call('instance.launch', { instance: 'lint.web' }));
assert.equal(project.code, 'project-refused');
assert.match(project.reason, /^lint\.web can't launch in demo: project web refuses its agents: /);
// Regenerate takes the same check.
assert.equal(f.refusal(() => f.call('instance.regenerate', { instance: 'researcher' })).code, 'credential-unbound');
// A name that is no copy and no instance is still unknown.
assert.equal(f.refusal(() => f.call('instance.launch', { instance: 'nobody' })).code, 'unknown-role');
// The trail holds each refusal's code, never its reason.
const trail = f.trail();
assert.match(trail, /credential-unbound/);
for (const text of ['has no binding', "can't launch", 'refuses its agents', SECRET]) assert.equal(trail.includes(text), false, text);
});
test('A3 bus: a project agent takes a thread only in its own project, and only when named', (t) => {
const f = setup(t);
const stack = f.call('project.create', { name: 'stack work', repo: 'stack' }).id;
const web = f.call('project.create', { name: 'web work', repo: 'web' }).id;
const loose = f.call('project.create', { name: 'loose' }).id;
// Unnamed, the organization file's own thread instance is still the one choice.
const plain = f.call('thread.create', { project: stack, name: 'plain' }).id;
assert.equal(f.store.get('SELECT instance FROM threads WHERE id=?', plain).instance, 'assistant');
const own = f.call('thread.create', { project: stack, name: 'own', instance: 'companion.stack' });
assert.match(own.id, /\S/);
for (const project of [web, loose])
assert.equal(f.refusal(() => f.call('thread.create', { project, name: 'elsewhere', instance: 'companion.stack' })).code, 'project-agent-elsewhere');
// A named copy the boot refused says why, before the thread instance check.
const unbound = f.refusal(() => f.call('thread.create', { project: stack, name: 'r', instance: 'researcher' }));
assert.deepEqual([unbound.code, unbound.reason], ['credential-unbound', 'researcher has no binding in demo for gitea']);
});
test('A3 bus: a room holds only the thread instances that work in its project; an excluded agent takes no thread there; a refused union takes none at all, naming the project', (t) => {
const f = setup(t);
const project = (repo) => f.call('project.create', { name: `${repo ?? 'loose'} work`, ...(repo ? { repo } : {}) }).id;
const [stack, web, docs, loose] = [project('stack'), project('web'), project('docs'), project(null)];
const members = (id) => f.call('room.open', { project: id }).threads.map((x) => x.instance).sort();
// Another project's agent never joins, and the project's own exclusion holds.
assert.deepEqual(members(stack), ['assistant', 'buddy', 'companion.stack']);
assert.deepEqual(members(web), ['assistant']);
assert.deepEqual(members(loose), ['assistant', 'buddy']);
assert.deepEqual(f.call('room.send', { project: web, body: 'hello' }).to, ['assistant']);
const excluded = f.refusal(() => f.call('thread.create', { project: web, name: 'b', instance: 'buddy' }));
assert.deepEqual([excluded.code, excluded.reason], ['project-refused', "buddy can't work in project web: the project excludes it"]);
// docs's union refuses: no thread, named or not, and no room, each naming docs and why.
for (const fn of [
() => f.call('thread.create', { project: docs, name: 'plain' }),
() => f.call('thread.create', { project: docs, name: 'a', instance: 'assistant' }),
() => f.call('room.open', { project: docs }),
]) {
const e = f.refusal(fn);
assert.equal(e.code, 'project-refused');
assert.match(e.reason, /^project docs refuses its agents: .*ghost/);
}
assert.equal(f.trail().includes('refuses its agents'), false, 'the trail holds the code only');
});
+3 -1
View File
@@ -37,7 +37,9 @@ const baselines = {
// #1839: artifact Console replies alone may use 2 MiB; the proof and other limits are unchanged.
// #1873 U1: humanCall, busCaller, userCall and userCaller take an optional signal and absolute
// deadline; userCall shares transport(). Nonces, argv and limits are unchanged.
"packages/webui/src/bus.mjs": "3ef8064dd7911f9eee4475b52c74a1d551583a065474b1bcee968cd45f722747",
// #1873 A3: STATUS maps credential-unbound, copy-refused, project-refused and project-agent-elsewhere
// to 409; no proof or transport change.
"packages/webui/src/bus.mjs": "d8f3e0eb5299fdb192e98a1238ca693aae7c36af9a21de72d5f5d39610589377",
};
const repoFile = path => new URL("../../../" + path, import.meta.url);
+3 -2
View File
@@ -4,6 +4,7 @@ import { connect } from 'node:net';
import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { Client } from '../src/client.mjs';
import { refusalGuidance } from '../src/refusals.mjs';
import { serve } from '../src/server.mjs';
import { FIXED_TEXTS, GuardError } from '../../library/src/guard.mjs';
import { git } from '../../library/tests/helpers.mjs';
@@ -138,7 +139,7 @@ test('C2-06 page store is session-local, caps 20 fetches and 200 KiB, drops at d
await assert.rejects(f.call(replacement, 'message.send', { to: 'human', body: citedBody([{ url: 'https://fixture.invalid/21', quote: 'xxxx' }]) }), { code: 'citation-unfetched' });
});
test('C2-06 transport passes safe capped guard reason; ordinary refusal envelope is byte-identical', async t => {
test('C2-06 transport passes safe capped guard reason; ordinary refusal retains its code and adds static rail guidance', async t => {
const f = citationWorld(t, { guard: async () => { throw new GuardError('citation-invalid', 'guard reason '.repeat(50)); } });
f.call(f.agent, 'role.claim'); const path = join(f.root, 'socket');
const server = await serve({ broker: f.broker, path }); t.after(() => server.close());
@@ -148,7 +149,7 @@ test('C2-06 transport passes safe capped guard reason; ordinary refusal envelope
const socket = connect(path); let text = ''; socket.on('error', reject); socket.on('data', b => { text += b; }); socket.on('end', () => resolve(text));
socket.on('connect', () => socket.write(JSON.stringify({ cap: f.agent, verb: 'unknown-fixture' }) + '\n'));
});
assert.equal(raw, '{"ok":false,"error":"unknown-verb"}\n');
assert.deepEqual(JSON.parse(raw), { ok: false, error: 'unknown-verb', ...refusalGuidance('unknown-verb') });
});
test('C2-06 non-library businesses retain role.claim shape and unguarded free sends', async t => {
+2 -1
View File
@@ -1,4 +1,5 @@
// Row 288: broker authority, exact notice and strict Console revert API. Fixture grant rows only.
import { refusalGuidance } from '../src/refusals.mjs';
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs';
@@ -180,7 +181,7 @@ test('C2-02 socket: a save name nested in proof cannot enlarge an agent envelope
socket.on('error', reject); socket.on('connect', () => socket.write(request));
socket.on('data', bytes => { result += bytes; if (result.includes('\n')) { socket.destroy(); resolve(JSON.parse(result)); } });
});
assert.deepEqual(response, { ok: false, error: 'request-too-large' });
assert.deepEqual(response, { ok: false, error: 'request-too-large', ...refusalGuidance('request-too-large') });
assert.equal(f.seen.length, 0);
});
+4 -3
View File
@@ -5,6 +5,7 @@ import { join } from 'node:path';
import { fixture, git } from './spec-fixtures.mjs';
import { serve } from '../src/server.mjs';
import { Client } from '../src/client.mjs';
import { refusalGuidance } from '../src/refusals.mjs';
import { PROJECT_FILE_VERBS } from '../src/broker.mjs';
// Row 148 (#1642): the human-only verbs behind the Console's spec and docs editor.
const refusals = f => f.store.all("SELECT body FROM events WHERE kind='action.refused'").map(r => JSON.parse(r.body).code);
@@ -109,11 +110,11 @@ test('a request that names the save verb first and another verb last is refused
const f = await socketFixture(t);
const pad = 'x'.repeat(100000);
const sneaky = `{"human":{"fixture":true},"verb":"project.file.save","verb":"agents","args":{"pad":"${pad}"}}\n`;
assert.deepEqual(await raw(f.path, sneaky), { ok: false, error: 'request-too-large' });
assert.deepEqual(await raw(f.path, sneaky), { ok: false, error: 'request-too-large', ...refusalGuidance('request-too-large') });
const unmarked = await raw(f.path, JSON.stringify({ human: { fixture: true }, verb: 'agents', args: { pad } }) + '\n');
assert.deepEqual(unmarked, { ok: false, error: 'request-too-large' });
assert.deepEqual(unmarked, { ok: false, error: 'request-too-large', ...refusalGuidance('request-too-large') });
const over = await raw(f.path, JSON.stringify({ human: { fixture: true }, verb: 'project.file.save', args: { pad: 'x'.repeat(1024 * 1024 + 80000) } }) + '\n');
assert.deepEqual(over, { ok: false, error: 'request-too-large' });
assert.deepEqual(over, { ok: false, error: 'request-too-large', ...refusalGuidance('request-too-large') });
});
test('two saves racing for one repository make one commit', async t => {
@@ -13,6 +13,8 @@ test('Broker keeps the sandbox client import closure: only the listed imports, n
const source = readFileSync(new URL('../src/broker.mjs', import.meta.url), 'utf8');
assert.deepEqual([...source.matchAll(/^import .*;$/gm)].map(m => m[0]), [
// Rows330/331: pure verb/refusal vocabulary, never the host management implementation.
"import { ownRails } from '../../organization/src/rails.mjs';",
"import { refusalGuidance } from './refusals.mjs';",
"import { PROXY_PAGE_VERBS, PROXY_PAGE_CODES } from './proxy-page-vocabulary.mjs';",
// Row 210 (#1707): createHash gives host.run events the argv, stdin and output sha256 values.
"import { randomUUID, randomBytes, createHash } from 'node:crypto';",
+147
View File
@@ -0,0 +1,147 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, rmSync, readFileSync, readdirSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { connect } from 'node:net';
import { PROXY_PAGE_CODES } from '../src/proxy-page-vocabulary.mjs';
import { Broker, BusError } from '../src/broker.mjs';
import { REFUSALS, refusalGuidance } from '../src/refusals.mjs';
import { Store } from '../src/store.mjs';
import { busOrganization } from '../src/organization-context.mjs';
import { serve } from '../src/server.mjs';
import { Client } from '../src/client.mjs';
import { resolveInstance, validateBusinessDocument } from '../../organization/src/index.mjs';
import { businessDoc, REPO_ROLES, systemFor } from '../../organization/tests/helpers.mjs';
import { railsText } from '../../organization/src/rails.mjs';
import { GUARD_CODES } from '../../library/src/reply-protocol.mjs';
const SECRET = 'sk-test-NOT-A-SECRET-1234';
function fixture(t) {
const root = mkdtempSync(join(tmpdir(), 'rails-'));
const business = validateBusinessDocument(businessDoc(root), join(root, 'acme.json'), { rolesDir: REPO_ROLES });
const resolve = b => Object.fromEntries(Object.keys(b.roles).map(instance => [instance, resolveInstance({ system: systemFor(root), business: b, instance })]));
writeFileSync(business.roles.coder.credentials.gitea.file, SECRET);
const resolved = resolve(business), store = new Store(root);
const broker = new Broker({ store, businesses: { acme: busOrganization(business, resolved) } });
const cap = broker.bindLaunch({ business: 'acme', role: 'coder', run: 'coder-run', harness: 'pi' });
broker.request(cap, { verb: 'role.claim' });
t.after(() => { store.close(); rmSync(root, { recursive: true, force: true }); });
return { root, business, resolved, resolve, broker, store, cap };
}
test('my_rails equals the resolved prompt content, reads live narrowing, and writes/raises nothing', t => {
const f = fixture(t), call = args => f.broker.request(f.cap, { verb: 'my_rails', args });
const before = f.store.get('SELECT count(*) n FROM events').n;
assert.deepEqual(call({}), f.resolved.coder.rails);
const result = call({}); result.actions.length = 0;
assert.notEqual(call({}).actions.length, 0, 'returned rails do not mutate policy');
const business = structuredClone(f.business);
business.roles.coder.vars['limits.authority'] = ['message.send'];
const narrowed = f.resolve(business);
f.broker.reloadBusiness('acme', busOrganization(business, narrowed));
assert.deepEqual(call({}), narrowed.coder.rails);
assert.equal(f.store.get('SELECT count(*) n FROM events').n, before);
assert.equal(f.store.get('SELECT count(*) n FROM decisions').n, 0);
});
test('my_rails cannot select another identity or expose credential refs and file/call values', t => {
const f = fixture(t);
for (const args of [{ agent: 'cto' }, { role: 'cto' }, { instance: 'cto' }, { business: 'other' }, { token: SECRET }]) {
assert.throws(() => f.broker.request(f.cap, { verb: 'my_rails', args }), { code: 'invalid-request' });
}
const rails = f.broker.request(f.cap, { verb: 'my_rails' });
const text = JSON.stringify(rails) + railsText(rails);
for (const value of [SECRET, 'placeholder-not-a-token', f.resolved.coder.credentials.gitea.file, f.resolved.cto.credentials.gitea.file]) assert.ok(!text.includes(value), value);
const human = f.broker.bindHuman({ business: 'acme', human: 'jason', via: 'cli', outsideAgent: true });
assert.throws(() => f.broker.request(human, { verb: 'my_rails' }), { code: 'agent-required' });
});
function raw(path, request) {
return new Promise((resolve, reject) => {
const socket = connect(path); let out = '';
socket.on('error', reject);
socket.on('connect', () => socket.end(JSON.stringify(request) + '\n'));
socket.on('data', b => out += b);
socket.on('end', () => resolve(JSON.parse(out)));
});
}
test('cross-role refusal envelope names its rail and next step without changing code or outcome', async t => {
const f = fixture(t), path = join(f.root, 'bus.sock');
const server = await serve({ broker: f.broker, path }); t.after(() => server.close());
const request = { cap: f.cap, verb: 'decision.resolve', args: { id: 'missing', choice: SECRET } };
const refused = await raw(path, request);
assert.equal(refused.ok, false); assert.equal(refused.error, 'decision-not-found');
assert.deepEqual({ rail: refused.rail, nextStep: refused.nextStep }, REFUSALS['decision-not-found']);
assert.ok(!JSON.stringify(refused).includes(SECRET));
await assert.rejects(new Client({ path, cap: f.cap }).call('decision.resolve', request.args), e => {
assert.equal(e.code, 'decision-not-found'); assert.equal(e.nextStep, refused.nextStep); return true;
});
const decision = f.broker.request(f.cap, { verb: 'decision.raise', args: { action: 'task.scope.change', domain: 'delivery',
question: 'Scope?', options: [{ key: 'yes', text: 'Yes' }, { key: 'no', text: 'No' }], recommendation: 'no', blocking: false } });
const cross = await raw(path, { cap: f.cap, verb: 'decision.resolve', args: { id: decision.id, choice: 'yes' } });
assert.equal(cross.error, 'not-resolver'); assert.match(cross.rail, /resolve right/); assert.match(cross.nextStep, /routed resolver/);
assert.equal(f.store.get('SELECT count(*) n FROM decision_events WHERE decision=?', decision.id).n, 0);
// Exercise an actual cross-role action without its required decision, not just resolve.
const live = busOrganization(f.business, f.resolved);
live.roles.coder.authority.crossRole.push('role.end');
f.broker.reloadBusiness('acme', live);
const decisions = f.store.get('SELECT count(*) n FROM decisions').n;
const required = await raw(path, { cap: f.cap, verb: 'role.revoke', args: { role: 'reviewer', reason: SECRET } });
assert.equal(required.ok, false); assert.equal(required.error, 'decision-required');
assert.deepEqual({ rail: required.rail, nextStep: required.nextStep }, REFUSALS['decision-required']);
assert.ok(!JSON.stringify(required).includes(SECRET));
assert.equal(f.store.get('SELECT count(*) n FROM decisions').n, decisions);
});
test('every literal fail branch in broker has static guidance; guidance never contains payload values', () => {
const source = readFileSync(new URL('../src/broker.mjs', import.meta.url), 'utf8');
// Balance each call (including ternaries), so a following statement cannot masquerade
// as a fail code. Every literal branch, with either quote style, must be in the table.
for (const match of source.matchAll(/\bfail\s*\(/g)) {
let depth = 1, i = match.index + match[0].length;
while (depth && i < source.length) {
const char = source[i++];
if (char === "'" || char === '"') {
const start = i - 1;
let value = '';
while (i < source.length && source[i] !== char) {
if (source[i] === "\\") { i += 2; continue; }
value += source[i++];
}
i++;
if (/^[a-z][a-z0-9-]*$/.test(value) && (value.includes('-') || !source.slice(match.index + match[0].length, start).trim())) assert.ok(Object.hasOwn(REFUSALS, value), value);
} else if (char === '(') depth++;
else if (char === ')') depth--;
}
assert.equal(depth, 0, 'balanced fail call');
}
// Finite delegated fail(code) sources must be covered as well as literal branches.
for (const code of [...PROXY_PAGE_CODES, ...GUARD_CODES, 'project-busy', 'remote-host-mismatch', 'remote-not-fork',
'processing-control-unsafe', 'processing-uninitialized', 'processing-generation-changed']) {
assert.ok(Object.hasOwn(REFUSALS, code), code);
}
for (const [code, guidance] of Object.entries(REFUSALS)) {
assert.ok(guidance.rail.length > 0 && guidance.nextStep.length > 0, code);
assert.deepEqual({ rail: new BusError(code).rail, nextStep: new BusError(code).nextStep }, guidance);
assert.ok(!JSON.stringify(guidance).includes(SECRET));
}
assert.deepEqual(refusalGuidance(SECRET), refusalGuidance('unknown-external-code'));
});
test('finite forwarded project, library and request errors have static next steps', () => {
const modules = [
...['index', 'git', 'paths'].map(name => new URL(`../../project/src/${name}.mjs`, import.meta.url)),
...readdirSync(new URL('../../library/src/', import.meta.url)).filter(f => f.endsWith('.mjs'))
.map(name => new URL(`../../library/src/${name}`, import.meta.url)),
...['request-store', 'request-lifecycle'].map(name => new URL(`../src/${name}.mjs`, import.meta.url)),
];
for (const file of modules) {
const source = readFileSync(file, 'utf8');
for (const match of source.matchAll(/\b(?:new\s+(?:ProjectError|LibraryError|RequestError|GuardError)|raise|refuse|fail)\s*\(\s*['"]([a-z][a-z0-9-]*)['"]/g)) {
assert.ok(Object.hasOwn(REFUSALS, match[1]), `${file.pathname}: ${match[1]}`);
}
}
assert.doesNotMatch(REFUSALS['outcome-unknown'].nextStep, /confirmation|human/);
assert.doesNotMatch(REFUSALS['revision-conflict'].nextStep, /confirmation|human/);
assert.match(REFUSALS['task-required'].nextStep, /task_ref.*blocking false/);
});
+3 -2
View File
@@ -10,6 +10,7 @@ import { Store } from '../src/store.mjs';
import { Broker, TASK_VERBS } from '../src/broker.mjs';
import { serve } from '../src/server.mjs';
import { Client } from '../src/client.mjs';
import { refusalGuidance } from '../src/refusals.mjs';
import { startBroker } from '../src/runtime.mjs';
import { readProcess, verifyHuman } from '../src/human.mjs';
import { verifyTester, testerNonce } from '../src/tester.mjs';
@@ -387,9 +388,9 @@ test('an envelope carries exactly one of cap, human and tester; no tester authen
const cap = b.bindReader({ business: 'demo' });
const role = b.bindLaunch({ business: 'demo', role: 'pm', run: 'pm-run', harness: 'pi' });
for (const r of [{ tester: p, cap }, { tester: p, cap: role }, { tester: p, human: p }, { tester: p, human: p, cap }])
assert.deepEqual(await raw(path, { ...r, verb: 'agents' }), { ok: false, error: 'invalid-envelope' }, Object.keys(r).join('+'));
assert.deepEqual(await raw(path, { ...r, verb: 'agents' }), { ok: false, error: 'invalid-envelope', ...refusalGuidance('invalid-envelope') }, Object.keys(r).join('+'));
assert.equal(calls, 0);
assert.deepEqual(await raw(bare, { tester: p, verb: 'agents' }), { ok: false, error: 'tester-required' });
assert.deepEqual(await raw(bare, { tester: p, verb: 'agents' }), { ok: false, error: 'tester-required', ...refusalGuidance('tester-required') });
assert.deepEqual(await raw(path, { tester: p, verb: 'agents' }), { ok: true, result: [] });
assert.equal(calls, 1);
// The tester's capability lasts one request, as the human's does.
+5 -1
View File
@@ -8,6 +8,7 @@ import { Store } from '../src/store.mjs';
import { Broker } from '../src/broker.mjs';
import { serve } from '../src/server.mjs';
import { Client } from '../src/client.mjs';
import { refusalGuidance } from '../src/refusals.mjs';
import { views } from '../src/views.mjs';
const businesses = {
demo: {
@@ -72,7 +73,10 @@ test('malformed, oversized and identity-forging envelopes refuse without echoing
const out = await raw(input);
assert.equal(JSON.parse(out).ok, false);
assert.ok(!out.includes('not-json'));
assert.ok(out.length < 150);
const refusal = JSON.parse(out);
assert.ok(['invalid-envelope', 'request-too-large'].includes(refusal.error));
assert.deepEqual(refusal, { ok: false, error: refusal.error, ...refusalGuidance(refusal.error) });
assert.ok(out.length < 1024); // A bounded static description, independent of input size.
}
});
test('client preserves UTF-8 when a response divides a multibyte character', async (t) => {
+2
View File
@@ -0,0 +1,2 @@
// Old path of packages/organization/src/agent-copies.mjs (rename design r2, stage B).
export * from "../../organization/src/agent-copies.mjs";
@@ -0,0 +1,2 @@
// Old path of packages/organization/src/agent-levels-write.mjs (rename design r2, stage B).
export * from "../../organization/src/agent-levels-write.mjs";
@@ -0,0 +1,2 @@
// Old path of packages/organization/src/config-dir-trust.mjs (rename design r2, stage B).
export * from "../../organization/src/config-dir-trust.mjs";
+2
View File
@@ -0,0 +1,2 @@
// Compatibility path for the organization package's caller-rails projection.
export * from "../../organization/src/rails.mjs";
@@ -1 +1 @@
{"business":"acme","contract":"<ROOT>/roles/coder.md","credentials":{"gitea":{"file":"<ROOT>/secrets/coder-gitea.token","rotateBy":"2099-01-01","service":"gitea"},"vikunja":{"expires":"2099-01-01","file":"<ROOT>/secrets/coder-vikunja.token","service":"vikunja"}},"definition":"coder","holder":null,"instance":"coder","launch":null,"limits":{"authority":{"crossRole":["task.scope.change"],"withinRole":["task.update.assigned","git.push.working","review.request","message.send","record.write"]},"network":"none","tools":["read","write","edit","bash","grep","find","ls"]},"project":"stack","provenance":{"bus.tester":"default","dataRoot":"system","environment":"system","execution.adapter":"system","execution.backend":"system","execution.model":"system","execution.provider":"system","gitea.baseUrl":"business:acme","harness":"business:acme:roles.coder","limits.network":["business:acme:roles.coder"],"tracker.baseUrl":"business:acme","tracker.descendants":"default","tracker.kind":"default","tracker.pollSeconds":"business:acme","tracker.project":"project:stack","tracker.reconcileMinutes":"default"},"skills":["ms-unslop","ms-sdlc","ms-file-read","ms-file-write"],"tracker":{"bot":"bot-acme-coder","botId":5},"vars":{"bus.tester":false,"dataRoot":"<ROOT>/data","environment":"development","execution.adapter":"mock","execution.backend":"docker","execution.model":"glm-5.3-flash","execution.provider":"zai","gitea.baseUrl":"https://git.example","harness":"pi","limits.network":"none","tracker.baseUrl":"http://127.0.0.1:3456","tracker.descendants":false,"tracker.kind":"vikunja","tracker.pollSeconds":60,"tracker.project":3,"tracker.reconcileMinutes":60},"webSource":null}
{"business":"acme","contract":"<ROOT>/roles/coder.md","credentials":{"gitea":{"file":"<ROOT>/secrets/coder-gitea.token","rotateBy":"2099-01-01","service":"gitea"},"vikunja":{"expires":"2099-01-01","file":"<ROOT>/secrets/coder-vikunja.token","service":"vikunja"}},"definition":"coder","holder":null,"instance":"coder","launch":null,"limits":{"authority":{"crossRole":["task.scope.change"],"withinRole":["task.update.assigned","git.push.working","review.request","message.send","record.write"]},"network":"none","tools":["read","write","edit","bash","grep","find","ls"]},"project":"stack","provenance":{"bus.tester":"default","dataRoot":"system","environment":"system","execution.adapter":"system","execution.backend":"system","execution.model":"system","execution.provider":"system","gitea.baseUrl":"business:acme","harness":"business:acme:roles.coder","limits.network":["business:acme:roles.coder"],"tracker.baseUrl":"business:acme","tracker.descendants":"default","tracker.kind":"default","tracker.pollSeconds":"business:acme","tracker.project":"project:stack","tracker.reconcileMinutes":"default"},"rails":{"actions":[{"action":"task.update.assigned","routes":[{"to":"alone"}]},{"action":"git.push.working","routes":[{"to":"alone"}]},{"action":"review.request","routes":[{"to":"alone"}]},{"action":"message.send","routes":[{"to":"alone"}]},{"action":"record.write","routes":[{"to":"alone"}]},{"action":"task.scope.change","routes":[{"domain":"delivery","to":"pm"},{"domain":"technical","to":"cto"}]}],"credentials":[{"scopes":["write:issue","write:repository","read:user"],"service":"gitea"},{"scopes":{"projects":["views_buckets_tasks"],"tasks":["read_one","update"],"tasks_comments":["create"]},"service":"vikunja"}],"messaging":null,"network":"none","resolve":[]},"skills":["ms-unslop","ms-sdlc","ms-file-read","ms-file-write"],"tracker":{"bot":"bot-acme-coder","botId":5},"vars":{"bus.tester":false,"dataRoot":"<ROOT>/data","environment":"development","execution.adapter":"mock","execution.backend":"docker","execution.model":"glm-5.3-flash","execution.provider":"zai","gitea.baseUrl":"https://git.example","harness":"pi","limits.network":"none","tracker.baseUrl":"http://127.0.0.1:3456","tracker.descendants":false,"tracker.kind":"vikunja","tracker.pollSeconds":60,"tracker.project":3,"tracker.reconcileMinutes":60},"webSource":null}
@@ -67,6 +67,91 @@
]
}
},
"rails": {
"actions": [
{
"action": "task.update.assigned",
"routes": [
{
"to": "alone"
}
]
},
{
"action": "git.push.working",
"routes": [
{
"to": "alone"
}
]
},
{
"action": "review.request",
"routes": [
{
"to": "alone"
}
]
},
{
"action": "message.send",
"routes": [
{
"to": "alone"
}
]
},
{
"action": "record.write",
"routes": [
{
"to": "alone"
}
]
},
{
"action": "task.scope.change",
"routes": [
{
"domain": "delivery",
"to": "pm"
},
{
"domain": "technical",
"to": "cto"
}
]
}
],
"resolve": [],
"network": "none",
"credentials": [
{
"service": "gitea",
"scopes": [
"write:issue",
"write:repository",
"read:user"
]
},
{
"service": "vikunja",
"scopes": {
"tasks": [
"read_one",
"update"
],
"tasks_comments": [
"create"
],
"projects": [
"views_buckets_tasks"
]
}
}
],
"messaging": null
},
"credentials": {
"gitea": {
"service": "gitea",
+1 -1
View File
@@ -1 +1 @@
{"business":"acme","contract":"/synthetic/roles/coder.md","credentials":{},"definition":"coder","holder":"example","instance":"coder","launch":null,"limits":{"authority":{"crossRole":[],"withinRole":["message.send"]},"network":"none","tools":["read","write"]},"project":"stack","provenance":{"bus.tester":"default","dataRoot":"system","environment":"system","execution.adapter":"system","execution.backend":"system","execution.model":"system","execution.provider":"system","limits.network":["business:acme:roles.coder"],"model":"business:acme:roles.coder","tracker.baseUrl":"business:acme","tracker.descendants":"default","tracker.kind":"default","tracker.pollSeconds":"project:stack:roles.coder","tracker.project":"project:stack","tracker.reconcileMinutes":"default"},"skills":[],"tracker":null,"vars":{"bus.tester":false,"dataRoot":"/synthetic/data","environment":"development","execution.adapter":"mock","execution.backend":"docker","execution.model":"system-model","execution.provider":"zai","limits.network":"none","model":"agent-model","tracker.baseUrl":"http://127.0.0.1:3456","tracker.descendants":false,"tracker.kind":"vikunja","tracker.pollSeconds":15,"tracker.project":3,"tracker.reconcileMinutes":60},"webSource":null}
{"business":"acme","contract":"/synthetic/roles/coder.md","credentials":{},"definition":"coder","holder":"example","instance":"coder","launch":null,"limits":{"authority":{"crossRole":[],"withinRole":["message.send"]},"network":"none","tools":["read","write"]},"project":"stack","provenance":{"bus.tester":"default","dataRoot":"system","environment":"system","execution.adapter":"system","execution.backend":"system","execution.model":"system","execution.provider":"system","limits.network":["business:acme:roles.coder"],"model":"business:acme:roles.coder","tracker.baseUrl":"business:acme","tracker.descendants":"default","tracker.kind":"default","tracker.pollSeconds":"project:stack:roles.coder","tracker.project":"project:stack","tracker.reconcileMinutes":"default"},"rails":{"actions":[{"action":"message.send","routes":[{"to":"alone"}]}],"credentials":[],"messaging":null,"network":"none","resolve":[]},"skills":[],"tracker":null,"vars":{"bus.tester":false,"dataRoot":"/synthetic/data","environment":"development","execution.adapter":"mock","execution.backend":"docker","execution.model":"system-model","execution.provider":"zai","limits.network":"none","model":"agent-model","tracker.baseUrl":"http://127.0.0.1:3456","tracker.descendants":false,"tracker.kind":"vikunja","tracker.pollSeconds":15,"tracker.project":3,"tracker.reconcileMinutes":60},"webSource":null}
+17 -1
View File
@@ -54,10 +54,26 @@
"crossRole": []
}
},
"rails": {
"actions": [
{
"action": "message.send",
"routes": [
{
"to": "alone"
}
]
}
],
"resolve": [],
"network": "none",
"credentials": [],
"messaging": null
},
"credentials": {},
"tracker": null,
"launch": null,
"webSource": null,
"skills": [],
"digest": "975144f5fcd6989c3393175b1713908c632c04f378b5f28a95cb2f4e552fbefa"
"digest": "5a8df768f2359fd8c0131eb5e8ce8d427f68d6d465ba20d42b413a280a922ef0"
}
@@ -117,7 +117,7 @@ test('installed wrapper pins v1 validate and resolve output, preimage and digest
'installed-resolve.json': normalized(resolved),
'installed-preimage.json': normalized(canonicalJson(preimage)) + '\n',
};
assert.equal(sha256(snapshots['installed-preimage.json'].slice(0, -1)), '5fc9d6258f4725b0e260d99e5cf81058a05d21b8ff298bb53653efbbc8013da9');
assert.equal(sha256(snapshots['installed-preimage.json'].slice(0, -1)), '0a12b35f55d033b91bd89745859028d252be52b05ca4083964b778a27ec20fd6');
for (const [name, actual] of Object.entries(snapshots)) {
assert.equal(actual, fixture(name), name);
}
+12 -2
View File
@@ -1304,8 +1304,10 @@ inactive. Project settings changes are append-only and require human proof.
`mosaic accounts` administers the instance-level accounts store locally, as the
effective uid owning the config directory. It requires a human shell, rejects `--tester`,
and takes no business selector or bus connection. It has no organization rights
verbs. Console sign-in, sessions and membership evaluation are later Phase M
slices; these commands alone do not turn accounts mode on.
verbs. Console OIDC sign-in re-reads these accounts on every request. A disabled
account or changed authentication generation loses account authority immediately;
a legacy access.json root grant is checked independently. Organization membership
evaluation follows in later Phase M slices.
```sh
mosaic accounts init --name Operator
@@ -1327,3 +1329,11 @@ organization (including archived organizations) or any registry entry lacks its
owner metadata during creation, and removes its memberships in
the same write. List reports rollback rows needing repair; its JSON omits invite
bindings and hashes. Receipts record effective process uid and verb and omit subjects and secrets.
Managed accounts instances use the system units from `scripts/case2-unit.sh`.
The selected uid must use a non-login shell, be the only member of its primary
group, and own its config directory and data root. The shared Case 1/Case 2
installer writes `<dataRoot>/.deployed` as mode 0444 naming the instance.
`webui-service.sh` refuses accounts and deployed instances because its user
manager runs under a human or development seat uid. The receipt uid identifies
the invoking process; it does not attribute a human or resist tampering by that uid.
+195
View File
@@ -0,0 +1,195 @@
// #1873 Phase A slice A2: the owner's terminal verbs for the root and project agent levels (design
// docs/plans/2026-11-10_agent-levels-design.md sections 6, 7 and 9 item 1).
//
// mosaic agents root list [--json]
// mosaic agents root set NAME [--definition ROLE] [--var KEY=VALUE]... [--var-json KEY=JSON]...
// [--unset-var KEY]... [--exclude-orgs ID,ID | --exclude-orgs none] [--revision R]
// mosaic agents root remove NAME [--revision R]
// mosaic project agents PROJECT --org ID list [--json]
// mosaic project agents PROJECT --org ID set NAME --definition ROLE [--var KEY=VALUE]... [--var-json KEY=JSON]... [--revision R]
// mosaic project agents PROJECT --org ID narrow NAME --var KEY=VALUE... [--var-json KEY=JSON]... [--revision R]
// mosaic project agents PROJECT --org ID exclude NAME [--revision R]
// mosaic project agents PROJECT --org ID remove NAME [--revision R]
//
// Every write is the owner's: an agent run is refused before anything is read (exit 3), and the
// verbs run only on a named dev instance, as `mosaic org` does, with the local dev owner as actor.
// Without --revision a write reads the revision just before it writes, so a concurrent change
// between that read and the write still refuses, but one made before the command started does
// not. Pass --revision from a `list` to make the write conditional on what was listed.
// A write prints its receipt; refusals name keys, never values.
import { configDir } from '../../organization/src/organization-config.mjs';
import { OrgError } from '../../organization/src/org-registry.mjs';
import { OrganizationError, organizationWriter } from '../../organization/src/organization.mjs';
import { changeRootAgent, patchRootEntry, projectAgentSummary, readRootAgentsFile, rootAgentSummary } from '../../organization/src/agent-levels-write.mjs';
import { loadSystem, rolesDir } from './config.mjs';
import { DEV_OWNER, devOwnerInstance } from './dev-local-owner.mjs';
import { CliError } from './errors.mjs';
import { refuseInsideAgent } from './transport.mjs';
export const AGENTS_ROOT_USAGE = 'mosaic agents root list [--json] | set NAME [--definition ROLE] [--var KEY=VALUE]... [--var-json KEY=JSON]... [--unset-var KEY]... [--exclude-orgs ID,ID|none] [--revision R] | remove NAME [--revision R]';
export const PROJECT_AGENTS_USAGE = 'mosaic project agents PROJECT --org ID list [--json] | set NAME --definition ROLE [--var KEY=VALUE]... [--revision R] | narrow NAME --var KEY=VALUE... [--revision R] | exclude NAME [--revision R] | remove NAME [--revision R]';
const usage = (text, line) => new CliError(`${text}\nusage: ${line}`, 4);
const REPEATED = ['--var', '--var-json', '--unset-var'];
const SINGLE = ['--definition', '--exclude-orgs', '--revision', '--org'];
// Option values are never echoed back: a refusal names the option only.
function parse(args, line) {
const positional = [], single = {}, repeated = { '--var': [], '--var-json': [], '--unset-var': [] }, 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', line); flags.add(a); }
else if (REPEATED.includes(a) || SINGLE.includes(a)) {
if (i + 1 >= args.length) throw usage(`${a} needs one value`, line);
const value = args[++i];
if (REPEATED.includes(a)) repeated[a].push(value);
else if (Object.hasOwn(single, a)) throw usage(`duplicate ${a}`, line);
else single[a] = value;
} else if (a.startsWith('--')) throw usage('unknown option', line);
else positional.push(a);
}
return { positional, single, repeated, flags };
}
function only(parsed, allowed, line) {
for (const key of [...Object.keys(parsed.single), ...REPEATED.filter(k => parsed.repeated[k].length), ...parsed.flags])
if (!allowed.includes(key)) throw usage(`unexpected ${key}`, line);
}
// KEY=VALUE pairs; a --var-json value is parsed as JSON. A malformed pair names its key at most.
function vars(parsed, line) {
const out = {};
const put = (key, value) => {
if (!key) throw usage('a var needs KEY=VALUE', line);
if (Object.hasOwn(out, key)) throw usage(`var ${key} is given twice`, line);
out[key] = value;
};
for (const pair of parsed.repeated['--var']) {
const at = pair.indexOf('=');
if (at < 1) throw usage('a var needs KEY=VALUE', line);
put(pair.slice(0, at), pair.slice(at + 1));
}
for (const pair of parsed.repeated['--var-json']) {
const at = pair.indexOf('=');
if (at < 1) throw usage('a var needs KEY=JSON', line);
const key = pair.slice(0, at);
try { put(key, JSON.parse(pair.slice(at + 1))); }
catch (e) { if (e instanceof CliError) throw e; throw usage(`var ${key} is not valid JSON`, line); }
}
return out;
}
const revisionOf = (parsed, read) => parsed.single['--revision'] ?? read();
function owner(io) {
refuseInsideAgent(io.env);
// Its refusal is one fixed line naming no path.
let instance;
try { instance = devOwnerInstance(io.env); } catch (error) { throw error instanceof CliError ? error : new CliError(String(error?.message), 2); }
// The instance's system config must exist and validate, marker included, as `mosaic org` checks:
// a missing or broken config refuses (exit 3) before anything is read or written.
loadSystem({ env: io.env, instance });
return { dir: configDir(io.env), roles: rolesDir(io.env) };
}
// Only the writers' own coded refusals print their text (it names keys and ids, never a value).
// Anything else, a filesystem error above all, prints its code and a fixed line: its message can
// hold a host path. So does a coded refusal whose text happens to hold an absolute path.
const ABSOLUTE_PATH = /(?:^|[\s('"=:])\/[^\s/]/;
function refusal(error) {
if (error instanceof CliError) return error;
const code = typeof error?.code === 'string' && /^[A-Za-z0-9_-]{1,64}$/.test(error.code) ? error.code : null;
const ours = (error instanceof OrgError || error instanceof OrganizationError || error?.name === 'BusinessError') && code !== null;
const text = ours && typeof error.message === 'string' && !ABSOLUTE_PATH.test(error.message) ? error.message : 'the operation failed; the error text is not shown';
// A refusal after the replace says the change landed; the receipt may be missing.
const landed = error?.committed ? ' (the change was written; its receipt may be missing)' : '';
return new CliError(`${code ? `${code}: ` : ''}${text}${landed}`, 2);
}
export async function agentsRootCommand(args, io) {
const line = AGENTS_ROOT_USAGE;
const parsed = parse(args, line);
const [op, name, ...extra] = parsed.positional;
try {
const { dir, roles } = owner(io);
if (op === 'list') {
if (name !== undefined) throw usage('list takes no name', line);
only(parsed, ['--json'], line);
const read = await readRootAgentsFile(dir);
const listing = { revision: read.revision, state: read.exists ? 'present' : 'absent', agents: rootAgentSummary(read.document) };
io.stdout.write(parsed.flags.has('--json') ? `${JSON.stringify(listing, null, 2)}\n` : formatRoot(listing));
return;
}
if (!['set', 'remove'].includes(op) || !name || extra.length) throw usage('root takes list, set NAME or remove NAME', line);
only(parsed, op === 'set' ? ['--definition', '--var', '--var-json', '--unset-var', '--exclude-orgs', '--revision'] : ['--revision'], line);
let entry = null;
if (op === 'set') {
const patch = {};
if (Object.hasOwn(parsed.single, '--definition')) patch.definition = parsed.single['--definition'];
const set = vars(parsed, line);
for (const key of parsed.repeated['--unset-var']) {
if (Object.hasOwn(set, key)) throw usage(`var ${key} is both set and unset`, line);
set[key] = null;
}
if (Object.keys(set).length) patch.vars = set;
if (Object.hasOwn(parsed.single, '--exclude-orgs')) {
const text = parsed.single['--exclude-orgs'];
patch.excludeOrgs = text === 'none' ? [] : text.split(',');
}
if (!Object.keys(patch).length) throw usage('set needs --definition, a var or --exclude-orgs', line);
entry = old => patchRootEntry(old, patch);
}
const revision = await revisionOf(parsed, async () => (await readRootAgentsFile(dir)).revision);
const receipt = await changeRootAgent(dir, { revision, name, entry }, { actor: DEV_OWNER, owner: DEV_OWNER, rolesDir: roles });
io.stdout.write(`${JSON.stringify(receipt)}\n`);
} catch (error) { throw refusal(error); }
}
export async function projectAgentsCommand(args, io) {
const line = PROJECT_AGENTS_USAGE;
const parsed = parse(args, line);
const [project, op, name, ...extra] = parsed.positional;
try {
const { dir, roles } = owner(io);
const org = parsed.single['--org'];
if (!project || !org) throw usage('project agents needs PROJECT and --org ID', line);
const writer = organizationWriter({ dir, rolesDir: roles });
if (op === 'list') {
if (name !== undefined) throw usage('list takes no name', line);
only(parsed, ['--org', '--json'], line);
const { document, revision } = writer.read(org);
if (!Object.hasOwn(document.projects ?? {}, project)) throw new OrgError('project-not-found', `project ${project} is not in organization ${org}`);
const listing = { organization: org, project, revision, agents: projectAgentSummary(document.projects[project].agents ?? {}) };
io.stdout.write(parsed.flags.has('--json') ? `${JSON.stringify(listing, null, 2)}\n` : formatProject(listing));
return;
}
if (!['set', 'narrow', 'exclude', 'remove'].includes(op) || !name || extra.length) throw usage('project agents takes list, set, narrow, exclude or remove NAME', line);
const allowed = { set: ['--definition', '--var', '--var-json'], narrow: ['--var', '--var-json'], exclude: [], remove: [] }[op];
only(parsed, ['--org', '--revision', ...allowed], line);
let entry = null;
if (op === 'set') {
if (!Object.hasOwn(parsed.single, '--definition')) throw usage('set needs --definition', line);
const v = vars(parsed, line);
entry = { definition: parsed.single['--definition'], ...(Object.keys(v).length ? { vars: v } : {}) };
} else if (op === 'narrow') {
const v = vars(parsed, line);
if (!Object.keys(v).length) throw usage('narrow needs at least one --var', line);
entry = { vars: v };
} else if (op === 'exclude') entry = { exclude: true };
const revisionBefore = revisionOf(parsed, () => writer.read(org).revision);
const result = writer.setProjectAgent(org, { revision: revisionBefore, project, name, entry }, DEV_OWNER.sub);
// The receipt: names and revisions only. The writer's `previous` is a host path; it isn't printed.
const receipt = { verb: 'project.agents.set', organization: org, project, agent: name, form: op === 'set' ? 'define' : op, revisionBefore, revisionAfter: result.revision, transaction: result.transaction };
io.stdout.write(`${JSON.stringify(receipt)}\n`);
} catch (error) { throw refusal(error); }
}
function formatRoot({ revision, state, agents }) {
if (!agents.length) return `root agents: none (${state === 'absent' ? 'no file' : 'empty'}), revision ${revision}\n`;
return `root agents (revision ${revision}):\n` + agents.map(a =>
` ${a.name}: ${a.definition}${a.model ? `, model ${a.model}` : ''}${a.varKeys.length ? `, vars ${a.varKeys.join(', ')}` : ''}${a.excludeOrgs.length ? `, not in ${a.excludeOrgs.join(', ')}` : ''}\n`).join('');
}
function formatProject({ organization, project, revision, agents }) {
if (!agents.length) return `project ${project} in ${organization}: no project agent entries, revision ${revision}\n`;
return `project ${project} in ${organization} (revision ${revision}):\n` + agents.map(a =>
` ${a.name}: ${a.form}${a.definition ? ` ${a.definition}` : ''}${a.varKeys?.length ? `, vars ${a.varKeys.join(', ')}` : ''}\n`).join('');
}
+10 -1
View File
@@ -8,6 +8,7 @@
// host (src/host.mjs) the systemd unit mosaic-bus@<business> runs; with
// --pm it also launches the business's PM (src/launcher.mjs).
import { assertBuildStart, assertDevInstance } from './deployment.mjs';
import { readFileSync } from "node:fs";
import { basename, join } from "node:path";
import { createInterface } from "node:readline/promises";
@@ -48,6 +49,7 @@ import { consoleRestart, consoleStart, consoleStatus, consoleStop } from "./org-
import { usageCommand, USAGE_USAGE } from "./usage.mjs";
import { orgCommand, ORG_USAGE } from "./org.mjs";
import { agentsRootCommand, AGENTS_ROOT_USAGE, projectAgentsCommand, PROJECT_AGENTS_USAGE } from "./agent-levels.mjs";
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";
@@ -65,6 +67,8 @@ export const USAGE = `usage:
${ORG_INSTANCES_USAGE}
mosaic business validate|resolve|instances|instance|veto: the old spelling of mosaic org config validate|resolve and mosaic org instances|instance|veto, same arguments
${PROJECT_USAGE}
${PROJECT_AGENTS_USAGE}
${AGENTS_ROOT_USAGE}
${PROJECT_BUS_USAGE}
${NORTH_STAR_USAGE}
${INTERVIEW_USAGE}
@@ -611,8 +615,10 @@ 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");
assertBuildStart({ devOwner: parsed.flags.has("--dev-local-owner") });
if (parsed.flags.has("--dev-local-owner")) devOwnerInstance(io.env);
const ctx = organizationContext(io, deps);
if (parsed.flags.has("--dev-local-owner")) assertDevInstance(ctx.system, io.env);
const businessId = parsed.positional[0];
if (!parsed.flags.has("--internal-host")) {
const receipt = await busStart(ctx, businessId, { detach: parsed.flags.has("--detach"), devLocalOwner: parsed.flags.has("--dev-local-owner"), pm: parsed.flags.has("--pm"), noLaunch: parsed.flags.has("--no-launch") });
@@ -655,7 +661,7 @@ async function bus(rest, io, deps) {
// tracker gets (the bus runtime makes the same choice, runtime.mjs trackerOf).
const taskModule = boot.businesses?.[businessId]?.plugins?.["task-tracker"];
const tracker = Boolean(boot.trackers?.[businessId]) || taskModule === undefined || taskModule === "store";
const launcher = createLauncher({ host, system: { ...system, instanceRoot: system.dataRoot, dataRoot: org.root }, env: launchEnv, tracker, launches, ...(instance ? fixtureLauncherOptions({ system, env: launchEnv, instance, doc: orgDoc }) : {}), ...(deps.launcher ?? {}) });
const launcher = createLauncher({ host, system: { ...system, instanceRoot: system.dataRoot, dataRoot: org.root }, env: launchEnv, tracker, launches, copies: boot.copies ?? null, ...(instance ? fixtureLauncherOptions({ system, env: launchEnv, instance, doc: orgDoc }) : {}), ...(deps.launcher ?? {}) });
await launcher.listen();
if (instance) io.stdout.write(`DEV LOCAL OWNER MODE: console owner local-dev accepted (named instance ${instance})\n`);
if (instance && parsed.flags.has("--pm")) io.stdout.write('DEV LOCAL OWNER MODE: launched agents run as this OS user and can reach the loopback Console as owner; use this instance for UI testing only\n');
@@ -731,6 +737,9 @@ export async function main(argv, io = { env: process.env, stdin: process.stdin,
if (verb === 'org' && rest[0] === 'config') return orgConfig(rest.slice(1), io);
if (verb === 'org' && ["instances", "instance", "veto"].includes(rest[0])) return orgInstancesCommand(rest, io, (p) => callFor(p, io, deps));
if (verb === 'org') return orgCommand(rest, io, deps);
// A2 (#1873): the owner's root and project agent levels. `mosaic agents` without `root` stays the bus read.
if (verb === "agents" && rest[0] === "root") return agentsRootCommand(rest.slice(1), io);
if (verb === "project" && rest[0] === "agents") return projectAgentsCommand(rest.slice(1), io);
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);
+27 -2
View File
@@ -10,6 +10,10 @@ import { dirname, isAbsolute, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { managedState } from "../../organization/src/project-bootstrap.mjs";
import { BusinessError, accessFilePath, configDir, loadAccess, loadBusiness, loadProject, projectFilePath, resolveInstance, systemVars } from "../../organization/src/index.mjs";
import { loadRootAgents } from "../../organization/src/agent-levels.mjs";
import { materializeCopies, withCopies } from "../../organization/src/agent-copies.mjs";
import { organizationWriter } from "../../organization/src/organization.mjs";
import { businessFilePath } from "../../organization/src/organization-config.mjs";
import { busOrganization } from "../../bus/src/organization-context.mjs";
import { BusError } from "../../bus/src/broker.mjs";
import { CliError } from "./errors.mjs";
@@ -86,7 +90,7 @@ function trackerFor(system, b, warn, dataRoot) {
if (dataRoot && managedState({ dataRoot, business: b.id, project: id, entry }) === 'reserved') continue;
if (!existsSync(projectFilePath(entry.root))) continue;
const project = loadProject(entry.root);
if (project.id !== id) throw new CliError(`project file ${project.file} has id ${project.id}, but business ${b.id} declares it as ${id}`, 3);
if (project.id !== id) throw new CliError(`project file ${project.file} has id that does not match the declared project key`, 3);
const vars = resolveInstance({ system, business: b, project, instance: first }).vars;
if (vars["tracker.project"] !== undefined) named.push({ id, vars });
}
@@ -144,10 +148,29 @@ export const consoleOwners = (sso) =>
// The `{op: 'boot'}` config for packages/bus/src/process.mjs, for one
// business. The host holds a reader capability for the notifier and binds
// launches later through bindLaunch, so `launches` starts empty.
// #1873 A3: the organization's root and project agent copies, read once here. The root file is read
// through loadRootAgents and the organization file through the organization's own writer; the
// snapshot is plain data that the broker and the launcher re-apply to every later read of the
// organization file, so a root file change takes effect at the next boot only. A root file that
// refuses hides the root level (design section 6): no root copy, and the organization still boots.
export function copiesAtBoot({ businessId, env = process.env, warn = () => {} }) {
const dir = configDir(env), roles = rolesDir(env);
loadBusiness(businessId, { dir, rolesDir: roles });
const { document } = organizationWriter({ dir, rolesDir: roles }).read(businessId);
let root = null;
try { root = loadRootAgents(dir, { rolesDir: roles }); }
catch (error) {
if (!(error instanceof BusinessError)) throw error;
warn(`root agent file refused, so ${businessId} runs no root copies until it is fixed and the bus restarts: ${error.message}`);
}
const snapshot = materializeCopies({ orgId: businessId, doc: document, root, rolesDir: roles });
return { snapshot, business: withCopies(document, businessFilePath(businessId, dir), { rolesDir: roles, snapshot }) };
}
export function bootConfig({ system, businessId, orgRoot = system.dataRoot, env = process.env, warn = () => {} }) {
return business(() => {
const vars = systemVars(system);
const b = loadBusiness(businessId, { dir: configDir(env), rolesDir: rolesDir(env) });
const { snapshot, business: b } = copiesAtBoot({ businessId, env, warn });
const resolved = {};
for (const instance of Object.keys(b.roles)) resolved[instance] = resolveInstance({ system: vars, business: b, instance });
const config = {
@@ -158,6 +181,8 @@ export function bootConfig({ system, businessId, orgRoot = system.dataRoot, env
organization: {dir:configDir(env),rolesDir:rolesDir(env),system:vars},
launches: [],
readers: [b.id],
// Present only when the organization has a copy, so a boot with none is the boot it was.
...(Object.keys(snapshot.copies).length ? { copies: snapshot } : {}),
};
const tracker = trackerFor(vars, b, warn, orgRoot);
if (tracker) {
+60
View File
@@ -0,0 +1,60 @@
// M3: executable and instance boundaries. Presence is checked with lstat, so
// broken links and unreadable metadata never turn a deployed path into dev.
import { constants, lstatSync, openSync, fstatSync, readFileSync, closeSync, writeFileSync, fchmodSync, mkdirSync } from 'node:fs';
import { basename, dirname, join, resolve } from 'node:path';
import { homedir } from 'node:os';
const configDir = (env = process.env) => dirname(resolve(env.MOSAIC_CONFIG || join(homedir(), '.config/mosaic-dev/config.json')));
const CODE_ROOT = resolve(import.meta.dirname, '../../..');
export function present(file) {
try { lstatSync(file); return true; } catch (e) { if (e.code === 'ENOENT') return false; throw e; }
}
function metadata(file, label, check) {
let fd;
try {
fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
const st = fstatSync(fd);
if (!st.isFile() || st.size > 4096 || !check(st)) throw new Error('metadata refused');
return JSON.parse(readFileSync(fd, 'utf8'));
} catch { throw new Error(`${label} is missing or invalid`); }
finally { if (fd !== undefined) closeSync(fd); }
}
export function buildStamp(root = CODE_ROOT) {
const file = join(root, '.stack-build.json');
if (!present(file)) return null;
const doc = metadata(file, 'build stamp', st => !(st.mode & 0o222));
if (!doc || Object.keys(doc).length !== 1 || !/^[0-9a-f]{40}$/.test(doc.commit) || doc.commit !== basename(root)) throw new Error('build stamp does not name this copy commit');
return doc;
}
export function assertBuildStart({ serve = false, sso = false, devOwner = false, devBind = null, devNames = [] } = {}) {
const stamp = buildStamp();
if (stamp && (devOwner || devBind !== null || devNames.length)) throw new Error('stamped build refuses dev-local-owner and LAN flags');
if (stamp && serve && !sso) throw new Error('stamped build serve requires --sso');
return stamp;
}
export function assertDevInstance(system, env = process.env) {
if (present(join(configDir(env), 'accounts.json'))) throw new Error('--dev-local-owner refuses accounts.json');
if (system.console?.sso) throw new Error('--dev-local-owner refuses console.sso');
if (present(join(system.dataRoot, '.deployed'))) throw new Error('--dev-local-owner refuses .deployed');
}
const instanceName = dir => /^mosaic-([a-z][a-z0-9]{1,15})$/.exec(basename(dir))?.[1] ?? 'dev';
export function assertDeployed(system, dir = configDir()) {
const doc = metadata(join(system.dataRoot, '.deployed'), '.deployed', st => (st.mode & 0o777) === 0o444 && st.uid === process.geteuid());
if (!doc || Object.keys(doc).length !== 1 || doc.instance !== instanceName(dir)) throw new Error('.deployed does not name this instance');
}
// Both managed host cases install through case2-install.sh. Existing metadata
// must validate; an install never replaces it or follows a destination link.
export function writeDeployment(system, dir = configDir()) {
// Default-instance bootstrap creates the config before its data directory.
mkdirSync(system.dataRoot, { recursive: true, mode: 0o700 });
const root = lstatSync(system.dataRoot);
if (!root.isDirectory() || root.uid !== process.geteuid() || (root.mode & 0o022)) throw new Error('.deployed data root must be an owned directory without public write');
const file = join(system.dataRoot, '.deployed');
if (present(file)) {
const doc = metadata(file, '.deployed', st => (st.mode & 0o777) === 0o444 && st.uid === process.geteuid());
if (!doc || Object.keys(doc).length !== 1 || doc.instance !== instanceName(dir)) throw new Error('.deployed does not name this instance');
return;
}
const fd = openSync(file, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, 0o444);
try { writeFileSync(fd, JSON.stringify({ instance: instanceName(dir) }) + '\n'); fchmodSync(fd, 0o444); } finally { closeSync(fd); }
}
+62 -11
View File
@@ -143,9 +143,10 @@ import { createHash } from 'node:crypto';
import { watchModelOutput } from './model-stall.mjs';
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
import { dirname, join, relative } from "node:path";
import { configDir, loadBusiness, resolveInstance, systemVars } from "../../organization/src/index.mjs";
import { configDir, resolveInstance, systemVars } from "../../organization/src/index.mjs";
import { organizationWriter } from "../../organization/src/organization.mjs";
import { businessFilePath, validateBusinessDocument } from "../../organization/src/organization-config.mjs";
import { businessFilePath } from "../../organization/src/organization-config.mjs";
import { copyOf, copyRefusal, loadWithCopies, projectRefusal, withCopies } from "../../organization/src/agent-copies.mjs";
import { selectModelProvider } from "../../plugins/src/model-provider-selection.mjs";
import { THREAD_DEFINITIONS } from "../../organization/src/vocabulary.mjs";
import { limitHit } from "../../organization/src/launch.mjs";
@@ -244,6 +245,9 @@ export function createLauncher({
git = {},
stopOptions = {},
log = (l) => process.stderr.write(`mosaic-bus: ${l}\n`),
// #1873 A3: the root and project agent copies the bus booted with (config.mjs copiesAtBoot), or
// null. Every read of the organization file here re-applies this snapshot, never the root file.
copies = null,
}) {
if (testProxy !== null && system.proxy)
throw Object.assign(new Error("testProxy skips P2 admission, so it is refused with system.proxy set"), { code: "test-proxy-refused" });
@@ -257,7 +261,8 @@ export function createLauncher({
}
// Row 64 (#1557): where a role's skills are read from; scripts/skill.sh activate fills it.
const skillsDir = join(system.instanceRoot ?? dataRoot, "skills-enabled");
let b = loadBusiness(host.business, { dir: configDir(env), rolesDir: rolesDir(env) });
const loadOrg = () => loadWithCopies(host.business, { dir: configDir(env), rolesDir: rolesDir(env), snapshot: copies?.org === host.business ? copies : null });
let b = loadOrg();
const vars = systemVars(system);
// Sessions an earlier host left in sessions.json: it died without ending
@@ -355,10 +360,17 @@ export function createLauncher({
try {
const snapshot = organizationWriter({dir:configDir(env),rolesDir:rolesDir(env)}).read(host.business);
businessRevision = snapshot.revision;
b = validateBusinessDocument(snapshot.document, businessFilePath(host.business, configDir(env)), {rolesDir:rolesDir(env)});
b = withCopies(snapshot.document, businessFilePath(host.business, configDir(env)), {rolesDir:rolesDir(env), snapshot: copies?.org === host.business ? copies : null});
} catch(e){throw new Refusal("organization-refused",e.message);}
if (!b.launch) throw new Refusal("launch-not-configured", `business ${b.id} has no launch block`);
if (!(pm ? instance === b.launch.by : b.launch.instances.includes(instance) || (human && Object.hasOwn(b.roles, instance)))) throw new Refusal("instance-not-listed");
// #1873 A3: an unbound or refused copy never launches. The message names the copy, its project
// and the missing service names or the refusal, never a value.
const copyRefused = copyRefusal(b, instance);
if (copyRefused) throw new Refusal(copyRefused.code, copyRefused.message);
// #1873 A3: a bound copy isn't on the chief's list. It launches when the human asks, or for a
// thread, which only a human or a granted user makes, naming the copy.
const boundCopy = copyOf(b, instance)?.state === "instance" && Object.hasOwn(b.roles, instance);
if (!(pm ? instance === b.launch.by : b.launch.instances.includes(instance) || (human && Object.hasOwn(b.roles, instance)) || (thread && boundCopy))) throw new Refusal("instance-not-listed");
const threaded = THREAD_DEFINITIONS.includes(b.roles[instance]?.definition);
if (threaded && !thread) throw new Refusal("thread-required", `${instance} runs only for a thread`);
if (!threaded && thread) throw new Refusal("thread-not-allowed", `${instance} isn't a thread instance`);
@@ -539,12 +551,12 @@ export function createLauncher({
await pickRoute(() => standIn);
await gate(model, harness, null);
admission(admissionGeneration);
return { resolved, model, account: null, harness, route, routeReason, admissionGeneration, resolver };
return { resolved, model, account: null, harness, route, routeReason, admissionGeneration, resolver, revision: businessRevision };
}
const sessionProxy = await pickRoute(proxyFor);
const account = await gate(model, harness, sessionProxy);
admission(admissionGeneration);
return { resolved, model, account, harness, route, routeReason, admissionGeneration, sessionProxy, resolver };
return { resolved, model, account, harness, route, routeReason, admissionGeneration, sessionProxy, resolver, revision: businessRevision };
}
// Reread host state on every new-run path, including opens/clears queued before archive.
@@ -554,6 +566,8 @@ export function createLauncher({
const row = rows.find(r => r.thread === thread && r.session === session);
if (!row) throw new Refusal("session-not-found");
if (row.archived) throw new Refusal("project-archived");
// #1873 A3: the thread's project no longer takes its instance: the union refuses, or excludes it.
if (row.projectRefused) throw new Refusal(row.projectRefused.code, row.projectRefused.reason);
}
// Bundle, spawn, bind, register, hand over the capability.
@@ -616,13 +630,24 @@ export function createLauncher({
// (a mapping published before the seed and records finished) is left out and the launch goes on without
// it. Ordinary mappings also recheck archive state through the same host admission.
const codeProjects = {};
// #1873 A3: an instance gets only the code projects it works in: a project agent its own one, and
// no instance a project that excludes it or whose union refuses. A project agent refuses when its
// own project isn't admitted. A checkout an earlier launch left in the workspace for a project
// the instance may not work in now is hidden from the session, never deleted (sandbox.mjs).
const own = copyOf(b, instance)?.project ?? null;
const declared = new Set();
for (const [project, entry] of Object.entries(b.projects)) {
if (entry.kind === 'library') continue;
declared.add(project);
if (own !== null && project !== own) continue;
const unavailable = projectRefusal(b, instance, project);
if (unavailable) { record({ event: "checkout-skipped", instance, project, reason: unavailable.code }); continue; }
let answer = null;
try { answer = await host.op({ op: 'request.admission', business: b.id, project }); } catch (e) { answer = { ready: false, code: e.code ?? 'admission-unavailable' }; }
if (answer?.ready !== true) { record({ event: "checkout-skipped", instance, project, reason: answer?.code ?? 'project-pending' }); continue; }
codeProjects[project] = entry;
}
if (own !== null && !Object.hasOwn(codeProjects, own)) throw new Refusal("project-refused", `${instance} works only in project ${own}, which isn't ready`);
await threadAdmission(thread, session);
let checkouts;
try {
@@ -646,9 +671,30 @@ export function createLauncher({
catch (error) { throw new Refusal(error.code ?? 'proxy-down', error.code ?? 'proxy-down'); }
if (!admittedProxy || typeof admittedProxy.key !== 'string' || !admittedProxy.key || admittedProxy.baseUrl !== `http://127.0.0.1:${system.proxy.port}`) throw new Refusal('proxy-down', 'proxy-down');
}
let harness, route, routeReason, sessionProxy, resolver;
({resolved,model,account,harness,route,routeReason,sessionProxy,resolver}=await check(instance,{pm:instance===b.launch.by,thread,session,human,proxyContext:admittedProxy,proxyReady:true}));
let harness, route, routeReason, sessionProxy, resolver, revision;
({resolved,model,account,harness,route,routeReason,sessionProxy,resolver,revision}=await check(instance,{pm:instance===b.launch.by,thread,session,human,proxyContext:admittedProxy,proxyReady:true}));
admission(admissionGeneration);
// #1873 A3: check() read the organization again. A project that excluded or refused this
// instance while its checkouts were prepared refuses the launch; the next one leaves it out.
for (const project of Object.keys(codeProjects)) {
const unavailable = projectRefusal(b, instance, project);
if (unavailable) throw new Refusal(unavailable.code, unavailable.message);
}
// #1873 A3: every declared code project this launch leaves out, in the document it started from
// or the one check() read, is hidden if an earlier launch left its checkout in the workspace,
// whatever kept it out (not admitted, excluded, refused, another project's agent). Only a real
// directory can be covered: a tmpfs mounted at a symlink would land on its target, so anything
// else at that path refuses the launch, and nothing on the host is changed or removed.
for (const [project, entry] of Object.entries(b.projects)) if (entry.kind !== 'library') declared.add(project);
const hidden = [];
for (const project of declared) {
if (Object.hasOwn(codeProjects, project)) continue;
const path = join(workspace, project);
let stat;
try { stat = lstatSync(path); } catch (e) { if (e.code === "ENOENT") continue; throw new Refusal("checkout-unsafe", `${instance}'s workspace path for project ${project} can't be read`); }
if (!stat.isDirectory()) throw new Refusal("checkout-unsafe", `${instance}'s workspace path for project ${project} isn't a directory, so it can't be hidden; a person removes it before ${instance} launches again`);
hidden.push(path);
}
const { run, runDir } = newRun(dataRoot, b.id);
let modelKeyId;
if (admittedProxy) {
@@ -744,6 +790,7 @@ export function createLauncher({
gitSocket: git,
threadStore: store,
readOnly,
hidden,
};
admission(admissionGeneration);
await threadAdmission(thread, session);
@@ -754,7 +801,9 @@ export function createLauncher({
}
// One synchronous broker snapshot covers the thread and the entire manifest after every
// asynchronous preparation read. Admission is the launch boundary; bindLaunch remains a fence.
const finalAdmission = await host.op({ op: "request.admission", business: b.id, projects: Object.keys(codeProjects), thread, session });
// #1873 A3: `revision` is the organization file check() resolved the bundle from; a file that
// changed since (a narrow, a define's vars, an exclude) refuses, and the next launch resolves anew.
const finalAdmission = await host.op({ op: "request.admission", business: b.id, projects: Object.keys(codeProjects), instance, revision, thread, session });
if (finalAdmission?.ready !== true) throw new Refusal(finalAdmission?.code ?? "project-pending");
const managed = registerRuntime(dataRoot, { kind: 'session', business: b.id, run });
let child;
@@ -889,6 +938,8 @@ export function createLauncher({
for (const i of pending) {
const session = registry.running(b.id, i.target, null);
if (!session || !i.project || stopping.has(session.run)) continue;
// #1873 A3: a manifest never gains a project its instance doesn't work in.
if (projectRefusal(b, i.target, i.project)) continue;
let projects = [];
try { projects = JSON.parse(readFileSync(join(session.runDir, 'bundle/manifest.json'), 'utf8')).projects ?? []; } catch {}
if (!projects.includes(i.project)) {
@@ -1348,7 +1399,7 @@ export function createLauncher({
if (typeof instance === 'string') await host.op({ op: 'request.launchResult', record: { business: b.id, instance, code: out.ok ? null : out.error, run: out.ok ? out.result.run : null } });
return out;
};
try { b = loadBusiness(host.business, { dir: configDir(env), rolesDir: rolesDir(env) }); }
try { b = loadOrg(); }
catch { return reply({ ok: false, error: 'organization-refused' }); }
// Product enums and clones are frozen in a session's manifest. A legitimate chief
// launch may request a drain, never interrupt the turn or start a privileged worker.
+1 -1
View File
@@ -353,7 +353,7 @@ test("T2 a bad user add refuses before any write", async (t) => {
}
const checked = [
[USER_ADD("bob", { subject: "github:bob-sub" }), /provider must be a configured provider/],
[USER_ADD("alice", { subject: "authentik:alice-2" }), /duplicate user id: alice/],
[USER_ADD("alice", { subject: "authentik:alice-2" }), /duplicate user id/],
[USER_ADD("tester"), /a bus principal/],
[USER_ADD("jason"), /a business's human/],
[USER_ADD("bob", { subject: "authentik:jason-sub" }), /owner sign-in subject/],
+32
View File
@@ -69,3 +69,35 @@ test('host boundary and receipt use effective uid, not real uid (current design
try { await s.run(['init']); await s.run(['list']); const receipt = JSON.parse(readFileSync(join(s.configDir, 'accounts-receipts.jsonl'), 'utf8')); assert.equal(receipt.uid, effectiveUid); }
finally { process.getuid = realUid; }
});
// The uid identifies this process, not the human who invoked it. The instance
// uid can rewrite its receipt; this is neither human attribution nor tamper evidence.
test('M3 CLI integration refuses foreign effective uid before any account mutation, and receipts identify the process', async t => {
const { spawnSync } = await import('node:child_process'); const { fixture } = await import('./helpers.mjs');
const root=mkdtempSync(join(tmpdir(),'ai-')); t.after(()=>rmSync(root,{recursive:true,force:true})); const f=fixture(root);
const config=JSON.parse(readFileSync(f.env.MOSAIC_CONFIG)); config.console={sso:{publicOrigin:'https://console.test',providers:[{id:'pocket',issuer:'https://id.test',clientId:'test',clientSecret:{slot:'pocket'},algs:['RS256'],subjects:[{sub:'owner',name:'Owner'}]}]}};
writeFileSync(f.env.MOSAIC_CONFIG,JSON.stringify(config));
const prelude=join(root,'uid.mjs'); const cli=new URL('../src/cli.mjs',import.meta.url).pathname;
const run=args=>spawnSync(process.execPath,['--import',prelude,cli,'accounts',...args],{env:{...process.env,...f.env},encoding:'utf8',timeout:10000});
writeFileSync(prelude,`process.geteuid=()=>${process.geteuid()+1};`); const refused=run(['init']); assert.equal(refused.status,3,refused.stderr); assert.match(refused.stderr,/accounts-unsafe: (a config directory ancestor|config directory) must belong to (root or )?uid \d+/);
for(const name of ['accounts.json','accounts.lock','accounts-receipts.jsonl']) assert.equal(existsSync(join(root,'config',name)),false);
// Different real uid, same effective uid: the effective identity is recorded.
writeFileSync(prelude,`process.getuid=()=>${process.geteuid()+2};`); const ok=run(['init']); assert.equal(ok.status,0,ok.stderr);
const receipt=JSON.parse(readFileSync(join(root,'config/accounts-receipts.jsonl'),'utf8')); assert.equal(receipt.uid,process.geteuid());
});
// org-acct-anchor (#1873): `mosaic accounts init` refuses under an ancestor another user could
// rename, before any lock or write, and the refusal names the rule, never the path.
test('org-acct-anchor: init, list and remove refuse a 0777 ancestor before any lock; 1777 passes', async t => {
const top = mkdtempSync(join(tmpdir(), 'a-')); t.after(() => { chmodSync(top, 0o700); rmSync(top, { recursive: true, force: true }); });
const parent = join(top, 'sk-test-NOT-A-SECRET-1234'), configDir = join(parent, 'cfg'); mkdirSync(configDir, { recursive: true, mode: 0o700 });
let output = ''; const io = { env: {}, stdout: { write: s => output += s }, stderr: { write: () => {} } };
const deps = { configDir, instance: 'test', system: { console: { sso: { providers } } } };
chmodSync(parent, 0o777);
for (const args of [['init'], ['list'], ['user', 'remove', 'u-000000000001', '--revision', '1']]) {
await assert.rejects(accountsCommand(args, io, deps), e => e.exitCode === 3 && /^refused: accounts-unsafe: a config directory ancestor must belong to root or uid \d+ and not be writable by others without the sticky bit$/.test(e.message) && !e.message.includes('sk-test') && !e.message.includes('/'));
for (const name of ['accounts.json', 'accounts.lock', 'accounts-receipts.jsonl', 'org-runtime.lock']) assert.equal(existsSync(join(configDir, name)), false, name);
}
chmodSync(parent, 0o1777);
await accountsCommand(['init'], io, deps); assert.match(output, /accounts init: u-[0-9a-f]{12}, revision 1/);
});
+595
View File
@@ -0,0 +1,595 @@
import { requestHostOps } from './request-host-ops.mjs';
// #1873 Phase A slice A3: root and project agent copies through the real bus and launcher. The
// cli's boot step reads the root file once and hands the snapshot to the broker and the launcher;
// a bound copy launches as an ordinary instance of the organization, an unbound or refused one
// refuses at the verb, and a project agent gets only its own project. Real runners in the bwrap
// sandbox on the fake adapter, with the in-process host lifecycle.test.mjs uses.
import { test } from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { DatabaseSync } from "node:sqlite";
import { existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, renameSync, symlinkSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { businessDoc, launchDoc, tokenFile, writeJson } from "../../organization/tests/helpers.mjs";
import { BusError } from "../../bus/src/broker.mjs";
import { Client } from "../../bus/src/client.mjs";
import { startBroker } from "../../bus/src/runtime.mjs";
import { gitEnv } from "../../seat/src/checkout.mjs";
import { launchLogFile } from "../../seat/src/session.mjs";
import { sandboxProblem } from "../../seat/src/sandbox.mjs";
import { bootConfig, loadSystem } from "../src/config.mjs";
import { startTimeOf } from "../src/host.mjs";
import { createLauncher } from "../src/launcher.mjs";
import { fakeHarnesses, fixture, tmp, withoutHostCredentials } from "./helpers.mjs";
const FAKE = fileURLToPath(new URL("../../harness/tests/fixtures/fake-adapter.mjs", import.meta.url));
const skip = sandboxProblem() ?? false;
const SECRET = "sk-test-NOT-A-SECRET-1234";
const VARS = { harness: "pi", model: "claude-sonnet-5-5", thinking: "high" };
async function until(fn, ms = 15000) {
const end = Date.now() + ms;
while (Date.now() < end) {
const v = await fn();
if (v) return v;
await new Promise((r) => setTimeout(r, 50));
}
throw new Error("timed out waiting");
}
function gitRepo(dir) {
mkdirSync(dir, { recursive: true });
const git = (...args) => {
const r = spawnSync("git", ["-c", "maintenance.auto=false", "-c", "gc.auto=0", "-c", "user.name=test", "-c", "[email protected]", "-C", dir, ...args], { env: gitEnv(), encoding: "utf8" });
assert.equal(r.status, 0, r.stderr);
};
git("init", "-q", "-b", "main");
writeFileSync(join(dir, "file.txt"), "one\n");
git("add", "file.txt");
git("commit", "-q", "-m", "one");
}
// acme: two code projects, stack and web. stack defines the project agent checker. The root file
// holds gate (merge-gate, no credential: bound), researcher (research, needs gitea: unbound in every
// organization) and sec (security-reviewer, excluded from acme). The coder's gitea token file is
// named for, and holds, the test secret, so it is bound elsewhere in the organization.
function prepare(t, edit = (doc) => doc) {
const root = tmp(t, "mosaic-copy-");
const f = fixture(root, "acme", (doc) => {
doc.roles.chief.vars = { harness: "pi", model: "claude-opus-5-5", thinking: "high" };
doc.roles.coder.vars.model = "claude-sonnet-5-5";
const secret = tokenFile(join(root, "secrets"), SECRET);
writeFileSync(secret, SECRET);
doc.roles.coder.credentials.gitea = { ...doc.roles.coder.credentials.gitea, file: secret };
doc.launch = launchDoc("chief", ["coder"], { opus: 1, sonnet: 4 });
doc.projects.stack.agents = { checker: { definition: "merge-gate", vars: VARS } };
doc.projects.web = { root: join(root, "web") };
return edit(doc);
});
withoutHostCredentials(f.env, { piKey: true });
gitRepo(f.doc.projects.stack.root);
gitRepo(f.doc.projects.web.root);
writeJson(join(root, "config", "agents.json"), { agentsVersion: 1, agents: {
gate: { definition: "merge-gate", vars: VARS },
researcher: { definition: "research", vars: VARS },
sec: { definition: "security-reviewer", vars: VARS, excludeOrgs: ["acme"] },
} }, 0o600);
const adapter = join(root, "adapter.sh");
writeFileSync(adapter, `exec '${process.execPath}' '${FAKE}'\n`, { mode: 0o644 });
return { root, f, adapter };
}
async function start(t, { f, adapter }, { businessId = "acme", orgRoot } = {}) {
const system = loadSystem({ env: f.env });
const { trackers, ...boot } = bootConfig({ system, businessId, env: f.env, ...(orgRoot ? { orgRoot } : {}) });
const runtime = await startBroker(boot);
const dataRoot = boot.dataRoot;
mkdirSync(join(dataRoot, "bus-host"), { recursive: true, mode: 0o700 });
const ops = {
...requestHostOps(runtime),
identity: (m) => runtime.identity(m.cap),
authorizeLaunch: (m) => runtime.authorizeLaunch(m.cap, m.instance),
refuse: (m) => runtime.refuse(m.cap, m.code, m.subject),
endLaunch: (m) => runtime.endLaunch(m.record),
credentialStatus: (m) => runtime.credentialStatus(m.business, m.role),
threadsState: (m) => runtime.threadsState(m.business),
threadIdle: (m) => runtime.threadIdle(m.record),
mayLaunch: (m) => runtime.mayLaunch(m),
landHeld: (m) => runtime.landHeld(m.record),
landHolders: (m) => runtime.landHolders(m.business),
landRelease: (m) => runtime.landRelease(m.record),
lifecycleRequests: (m) => runtime.lifecycleRequests(m.business),
lifecycleTake: (m) => runtime.lifecycleTake(m.record),
lifecycleDelete: (m) => runtime.lifecycleDelete(m.record),
lifecycleDone: (m) => runtime.lifecycleDone(m.record),
};
const refusing = (what, fn) => async (m) => {
try {
return fn(m);
} catch (e) {
const code = e instanceof BusError ? e.code : `${what}-refused`;
throw Object.assign(new Error(`${what} refused: ${code}`), { code });
}
};
const hooks = [];
let closing = null;
const host = {
business: businessId,
path: runtime.path,
dataRoot,
op: refusing("launch-op", (m) => {
if (!Object.hasOwn(ops, m.op)) throw new BusError("invalid-host-request");
return ops[m.op](m);
}),
bindLaunch: refusing("launch bind", (r) => runtime.bindLaunch(r)),
beforeClose: (fn) => void hooks.push(fn),
};
const logs = [];
const launcher = createLauncher({
host,
system: orgRoot ? { ...system, dataRoot } : system,
env: f.env,
makeLoader: await fakeHarnesses(t, { pi: adapter, "claude-code": adapter }),
sessionDefaults: { pollInterval: 100 },
harnessVersion: "0.85.1",
readOnly: [FAKE],
threadTickMs: 3600000,
stopTimeoutMs: 1000,
log: (l) => logs.push(l),
copies: boot.copies ?? null,
});
const close = () =>
(closing ??= (async () => {
for (const fn of hooks) await fn(0);
await runtime.close();
})());
t.after(close);
await launcher.listen();
const human = runtime.broker.bindHuman({ business: businessId, human: f.doc.human, via: "cli", outsideAgent: true });
const call = (verb, args = {}) => runtime.broker.request(human, { verb, args });
const carry = async (request) => {
await launcher.tick();
return call("instance.request", { request });
};
const running = (instance) => launcher.registry.sessions.filter((s) => s.instance === instance);
return { boot, dataRoot, runtime, launcher, host, call, carry, running, logs, close };
}
// What the bus's trail holds: every event body, as text.
function trail(dataRoot) {
const db = new DatabaseSync(join(dataRoot, "bus", "bus.sqlite"), { readOnly: true });
try {
return db.prepare("SELECT kind, body FROM events").all().map((e) => `${e.kind} ${e.body}`).join("\n");
} finally {
db.close();
}
}
const refusedAt = (s, instance) => {
try {
s.call("instance.launch", { instance });
} catch (e) {
assert.ok(e instanceof BusError, String(e));
return e;
}
assert.fail(`${instance} launched`);
};
test("A3: a bound root copy launches as an instance of the organization; the excluded one and the root assistant aren't there", { skip, timeout: 60000 }, async (t) => {
const p = prepare(t);
const s = await start(t, p);
assert.deepEqual(Object.keys(s.boot.copies.copies).sort(), ["checker.stack", "gate", "researcher"]);
const done = await s.carry(s.call("instance.launch", { instance: "gate" }).request);
assert.equal(done.done.refusal, null, JSON.stringify(done));
const [run] = s.running("gate");
assert.equal(run.run, done.done.run);
// The grid read names it running from the bus's own claims.
await until(() => readFileSync(join(s.dataRoot, "launches", "acme", run.run, "runner.log"), "utf8").includes("claimed by run"));
const grid = s.call("instances");
assert.equal(grid.copies.gate.state, "instance");
assert.deepEqual(grid.copies.gate.running, [run.run]);
// sec is excluded from acme: no copy, no instance, and a launch names it unknown.
assert.equal(Object.hasOwn(grid.copies, "sec"), false);
assert.equal(refusedAt(s, "sec").code, "unknown-role");
});
test("A3: an unbound copy refuses at the verb by name and binding; no value reaches the refusal, the trail or the grid", { skip, timeout: 60000 }, async (t) => {
const p = prepare(t);
const s = await start(t, p);
const e = refusedAt(s, "researcher");
assert.equal(e.code, "credential-unbound");
assert.match(e.reason, /^researcher has no binding in acme for gitea$/);
const grid = JSON.stringify(s.call("instances"));
assert.match(grid, /"researcher":\{[^}]*"state":"unbound"/);
for (const text of [e.message, e.reason, grid, trail(s.dataRoot), s.logs.join("\n")]) {
assert.equal(text.includes(SECRET), false);
assert.equal(text.includes(p.root), false, "no host path");
}
// Nothing was asked of the launcher.
await s.launcher.tick();
assert.equal(s.running("researcher").length, 0);
});
test("A3: a project agent launches with only its own project's mapping; a project whose union refuses refuses every agent in it", { skip, timeout: 60000 }, async (t) => {
const p = prepare(t);
const s = await start(t, p);
const done = await s.carry(s.call("instance.launch", { instance: "checker.stack" }).request);
assert.equal(done.done.refusal, null, JSON.stringify(done));
const [run] = s.running("checker.stack");
const manifest = JSON.parse(readFileSync(join(run.runDir, "bundle", "manifest.json"), "utf8"));
assert.deepEqual(manifest.projects, ["stack"]);
// An org instance of the same role takes both code projects.
const coder = await s.carry(s.call("instance.launch", { instance: "coder" }).request);
assert.equal(coder.done.refusal, null, JSON.stringify(coder));
const [c] = s.running("coder");
assert.deepEqual(JSON.parse(readFileSync(join(c.runDir, "bundle", "manifest.json"), "utf8")).projects.sort(), ["stack", "web"]);
// web excludes an agent nothing defines: its union refuses, so its agents refuse, naming web.
const q = prepare(t, (doc) => {
doc.projects.web.agents = { checker: { definition: "merge-gate", vars: VARS }, lint: { definition: "security-reviewer", vars: VARS }, ghost: { exclude: true } };
return doc;
});
const r = await start(t, q);
for (const name of ["checker.web", "lint.web"]) {
const e = refusedAt(r, name);
assert.equal(e.code, "project-refused");
assert.match(e.reason, new RegExp(`^${name.replace(".", "\\.")} can't launch in acme: project web refuses its agents: .*ghost`));
}
// The other project's agent is untouched.
assert.equal((await r.carry(r.call("instance.launch", { instance: "checker.stack" }).request)).done.refusal, null);
// An inherited root copy and an organization instance still launch, without web's mount.
for (const instance of ["gate", "coder"]) {
const out = await r.carry(r.call("instance.launch", { instance }).request);
assert.equal(out.done.refusal, null, JSON.stringify(out));
const [inherited] = r.running(instance);
assert.deepEqual(projectsOf(inherited), ["stack"], instance);
}
});
const projectsOf = (run) => JSON.parse(readFileSync(join(run.runDir, "bundle", "manifest.json"), "utf8")).projects.sort();
const bundleOf = (run, file) => JSON.parse(readFileSync(join(run.runDir, "bundle", file), "utf8"));
test("A3: a project's exclude takes its mount from an inherited agent, and its narrow reaches the agent's real bundle", { skip, timeout: 60000 }, async (t) => {
const p = prepare(t, (doc) => {
doc.projects.stack.agents.gate = { vars: { "limits.tools": ["read"] } };
doc.projects.web.agents = { gate: { exclude: true } };
return doc;
});
const s = await start(t, p);
const done = await s.carry(s.call("instance.launch", { instance: "gate" }).request);
assert.equal(done.done.refusal, null, JSON.stringify(done));
const [run] = s.running("gate");
assert.deepEqual(projectsOf(run), ["stack"]);
const tools = bundleOf(run, "policy.json").tools;
assert.ok(tools.includes("read"), JSON.stringify(tools));
assert.equal(tools.includes("grep"), false, JSON.stringify(tools));
// The launch record names the skipped project and the code, never a reason text.
const skipped = readFileSync(launchLogFile(s.dataRoot, "acme"), "utf8").trim().split("\n").map((l) => JSON.parse(l)).filter((e) => e.event === "checkout-skipped");
assert.deepEqual(skipped.map(({ instance, project, reason }) => ({ instance, project, reason })), [{ instance: "gate", project: "web", reason: "project-refused" }]);
});
// Writes the organization file the way a human edit does, outside any writer verb.
const editOrg = (p, change) => {
const file = join(p.root, "config", "businesses", "acme.json");
const doc = JSON.parse(readFileSync(file, "utf8"));
change(doc);
writeFileSync(file, JSON.stringify(doc, null, 2) + "\n", { mode: 0o600 });
};
// A writer verb reloads the broker from the file. Keeping coder twice changes nothing and refuses,
// so a repeat reload drops the keep instead.
const reload = (s) => {
try { return s.call("veto.set", { revision: s.call("instances").revision, op: "keep", name: "coder" }); }
catch (e) { if (e.code !== "organization-no-change") throw e; }
return s.call("veto.set", { revision: s.call("instances").revision, op: "unkeep", name: "coder" });
};
// What the run's first turn said, from inside the real sandbox, about the web checkout.
async function webSeen(s, run) {
await until(() => existsSync(join(run.runDir, "runner.log")) && readFileSync(join(run.runDir, "runner.log"), "utf8").includes("claimed by run"));
s.call("message.send", { to: run.instance, class: "REQUEST", body: "fixture" });
const said = join(run.runDir, "turns", "0001.stderr");
await until(() => existsSync(said) && /WEB-(READABLE|ABSENT)/.test(readFileSync(said, "utf8")));
return readFileSync(said, "utf8").match(/WEB-(READABLE|ABSENT)/)[1];
}
test("A3: a checkout an earlier launch made is out of sight in the sandbox once its project excludes the agent, and stays on the host", { skip, timeout: 90000 }, async (t) => {
const p = prepare(t);
// The adapter runs inside the sandbox and says only whether the web checkout's file is readable.
writeFileSync(p.adapter, `if test -r "$MOSAIC_WORKSPACE/web/file.txt"; then echo WEB-READABLE >&2; else echo WEB-ABSENT >&2; fi\nexec '${process.execPath}' '${FAKE}'\n`);
const s = await start(t, p);
let out = await s.carry(s.call("instance.launch", { instance: "gate" }).request);
assert.equal(out.done.refusal, null, JSON.stringify(out));
let [run] = s.running("gate");
assert.deepEqual(projectsOf(run), ["stack", "web"]);
assert.equal(await webSeen(s, run), "READABLE");
await s.carry(s.call("instance.end", { instance: "gate" }).request);
await until(() => s.running("gate").length === 0);
for (const exclude of [{ gate: { exclude: true } }, { ghost: { exclude: true } }]) {
// The project excludes gate, then refuses its whole union.
editOrg(p, (d) => { d.projects.web.agents = exclude; });
reload(s);
out = await s.carry(s.call("instance.launch", { instance: "gate" }).request);
assert.equal(out.done.refusal, null, JSON.stringify(out));
[run] = s.running("gate");
assert.deepEqual(projectsOf(run), ["stack"]);
assert.equal(await webSeen(s, run), "ABSENT");
await s.carry(s.call("instance.end", { instance: "gate" }).request);
await until(() => s.running("gate").length === 0);
}
// The earlier work is still on the host, untouched.
assert.equal(readFileSync(join(s.dataRoot, "workspaces", "acme", "gate", "web", "file.txt"), "utf8"), "one\n");
});
test("A3: a project that excludes the agent while its launch is prepared refuses the launch; the bus's last admission asks too", { skip, timeout: 60000 }, async (t) => {
const p = prepare(t);
const s = await start(t, p);
const op = s.host.op;
let changed = false;
s.host.op = async (m) => {
const out = await op(m);
if (!changed && m.op === "request.admission" && m.project === "web") {
changed = true;
editOrg(p, (d) => { d.projects.web.agents = { gate: { exclude: true } }; });
}
return out;
};
const out = await s.carry(s.call("instance.launch", { instance: "gate" }).request);
assert.equal(changed, true);
assert.equal(out.done.refusal, "project-refused", JSON.stringify(out));
assert.equal(s.running("gate").length, 0);
// Once the bus has the change, its launch-boundary admission refuses the instance there too.
reload(s);
const revision = s.call("instances").revision;
assert.deepEqual(s.runtime.admission({ business: "acme", projects: ["stack", "web"], instance: "gate", revision }).code, "project-refused");
assert.equal(s.runtime.admission({ business: "acme", projects: ["stack"], instance: "gate", revision }).ready, true);
assert.equal(s.runtime.admission({ business: "acme", projects: ["stack", "web"] }).ready, true);
// The launch boundary takes an instance only with the revision its bundle came from.
assert.equal(s.runtime.admission({ business: "acme", projects: ["stack"], instance: "gate" }).code, "project-unknown");
assert.equal(s.runtime.admission({ business: "acme", projects: ["stack"], instance: "gate", revision: "0".repeat(64) }).code, "organization-changed");
});
// Launches gate with both projects, reads web from inside the sandbox, and ends the run, so the
// workspace keeps a web checkout an earlier launch made.
async function launchBoth(s) {
const out = await s.carry(s.call("instance.launch", { instance: "gate" }).request);
assert.equal(out.done.refusal, null, JSON.stringify(out));
const [run] = s.running("gate");
assert.deepEqual(projectsOf(run), ["stack", "web"]);
assert.equal(await webSeen(s, run), "READABLE");
await s.carry(s.call("instance.end", { instance: "gate" }).request);
await until(() => s.running("gate").length === 0);
}
const readsWeb = (p) => writeFileSync(p.adapter, `if test -r "$MOSAIC_WORKSPACE/web/file.txt"; then echo WEB-READABLE >&2; else echo WEB-ABSENT >&2; fi\nexec '${process.execPath}' '${FAKE}'\n`);
test("A3: a checkout of a project the bus doesn't admit now is out of sight too, not only an excluded one", { skip, timeout: 90000 }, async (t) => {
const p = prepare(t);
readsWeb(p);
const s = await start(t, p);
await launchBoth(s);
// web is declared and gate is in its union, but the bus doesn't admit the project for this launch.
const op = s.host.op;
s.host.op = async (m) => (m.op === "request.admission" && m.project === "web" ? { ready: false, revision: null, code: "project-pending" } : op(m));
const out = await s.carry(s.call("instance.launch", { instance: "gate" }).request);
assert.equal(out.done.refusal, null, JSON.stringify(out));
const [run] = s.running("gate");
assert.deepEqual(projectsOf(run), ["stack"]);
assert.equal(await webSeen(s, run), "ABSENT");
assert.equal(readFileSync(join(s.dataRoot, "workspaces", "acme", "gate", "web", "file.txt"), "utf8"), "one\n");
});
test("A3: a symlink or a file where a hidden checkout would be refuses the launch, and nothing on the host is touched", { skip, timeout: 90000 }, async (t) => {
const p = prepare(t);
readsWeb(p);
const s = await start(t, p);
await launchBoth(s);
const ws = join(s.dataRoot, "workspaces", "acme", "gate");
// The run's own workspace moves the checkout aside and leaves a link at web, then web excludes gate.
renameSync(join(ws, "web"), join(ws, "kept"));
symlinkSync(join(ws, "kept"), join(ws, "web"));
editOrg(p, (d) => { d.projects.web.agents = { gate: { exclude: true } }; });
reload(s);
let out = await s.carry(s.call("instance.launch", { instance: "gate" }).request);
assert.equal(out.done.refusal, "checkout-unsafe", JSON.stringify(out));
assert.equal(s.running("gate").length, 0);
assert.equal(lstatSync(join(ws, "web")).isSymbolicLink(), true);
assert.equal(readFileSync(join(ws, "kept", "file.txt"), "utf8"), "one\n");
// A plain file there refuses the same way.
renameSync(join(ws, "web"), join(ws, "link"));
writeFileSync(join(ws, "web"), "not a checkout\n");
out = await s.carry(s.call("instance.launch", { instance: "gate" }).request);
assert.equal(out.done.refusal, "checkout-unsafe", JSON.stringify(out));
assert.equal(readFileSync(join(ws, "web"), "utf8"), "not a checkout\n");
});
test("A3: a narrow that lands after the launcher's recheck and before the bus's last admission refuses the launch; the next one takes it", { skip, timeout: 90000 }, async (t) => {
for (const [instance, narrow, after] of [
// An inherited agent's narrow in its project.
["gate", (d) => { d.projects.stack.agents.gate = { vars: { "limits.tools": ["read"] } }; }, "launches"],
// A project agent's define: its vars change after boot.
["checker.stack", (d) => { d.projects.stack.agents.checker.vars["limits.tools"] = ["read"]; }, "project-refused"],
]) {
const p = prepare(t);
const s = await start(t, p);
const op = s.host.op;
let landed = false;
// The narrow lands after every launcher read, just before the bus's last admission answers.
s.host.op = async (m) => {
if (!landed && m.op === "request.admission" && m.projects !== undefined) { landed = true; editOrg(p, narrow); reload(s); }
return op(m);
};
let out = await s.carry(s.call("instance.launch", { instance }).request);
assert.equal(landed, true, instance);
assert.equal(out.done.refusal, "organization-changed", `${instance} ${JSON.stringify(out)}`);
assert.equal(s.running(instance).length, 0, instance);
// The next launch resolves from the narrowed file.
if (after === "launches") {
out = await s.carry(s.call("instance.launch", { instance }).request);
assert.equal(out.done.refusal, null, JSON.stringify(out));
const tools = bundleOf(s.running(instance)[0], "policy.json").tools;
assert.ok(tools.includes("read"), JSON.stringify(tools));
assert.equal(tools.includes("grep"), false, JSON.stringify(tools));
} else {
assert.equal(refusedAt(s, instance).code, after, instance);
}
await s.close();
}
});
test("A3: a project define whose vars change after boot refuses until the next start rather than launch with the old ones", { skip, timeout: 90000 }, async (t) => {
const p = prepare(t);
let s = await start(t, p);
editOrg(p, (d) => { d.projects.stack.agents.checker.vars["limits.tools"] = ["read"]; });
reload(s);
const copy = s.call("instances").copies["checker.stack"];
assert.equal(copy.state, "refused");
assert.match(copy.reason, /^project stack changed checker's vars since the bus started/);
assert.equal(refusedAt(s, "checker.stack").code, "project-refused");
await s.close();
s = await start(t, p);
const out = await s.carry(s.call("instance.launch", { instance: "checker.stack" }).request);
assert.equal(out.done.refusal, null, JSON.stringify(out));
const tools = bundleOf(s.running("checker.stack")[0], "policy.json").tools;
assert.ok(tools.includes("read"), JSON.stringify(tools));
assert.equal(tools.includes("grep"), false, JSON.stringify(tools));
});
test("A3: a collision a project brings in after boot refuses the copy it collides with, naming both claimants", { skip, timeout: 60000 }, async (t) => {
const p = prepare(t);
const rootFile = join(p.root, "config", "agents.json");
const root = JSON.parse(readFileSync(rootFile, "utf8"));
root.agents["new.stack"] = { definition: "security-reviewer", vars: VARS };
writeFileSync(rootFile, JSON.stringify(root));
const s = await start(t, p);
assert.equal(s.call("instances").copies["new.stack"].state, "instance");
editOrg(p, (d) => { d.projects.stack.agents.new = { definition: "merge-gate", vars: VARS }; });
reload(s);
const copy = s.call("instances").copies["new.stack"];
assert.equal(copy.state, "refused");
assert.deepEqual(copy.claims, [{ name: "new.stack", project: null }, { name: "new", project: "stack" }]);
assert.equal(copy.reason, "the instance name new.stack is claimed by root agent new.stack and project stack's new");
assert.equal(refusedAt(s, "new.stack").code, "copy-refused");
// Taking the project's define back out frees the root copy again.
editOrg(p, (d) => { delete d.projects.stack.agents.new; });
reload(s);
assert.equal(s.call("instances").copies["new.stack"].state, "instance");
});
test("A3: an organization instance written over a root copy after boot launches as itself, with its own role", { skip, timeout: 60000 }, async (t) => {
const p = prepare(t);
const s = await start(t, p);
assert.equal(s.call("instances").copies.gate.state, "instance");
const file = join(p.root, "config", "businesses", "acme.json");
const doc = JSON.parse(readFileSync(file, "utf8"));
doc.roles.gate = { definition: "security-reviewer", vars: VARS };
writeFileSync(file, JSON.stringify(doc, null, 2) + "\n", { mode: 0o600 });
// A writer verb reloads the broker from the file, as any human edit would.
const { revision } = s.call("instances");
s.call("veto.set", { revision, op: "keep", name: "coder" });
assert.equal(Object.hasOwn(s.call("instances").copies ?? {}, "gate"), false);
const done = await s.carry(s.call("instance.launch", { instance: "gate" }).request);
assert.equal(done.done.refusal, null, JSON.stringify(done));
const [run] = s.running("gate");
assert.equal(bundleOf(run, "manifest.json").definition, "security-reviewer");
});
test("A3: two organizations each launch their own gate copy; runs, sessions and trails stay under each one's data root", { skip, timeout: 90000 }, async (t) => {
const p = prepare(t);
const beta = businessDoc(p.root, "beta");
for (const r of Object.values(beta.roles)) r.vars = { ...VARS, ...r.vars, model: r.vars?.model ?? "claude-sonnet-5-5" };
beta.roles.chief.vars = { harness: "pi", model: "claude-opus-5-5", thinking: "high" };
beta.launch = launchDoc("chief", ["coder"], { opus: 1, sonnet: 4 });
beta.plugins = { ...beta.plugins, harness: ["pi", "claude-code"] };
writeJson(join(p.root, "config", "businesses", "beta.json"), beta);
const a = await start(t, p);
mkdirSync(join(p.root, "ob"), { mode: 0o700 });
const b = await start(t, p, { businessId: "beta", orgRoot: join(p.root, "ob") });
const runs = {};
for (const [id, s] of [["acme", a], ["beta", b]]) {
const done = await s.carry(s.call("instance.launch", { instance: "gate" }).request);
assert.equal(done.done.refusal, null, JSON.stringify(done));
const [run] = s.running("gate");
runs[id] = run;
assert.ok(run.runDir.startsWith(join(s.dataRoot, "launches", id) + "/"), run.runDir);
await until(() => existsSync(join(run.runDir, "session")));
await until(() => readFileSync(join(run.runDir, "runner.log"), "utf8").includes("claimed by run"));
assert.deepEqual(s.call("instances").copies.gate.running, [run.run]);
}
assert.notEqual(runs.acme.run, runs.beta.run);
// Neither bus, trail or data root holds the other's run.
for (const [mine, other, s] of [["acme", "beta", a], ["beta", "acme", b]]) {
assert.equal(trail(s.dataRoot).includes(runs[other].run), false, mine);
assert.equal(existsSync(join(s.dataRoot, "launches", other)), false, mine);
assert.equal(readdirSync(s.dataRoot, { recursive: true }).join("\n").includes(runs[other].run), false, mine);
}
assert.equal(b.running("gate").some((r) => r.run === runs.acme.run), false);
});
test("A3: a root file change while the bus runs reaches no relaunch or reload; the next boot takes it", { skip, timeout: 90000 }, async (t) => {
const p = prepare(t);
const s = await start(t, p);
const rootFile = join(p.root, "config", "agents.json");
writeJson(rootFile, { agentsVersion: 1, agents: { fresh: { definition: "merge-gate", vars: VARS } } }, 0o600);
// A launch, a relaunch and a reload all keep the boot's copy set.
for (let i = 0; i < 2; i++) {
const done = await s.carry(s.call("instance.launch", { instance: "gate" }).request);
assert.equal(done.done.refusal, null, JSON.stringify(done));
await until(() => s.running("gate").length === 1 && readFileSync(join(s.running("gate")[0].runDir, "runner.log"), "utf8").includes("claimed by run"));
const { revision } = s.call("instances");
s.call("veto.set", { revision, op: i ? "unkeep" : "keep", name: "coder" });
assert.deepEqual(Object.keys(s.call("instances").copies).sort(), ["checker.stack", "gate", "researcher"]);
assert.equal(refusedAt(s, "fresh").code, "unknown-role");
await s.carry(s.call("instance.end", { instance: "gate" }).request);
await until(() => s.running("gate").length === 0);
}
await s.close();
// A restart reads the file again.
const next = await start(t, p);
assert.deepEqual(Object.keys(next.boot.copies.copies).sort(), ["checker.stack", "fresh"]);
assert.equal(refusedAt(next, "gate").code, "unknown-role");
assert.equal((await next.carry(next.call("instance.launch", { instance: "fresh" }).request)).done.refusal, null);
});
test("A3: the chief can't launch a copy; only the human or a thread names one", { skip, timeout: 60000 }, async (t) => {
const p = prepare(t);
const s = await start(t, p);
const chief = await s.host.bindLaunch({ business: "acme", role: "chief", run: "chief-test", harness: "pi", pid: process.pid, startTime: startTimeOf(process.pid) });
await new Client({ path: s.runtime.path, cap: chief.cap }).call("role.claim");
for (const instance of ["gate", "checker.stack"]) {
const out = await s.launcher.fromSession(chief.cap, instance);
assert.deepEqual(out, { ok: false, error: "instance-not-listed" }, instance);
}
const unbound = await s.launcher.fromSession(chief.cap, "researcher");
assert.deepEqual(unbound, { ok: false, error: "credential-unbound" });
assert.equal(s.launcher.registry.sessions.length, 0);
});
test("A3: a second organization boots its own copies from the same root file and shares no record, request or session with the first", { skip, timeout: 60000 }, async (t) => {
const p = prepare(t);
const beta = businessDoc(p.root, "beta");
for (const r of Object.values(beta.roles)) r.vars = { ...VARS, ...r.vars, model: r.vars?.model ?? "claude-sonnet-5-5" };
beta.roles.chief.vars = { harness: "pi", model: "claude-opus-5-5", thinking: "high" };
beta.launch = launchDoc("chief", ["coder"], { opus: 1, sonnet: 4 });
beta.plugins = { ...beta.plugins, harness: ["pi", "claude-code"] };
writeJson(join(p.root, "config", "businesses", "beta.json"), beta);
const a = await start(t, p);
mkdirSync(join(p.root, "ob"), { mode: 0o700 });
const b = await start(t, p, { businessId: "beta", orgRoot: join(p.root, "ob") });
assert.notEqual(a.dataRoot, b.dataRoot);
assert.equal(a.boot.copies.org, "acme");
assert.equal(b.boot.copies.org, "beta");
// beta isn't excluded from sec, and has no project agent of acme's.
assert.deepEqual(Object.keys(b.boot.copies.copies).sort(), ["gate", "researcher", "sec"]);
const { request } = a.call("instance.launch", { instance: "gate" });
const done = await a.carry(request);
assert.equal(done.done.refusal, null, JSON.stringify(done));
const [run] = a.running("gate");
// beta's bus holds none of it: no request, no claim, no run, no session.
assert.throws(() => b.call("instance.request", { request }), BusError);
assert.deepEqual(b.call("instances").copies.gate.running, []);
assert.equal(trail(b.dataRoot).includes(run.run), false);
assert.equal(existsSync(join(b.dataRoot, "launches", "acme")), false);
const sessions = (root) => existsSync(join(root, "sessions")) ? readdirSync(join(root, "sessions"), { recursive: true }).join("\n") : "";
assert.equal(sessions(b.dataRoot).includes(run.run), false);
assert.equal(b.running("gate").length, 0);
});
@@ -0,0 +1,151 @@
// #1873 A2: `mosaic agents root` and `mosaic project agents`, end to end through scripts/mosaic on a
// bootstrapped named instance (the org CLI's own setup, org.test.mjs).
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { tmpdir } from 'node:os';
import { spawnSync } from 'node:child_process';
const repo = resolve(import.meta.dirname, '../../..');
const SECRET = 'sk-test-NOT-A-SECRET-1234';
const ABSENT = 'e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855';
function instance() {
const home = mkdtempSync(join(tmpdir(), 'a2cli-'));
const config = join(home, '.config/mosaic-atwo/config.json');
const env = { PATH: process.env.PATH, LANG: 'C.UTF-8', TMPDIR: process.env.TMPDIR ?? tmpdir(), HOME: home, MOSAIC_CONFIG: config };
const boot = spawnSync(join(repo, 'scripts/bootstrap.sh'), ['--instance', 'atwo'], { cwd: repo, env, encoding: 'utf8' });
assert.equal(boot.status, 0, boot.stderr);
const run = (args, extra = {}) => spawnSync(join(repo, 'scripts/mosaic'), args, { cwd: repo, env: { ...env, ...extra }, encoding: 'utf8' });
const ok = (...args) => { const r = run(args); assert.equal(r.status, 0, `${args.join(' ')}: ${r.stderr}`); return r.stdout; };
const file = join(home, 'alpha.json');
ok('org', 'template', 'render', 'alpha', '--fixture', 'local', '--output', file);
ok('org', 'create', 'alpha', '--from', file);
const project = Object.keys(JSON.parse(readFileSync(join(home, '.config/mosaic-atwo/businesses/alpha.json'), 'utf8')).projects)[0];
return { home, env, run, ok, project, configDir: join(home, '.config/mosaic-atwo') };
}
test('agents root list, set, remove: each write prints its receipt, values stay out of receipts and refusals', () => {
const { run, ok, configDir } = instance();
assert.deepEqual(JSON.parse(ok('agents', 'root', 'list', '--json')), { revision: ABSENT, state: 'absent', agents: [] });
assert.match(ok('agents', 'root', 'list'), /^root agents: none \(no file\)/);
const added = JSON.parse(ok('agents', 'root', 'set', 'researcher', '--definition', 'research', '--var', 'model=claude-opus-5-5'));
assert.equal(added.verb, 'root.agent.set'); assert.equal(added.agent, 'researcher'); assert.deepEqual(added.changed, ['added']);
assert.equal(added.revisionBefore, ABSENT);
const patched = run(['agents', 'root', 'set', 'researcher', '--var', 'limits.network=none', '--exclude-orgs', 'home']);
assert.equal(patched.status, 0, patched.stderr);
assert.deepEqual(JSON.parse(patched.stdout).changed, ['vars.limits.network', 'excludeOrgs']);
assert.ok(!patched.stdout.includes('none') && !patched.stdout.includes('claude-opus'));
const listed = JSON.parse(ok('agents', 'root', 'list', '--json'));
assert.equal(listed.state, 'present');
assert.deepEqual(listed.agents, [{ name: 'researcher', definition: 'research', model: 'claude-opus-5-5', varKeys: ['limits.network'], excludeOrgs: ['home'] }]);
// A stale --revision refuses and the file is unchanged.
const before = readFileSync(join(configDir, 'agents.json'), 'utf8');
const stale = run(['agents', 'root', 'set', 'researcher', '--var', 'limits.network=api-only', '--revision', added.revisionAfter]);
assert.equal(stale.status, 2); assert.match(stale.stderr, /revision-conflict/);
// An invalid value refuses with the key named and the value absent.
const bad = run(['agents', 'root', 'set', 'researcher', '--var', `model=${SECRET.repeat(10)}`]);
assert.equal(bad.status, 2); assert.match(bad.stderr, /agents-invalid: .*model/); assert.ok(!bad.stderr.includes(SECRET), bad.stderr);
const badJson = run(['agents', 'root', 'set', 'researcher', '--var-json', `limits.tools=${SECRET}`]);
assert.equal(badJson.status, 4); assert.match(badJson.stderr, /var limits\.tools is not valid JSON/); assert.ok(!badJson.stderr.includes(SECRET));
const chief = run(['agents', 'root', 'set', 'boss', '--definition', 'chief-of-staff']);
assert.equal(chief.status, 2); assert.match(chief.stderr, /chief-of-staff can't be a root agent/);
assert.equal(readFileSync(join(configDir, 'agents.json'), 'utf8'), before);
// An agent run is refused before anything is read.
for (const marker of ['MOSAIC_RUN_ID', 'MOSAIC_BUS_CAP', 'MOSAIC_AGENT_RUN']) {
const inside = run(['agents', 'root', 'remove', 'researcher'], { [marker]: '1' });
assert.equal(inside.status, 3, `${marker}: ${inside.stderr}`);
}
assert.equal(readFileSync(join(configDir, 'agents.json'), 'utf8'), before);
const removed = JSON.parse(ok('agents', 'root', 'remove', 'researcher'));
assert.equal(removed.verb, 'root.agent.remove'); assert.deepEqual(removed.changed, ['removed']);
const receipts = readFileSync(join(configDir, 'agents-receipts.jsonl'), 'utf8');
assert.equal(receipts.trim().split('\n').length, 3);
assert.ok(!receipts.includes('claude-opus') && !receipts.includes(SECRET));
// Usage refusals never echo option values.
const unknown = run(['agents', 'root', 'set', 'researcher', `--${SECRET}`]);
assert.equal(unknown.status, 4); assert.ok(!unknown.stderr.includes(SECRET));
});
test('a filesystem failure prints its code and a fixed line, never a host path', () => {
const { run, configDir, home } = instance();
// A directory where the receipt file goes: opening it fails EISDIR, whose message holds the path.
mkdirSync(join(configDir, 'agents-receipts.jsonl'));
const r = run(['agents', 'root', 'set', 'researcher', '--definition', 'research']);
assert.equal(r.status, 2, r.stderr);
assert.match(r.stderr, /EISDIR: the operation failed; the error text is not shown/);
assert.ok(!r.stderr.includes(home), r.stderr);
assert.equal(existsSync(join(configDir, 'agents.json')), false);
});
test('a missing, malformed or unclaimed instance config refuses before anything is read or written', () => {
const verbs = [['agents', 'root', 'set', 'researcher', '--definition', 'research'], ['agents', 'root', 'list'], ['project', 'agents', 'work', '--org', 'alpha', 'exclude', 'coder']];
const check = (env, dir, why) => {
for (const args of verbs) {
const r = spawnSync(join(repo, 'scripts/mosaic'), args, { cwd: repo, env, encoding: 'utf8' });
assert.equal(r.status, 3, `${why} ${args.join(' ')}: ${r.stderr}`); assert.match(r.stderr, /system config problem/);
}
assert.equal(existsSync(join(dir, 'agents.json')), false, why);
assert.equal(existsSync(join(dir, 'agents-receipts.jsonl')), false, why);
};
// Missing: the instance's config directory exists, its config.json does not.
const home = mkdtempSync(join(tmpdir(), 'a2cfg-'));
const dir = join(home, '.config/mosaic-atwo');
mkdirSync(dir, { recursive: true, mode: 0o700 });
const env = { PATH: process.env.PATH, LANG: 'C.UTF-8', TMPDIR: process.env.TMPDIR ?? tmpdir(), HOME: home, MOSAIC_CONFIG: join(dir, 'config.json') };
check(env, dir, 'missing');
writeFileSync(join(dir, 'config.json'), '{', { mode: 0o600 });
check(env, dir, 'malformed');
// Unclaimed: a bootstrapped instance whose data root marker names another instance.
const booted = instance();
const { dataRoot } = JSON.parse(readFileSync(booted.env.MOSAIC_CONFIG, 'utf8'));
writeFileSync(join(dataRoot, 'instance.json'), JSON.stringify({ instanceVersion: 1, name: 'other' }));
check(booted.env, booted.configDir, 'marker');
});
test('the default instance refuses the agent level verbs', () => {
const home = mkdtempSync(join(tmpdir(), 'a2def-'));
const env = { PATH: process.env.PATH, LANG: 'C.UTF-8', TMPDIR: process.env.TMPDIR ?? tmpdir(), HOME: home };
for (const args of [['agents', 'root', 'list'], ['project', 'agents', 'work', '--org', 'alpha', 'list']]) {
const r = spawnSync(join(repo, 'scripts/mosaic'), args, { cwd: repo, env, encoding: 'utf8' });
assert.equal(r.status, 2, r.stderr); assert.match(r.stderr, /named instance/);
}
assert.equal(existsSync(join(home, '.config/mosaic-dev/agents.json')), false);
});
test('project agents: set, narrow, exclude and remove print receipts; a widening narrow and the chief refuse', () => {
const { run, ok, project, home } = instance();
ok('agents', 'root', 'set', 'researcher', '--definition', 'research');
ok('agents', 'root', 'set', 'scout', '--definition', 'research');
const p = (...args) => ['project', 'agents', project, '--org', 'alpha', ...args];
assert.deepEqual(JSON.parse(ok(...p('list', '--json'))).agents, []);
const defined = JSON.parse(ok(...p('set', 'checker', '--definition', 'fact-checker', '--var', 'model=claude-sonnet-5-5')));
assert.deepEqual({ ...defined, revisionBefore: undefined, revisionAfter: undefined, transaction: undefined },
{ verb: 'project.agents.set', organization: 'alpha', project, agent: 'checker', form: 'define', revisionBefore: undefined, revisionAfter: undefined, transaction: undefined });
assert.notEqual(defined.revisionBefore, defined.revisionAfter);
assert.ok(!JSON.stringify(defined).includes(home), 'no host path in the receipt');
assert.equal(JSON.parse(ok(...p('narrow', 'researcher', '--var', 'limits.network=none'))).form, 'narrow');
assert.equal(JSON.parse(ok(...p('exclude', 'scout'))).form, 'exclude');
assert.deepEqual(JSON.parse(ok(...p('list', '--json'))).agents, [
{ name: 'checker', form: 'define', definition: 'fact-checker', varKeys: ['model'] },
{ name: 'researcher', form: 'narrow', varKeys: ['limits.network'] },
{ name: 'scout', form: 'exclude' },
]);
const file = join(home, '.config/mosaic-atwo/businesses/alpha.json');
const before = readFileSync(file, 'utf8');
const widen = run(p('narrow', 'scout', '--var', `model=${SECRET}`));
assert.equal(widen.status, 2); assert.match(widen.stderr, /can only narrow limits\.tools, limits\.network, limits\.authority, not "model"/); assert.ok(!widen.stderr.includes(SECRET));
const chief = run(p('exclude', 'chief'));
assert.equal(chief.status, 2); assert.match(chief.stderr, /project-agents-invalid/);
const stale = run(p('remove', 'checker', '--revision', defined.revisionBefore));
assert.equal(stale.status, 2); assert.match(stale.stderr, /revision-conflict/);
const inside = run(p('remove', 'checker'), { MOSAIC_RUN_ID: 'r1' });
assert.equal(inside.status, 3);
assert.equal(readFileSync(file, 'utf8'), before);
for (const name of ['checker', 'researcher', 'scout']) assert.equal(JSON.parse(ok(...p('remove', name))).form, 'remove');
assert.deepEqual(JSON.parse(ok(...p('list', '--json'))).agents, []);
const missing = run(['project', 'agents', 'nope', '--org', 'alpha', 'exclude', 'scout']);
assert.equal(missing.status, 2); assert.match(missing.stderr, /project-not-found/);
});
+17 -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/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/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"`);
@@ -148,6 +148,8 @@ test("the library install writes the business file, personas and slot dir, makes
const x = world(t);
const r = install(x);
assert.equal(r.code, 0, r.out);
assert.equal(mode(join(x.dataRoot, '.deployed')), 0o444);
assert.deepEqual(JSON.parse(readFileSync(join(x.dataRoot, '.deployed'), 'utf8')), { instance: 'dev' });
assert.equal(mode(x.business), 0o600);
const doc = JSON.parse(readFileSync(x.business, "utf8"));
for (const [instance, name] of [["bean", "Bean"], ["chet", "Chet"]]) {
@@ -494,6 +496,9 @@ function dependencyFreeClone(t) {
for (const file of ["case2-install.sh", "case2-persona-import.mjs", "selector.sh"]) {
cpSync(join(REPO, "scripts", file), join(clone, "scripts", file));
}
mkdirSync(join(clone, 'packages/cli/src'), { recursive: true });
cpSync(join(REPO, 'packages/cli/src/deployment.mjs'), join(clone, 'packages/cli/src/deployment.mjs'));
writeJson(x.config, { dataRoot: x.dataRoot });
// Isolate the persona step; bootstrap and business validation are covered above.
script(join(clone, "scripts", "bootstrap.sh"), "exit 0");
script(join(clone, "scripts", "mosaic"), "exit 0");
@@ -565,3 +570,14 @@ test('row334 install Next tells root to select proxy only when configured', t =>
assert.equal(r.code, 0, r.out);
assert.match(r.out, /Next: root runs case2-unit.sh[^\n]+add --proxy when the config has a proxy section/);
});
test('M3 install Next names the SSO, accounts-init and dedicated-uid steps before root re-pins with --sso', t => {
const x = world(t), r = install(x);
assert.equal(r.code, 0, r.out);
const done = r.out.split('\n').findIndex(l => l.startsWith('case2-install: done. .deployed names this instance'));
assert.ok(done >= 0, r.out);
assert.match(r.out.split('\n')[done], /in order \(run sheet section 7\): prepare console\.sso and its client secret slots in .+; a human runs `mosaic accounts init` as [^;]+; [^ ]+ stays a dedicated uid with a non-login shell, the only member of its primary group\.$/);
assert.match(r.out.split('\n')[done + 1], /^case2-install: Next: root runs case2-unit\.sh --copy [0-9a-f]{40} --business \S+ with --sso in the serve flags \(factory: --sso --send;/);
// The installer names the human step; it never runs it.
assert.equal(existsSync(join(x.configDir, 'accounts.json')), false);
});
+258 -3
View File
@@ -66,7 +66,7 @@ function hub(t, { uid = UID } = {}) {
const bin = join(root, "bin");
const log = join(root, "systemctl.log");
script(join(bin, "getent"), `case "$1" in
passwd) [ "$2" = mosaic ] && echo "mosaic:x:${uid}:${GID}::${home}:/bin/bash" ;;
passwd) { [ -z "$2" ] || [ "$2" = mosaic ]; } && echo "mosaic:x:${uid}:${GID}::${home}:/usr/sbin/nologin" ;;
group) [ "$2" = "${GID}" ] && echo "mosaic:x:${GID}:" ;;
*) exit 2 ;;
esac`);
@@ -364,7 +364,7 @@ test("the unit takes --org or --business on either side of --, renders --busines
test("the unit refuses a bad selector in either scope, and an outer and serve pair that differ, before writing a unit", (t) => {
const h = hub(t);
const serve = ["--port", "443", "--business", "acme"];
const usage = "usage: case2-unit.sh --user NAME --home DIR --business ID --copy ID --node PATH [--profile factory|library] [--proxy] [--pm] [--claude PATH] -- SERVE FLAGS\n";
const usage = "usage: case2-unit.sh [--check|--check-new] --user NAME --home DIR --business ID --copy ID --node PATH [--profile factory|library] [--proxy] [--pm] [--claude PATH] -- SERVE FLAGS\n";
const head = ["--user", "mosaic", "--home", h.home, "--copy", COPY_A, "--node", USR_NODE, "--profile", "factory"];
for (const [label, argv, code, err] of [
["outer unequal, old first", [...head, "--business", "acme", "--org", "beta", "--", ...serve], 4, "case2-unit: --business acme and --org beta conflict\n"],
@@ -415,6 +415,8 @@ test("the read-only library profile explicitly refuses --send before writing a u
test("PATH starts with the --node directory, for a node outside the default list and for /usr/bin/node", (t) => {
const h = hub(t);
const node = outsideNode();
// This metadata-only test uses a root-owned executable as a path stand-in.
writeFileSync(join(h.root, 'query-output'), JSON.stringify({ dataRoot: h.dataRoot, socket: true })); writeFileSync(join(h.root, 'query-status'), '0');
assert.equal(unit(h, { node }).code, 0);
for (const f of [busFile(h), webuiFile(h)]) assert.deepEqual(lines(read(f), "Environment"), [`PATH=${dirname(node)}:/usr/local/bin:/usr/bin:/bin`]);
assert.match(lines(read(webuiFile(h)), "ExecStart")[0], new RegExp(`^${node} `));
@@ -550,6 +552,7 @@ test("a --node that is relative, not root-owned or under <home> refuses", (t) =>
test("a re-install with a different --node or different serve flags refuses", (t) => {
const h = hub(t);
assert.equal(unit(h).code, 0);
writeFileSync(join(h.root, 'query-output'), JSON.stringify({ dataRoot: h.dataRoot, socket: true })); writeFileSync(join(h.root, 'query-status'), '0');
// A new node moves PATH too, so the installed unit fails its own Environment= check first.
refuses(unit(h, { node: outsideNode() }), /installed .*: the Environment= lines must be exactly/);
const flags = libraryFlags(h).map((f) => (f === "melanie" ? "someone" : f));
@@ -705,7 +708,9 @@ test("#415 factory --sso refuses before any unit write when the stack user's con
noUnits(h);
});
test("#416 library still refuses --sso, alone and with --send, before writing a unit", (t) => {
// M3 (#1873; Jason's ruling 2026-11-10): only an unstamped library copy keeps proxy preview. The
// stamped copies below sign in through SSO accounts.
test("#416 an unstamped library copy still refuses --sso, alone and with --send, before writing a unit", (t) => {
const h = hub(t);
ssoConfig(h);
refuses(unit(h, { flags: [...libraryFlags(h), "--sso"] }), /refusing --sso: the Console signs people in through the proxy/);
@@ -713,6 +718,233 @@ test("#416 library still refuses --sso, alone and with --send, before writing a
noUnits(h);
});
// ---- M3 (#1873): stamped copies ----
// A stamped copy (stack-copy.sh's read-only .stack-build.json) serves only with --sso, console.sso,
// an accounts.json that loads and a .deployed naming the default instance. The render asks the
// stack user's copy through the bounded sudo query, with the copy's real deployment.mjs and
// organization accounts.mjs, and refuses before any write, naming every missing step in order.
// No real host, config or credential: the issuer is on .invalid and nothing connects to it.
const ISSUER = "https://idp.invalid/a";
const STAMP_ACCOUNTS = { accountsVersion: 1, revision: 1, users: [{ id: "u-000000000001", subjects: [{ provider: "a", sub: "owner" }], name: "Owner", instanceRole: "operator", state: "active", authGen: 1, created: "2026-11-10T00:00:00Z" }], memberships: [], invites: [] };
const accountsFile = (h) => join(h.home, ".config", "mosaic-dev", "accounts.json");
const deployedFile = (h) => join(h.dataRoot, ".deployed");
function stamp(h, id = COPY_A) {
const dir = copyDir(h, id);
cpSync(join(REPO, "packages/cli/src/deployment.mjs"), join(dir, "packages/cli/src/deployment.mjs"));
for (const pkg of ["organization", "seat"]) symlinkSync(join(REPO, "packages", pkg), join(dir, "packages", pkg));
writeFileSync(join(dir, ".stack-build.json"), JSON.stringify({ commit: id }) + "\n");
chmodSync(join(dir, ".stack-build.json"), 0o444);
}
const stampSso = (h) => writeJson(h.query, { dataRoot: h.dataRoot, console: { sso: { publicOrigin: "https://console.invalid", providers: [{ id: "a", issuer: ISSUER }] } } });
function stampAccounts(h, doc = STAMP_ACCOUNTS) {
writeFileSync(accountsFile(h), JSON.stringify(doc));
chmodSync(accountsFile(h), 0o600);
}
function stampDeployed(h, instance = "dev") {
writeFileSync(deployedFile(h), JSON.stringify({ instance }) + "\n");
chmodSync(deployedFile(h), 0o444);
}
function stamped(t, { sso = true, accounts = true, deployed = true } = {}) {
const h = hub(t);
stamp(h);
if (sso) stampSso(h);
if (accounts) stampAccounts(h);
if (deployed) stampDeployed(h);
return h;
}
const STAMP_LIBRARY = ["--port", "443", "--business", "acme", "--sso"];
const STAMP_FACTORY = ["--port", "443", "--business", "acme", "--tester-socket", "--sso", "--send"];
const STEP = {
deployed: (h) => `check or create .deployed: mosaic runs case2-install.sh, whose deployment step writes ${h.dataRoot}/.deployed`,
deployedBad: (h) => `check .deployed: ${h.dataRoot}/.deployed is invalid or names another instance; case2-install.sh's deployment step reports why and never replaces it`,
sso: (h) => `prepare SSO: console.sso in mosaic's system config ${h.home}/.config/mosaic-dev/config.json, with its client secret slots`,
accounts: (h) => `run \`mosaic accounts init\` as mosaic, a human verb: no ${accountsFile(h)}`,
accountsBad: (h) => `fix ${accountsFile(h)}: it does not load (its owner or mode, an active operator, or a subject console.sso does not name); a human fixes it with the accounts verbs`,
library: () => "add --sso to the serve flags, then re-pin",
factory: () => "add --sso --send to the serve flags, then re-pin",
};
// The exact refusal: one header line, then the steps numbered in the run sheet's order.
function refusesSteps(h, r, steps) {
assert.equal(r.code, 1, `expected a refusal, got ${r.code}: ${r.out}${r.err}`);
const want = [`case2-unit: refusing the units for ${COPY_A}: a stamped copy's Console starts only with SSO accounts; missing steps, in order:`, ...steps.map((s, i) => ` ${i + 1}. ${s(h)}`)].join("\n") + "\n";
assert.equal(r.err, want);
}
// Nothing the render must not write: no unit, no systemctl, and none of the prerequisites.
function wroteNothing(h, { accounts = false, deployed = false } = {}) {
noUnits(h);
assert.equal(existsSync(accountsFile(h)), accounts, "the render never writes accounts.json");
assert.equal(existsSync(deployedFile(h)), deployed, "the render never writes .deployed");
}
test("M3 a stamped library unit renders only with --sso and the prerequisites, and runs the Console without proxy preview", (t) => {
const h = stamped(t);
const r = unit(h, { flags: STAMP_LIBRARY });
assert.equal(r.code, 0, r.out + r.err);
const webui = read(webuiFile(h));
assert.deepEqual(lines(webui, "ExecStart"), [`${USR_NODE} ${copyDir(h)}/packages/webui/src/cli.mjs serve --port 443 --business acme --sso`]);
assert.doesNotMatch(webui.split("\n").filter((l) => !l.startsWith("#")).join("\n"), /--public-host|--auth-header|--allow-user|--proof-header|--proof-file|--send/);
assert.deepEqual(lines(read(busFile(h)), "ExecStart"), [`${copyDir(h)}/scripts/mosaic bus start %i`]);
// The prerequisites were asked as the stack user, through the copy's own modules.
const sudo = read(h.sudoLog);
assert.match(sudo, new RegExp(`^-u mosaic -H env -i HOME=${h.home} PATH=`, "m"));
assert.match(sudo, /a\.loadAccounts\(.*d\.assertDeployed\(s,dir\)/);
assert.ok(sudo.includes(`${copyDir(h)}/packages/organization/src/accounts.mjs`));
assert.match(r.out, /enabled and started mosaic-bus@acme\.service, then mosaic-webui\.service\n$/);
// The render read them and left each as it was.
assert.deepEqual(JSON.parse(read(accountsFile(h))), STAMP_ACCOUNTS);
assert.equal(statSync(deployedFile(h)).mode & 0o777, 0o444);
});
test("M3 a stamped library unit refuses proxy-preview admission, its proof and --send, before any write", (t) => {
const h = stamped(t);
for (const [flag, value] of [["--public-host", "books.example"], ["--auth-header", "X-Auth-User"], ["--allow-user", "melanie"], ["--proof-header", "X-Proxy-Proof"], ["--proof-file", h.proof]]) {
const r = unit(h, { flags: [...STAMP_LIBRARY, flag, value] });
assert.equal(r.code, 1, flag);
assert.equal(r.err, `case2-unit: refusing ${flag}: ${COPY_A} is a stamped copy, and proxy-preview admission and its proof do not apply to a stamped copy; its Console signs in through SSO accounts with --sso\n`);
}
// The whole old preview set refuses at its first flag, with or without --sso.
refuses(unit(h, { flags: libraryFlags(h) }), /refusing --public-host: .* is a stamped copy, and proxy-preview admission and its proof do not apply/);
refuses(unit(h, { flags: [...STAMP_LIBRARY, "--send"] }), /the library profile is read-only; refusing --send \(a stamped library Console signs in with --sso and stays read-only\)/);
wroteNothing(h, { accounts: true, deployed: true });
assert.equal(existsSync(h.sudoLog), false, "a flag refusal comes before any query");
});
test("M3 a stamped library unit without its prerequisites refuses before any write and names every missing step in order", (t) => {
const none = stamped(t, { sso: false, accounts: false, deployed: false });
refusesSteps(none, unit(none, { flags: ["--port", "443", "--business", "acme"] }), [STEP.deployed, STEP.sso, STEP.accounts, STEP.library]);
wroteNothing(none);
refusesSteps(none, unit(none, { flags: STAMP_LIBRARY }), [STEP.deployed, STEP.sso, STEP.accounts]);
wroteNothing(none);
// Each prerequisite alone.
for (const [name, opts, steps] of [
["no .deployed", { deployed: false }, [STEP.deployed]],
["no console.sso", { sso: false }, [STEP.sso]],
["no accounts.json", { accounts: false }, [STEP.accounts]],
]) {
const h = stamped(t, opts);
refusesSteps(h, unit(h, { flags: STAMP_LIBRARY }), steps);
wroteNothing(h, { accounts: opts.accounts !== false, deployed: opts.deployed !== false });
}
const flagless = stamped(t);
refusesSteps(flagless, unit(flagless, { flags: ["--port", "443", "--business", "acme"] }), [STEP.library]);
wroteNothing(flagless, { accounts: true, deployed: true });
});
test("M3 a stamped copy refuses an invalid .deployed or accounts.json with the step that names it", (t) => {
const cases = [
[".deployed names another instance", (h) => { rmSync(deployedFile(h)); stampDeployed(h, "other"); }, [STEP.deployedBad]],
[".deployed writable", (h) => chmodSync(deployedFile(h), 0o644), [STEP.deployedBad]],
[".deployed broken link", (h) => { rmSync(deployedFile(h)); symlinkSync(join(h.root, "missing"), deployedFile(h)); }, [STEP.deployedBad]],
["accounts without an active operator", (h) => stampAccounts(h, { ...STAMP_ACCOUNTS, users: [{ ...STAMP_ACCOUNTS.users[0], state: "disabled" }] }), [STEP.accountsBad]],
["accounts subject on an unconfigured provider", (h) => stampAccounts(h, { ...STAMP_ACCOUNTS, users: [{ ...STAMP_ACCOUNTS.users[0], subjects: [{ provider: "b", sub: "owner" }] }] }), [STEP.accountsBad]],
["accounts writable by group", (h) => chmodSync(accountsFile(h), 0o620), [STEP.accountsBad]],
["accounts broken link", (h) => { rmSync(accountsFile(h)); symlinkSync(join(h.root, "missing"), accountsFile(h)); }, [STEP.accountsBad]],
];
for (const [name, change, steps] of cases) {
for (const [profile, flags] of [["library", STAMP_LIBRARY], ["factory", STAMP_FACTORY]]) {
const h = stamped(t);
change(h);
refusesSteps(h, unit(h, { profile, flags }), steps);
noUnits(h);
}
}
});
test("M3 a stamped factory or Case 1 unit renders only with --sso --send and the prerequisites, and names each missing step", (t) => {
const h = stamped(t);
const r = unit(h, { profile: "factory", flags: STAMP_FACTORY });
assert.equal(r.code, 0, r.out + r.err);
assert.deepEqual(lines(read(webuiFile(h)), "ExecStart"), [`${USR_NODE} ${copyDir(h)}/packages/webui/src/cli.mjs serve ${STAMP_FACTORY.join(" ")}`]);
const none = stamped(t, { sso: false, accounts: false, deployed: false });
refusesSteps(none, unit(none, { profile: "factory" }), [STEP.deployed, STEP.sso, STEP.accounts, STEP.factory]);
wroteNothing(none);
refusesSteps(none, unit(none, { profile: "factory", flags: STAMP_FACTORY }), [STEP.deployed, STEP.sso, STEP.accounts]);
wroteNothing(none);
// A stamped copy without the flag pair names it as the last step, with the prerequisites present.
const flagless = stamped(t);
refusesSteps(flagless, unit(flagless, { profile: "factory" }), [STEP.factory]);
refusesSteps(flagless, unit(flagless, { profile: "factory", flags: [...factoryFlags(), "--send"] }), [STEP.factory]);
wroteNothing(flagless, { accounts: true, deployed: true });
// The pair stays a pair: --sso alone still refuses, as for an unstamped copy.
refuses(unit(flagless, { profile: "factory", flags: [...factoryFlags(), "--sso"] }), /refusing --sso without --send in the factory profile/);
noUnits(flagless);
});
test("M3 a stamped copy's query refuses on noise or a failed query, before any write", (t) => {
// The copy's accounts module stands in for the stack user's code: noise on stdout, a failed exit.
for (const [body, pattern] of [
['console.log("forged");export const loadAccounts=()=>{};', /stamped copy query has invalid facts/],
["process.exit(1);export const loadAccounts=()=>{};", /stamped copy query inner-command/],
]) {
const h = stamped(t);
const org = join(copyDir(h), "packages/organization");
rmSync(org);
mkdirSync(join(org, "src"), { recursive: true });
writeFileSync(join(org, "src/accounts.mjs"), body);
refuses(unit(h, { flags: STAMP_LIBRARY }), pattern);
noUnits(h);
}
});
// #1873 M3: stack-upgrade.sh checks each render before it stops anything. --check runs a render's
// checks, the installed units' included, and writes nothing; --check-new skips only the installed
// units' checks, for --next-args, which moves them aside before its render.
const checkArgv = (h, { mode = "--check", profile = "library", copy = COPY_A, flags } = {}) =>
[mode, "--user", "mosaic", "--home", h.home, "--business", "acme", "--copy", copy, "--node", USR_NODE, "--profile", profile, "--",
...(flags ?? (profile === "factory" ? factoryFlags() : libraryFlags(h)))];
const unitsNow = (h) => [busFile(h), webuiFile(h)].map((f) => (existsSync(f) ? read(f) : null));
const logNow = (h) => (existsSync(h.log) ? read(h.log) : "");
test("#1873 --check passes on a matching install and on a first install, refuses an edited unit, and never writes or calls systemctl", (t) => {
const h = hub(t);
const empty = unit(h, { argv: checkArgv(h) });
assert.equal(empty.code, 0, empty.out + empty.err);
assert.equal(empty.out, `case2-unit: check passed: the units for copy ${COPY_A} render, and the installed ones match them; nothing was written\n`);
noUnits(h);
assert.equal(unit(h).code, 0);
const units = unitsNow(h), log = logNow(h);
for (const copy of [COPY_A, COPY_B]) {
const r = unit(h, { argv: checkArgv(h, { copy }) });
assert.equal(r.code, 0, r.out + r.err);
assert.deepEqual(unitsNow(h), units, copy);
assert.equal(logNow(h), log, `${copy}: no systemctl call`);
}
// The installed-unit match stays: an edit, or other serve flags, refuses as the render would.
writeFileSync(busFile(h), units[0].replace(/^Description=.*$/m, "Description=edited"));
refuses(unit(h, { argv: checkArgv(h, { copy: COPY_B }) }), /differs from the render in more than the copy id/);
writeFileSync(busFile(h), units[0]);
const flags = libraryFlags(h).map((f) => (f === "melanie" ? "someone" : f));
refuses(unit(h, { argv: checkArgv(h, { copy: COPY_B, flags }) }), /installed .*: ExecStart= must be exactly once/);
assert.deepEqual(unitsNow(h), units);
assert.equal(logNow(h), log);
});
for (const profile of ["library", "factory"]) {
test(`#1873 an installed pre-M3 ${profile} unit: --check and the render refuse a stamped copy's SSO flags, and --check-new passes with the prerequisites, writing nothing`, (t) => {
const h = hub(t);
assert.equal(unit(h, { profile }).code, 0);
stamp(h, COPY_B);
stampSso(h);
stampAccounts(h);
stampDeployed(h);
const flags = profile === "factory" ? STAMP_FACTORY : STAMP_LIBRARY;
const units = unitsNow(h), log = logNow(h);
refuses(unit(h, { argv: checkArgv(h, { profile, copy: COPY_B, flags }) }), /installed .*: ExecStart= must be exactly once/);
refuses(unit(h, { profile, copy: COPY_B, flags }), /installed .*: ExecStart= must be exactly once/);
const r = unit(h, { argv: checkArgv(h, { mode: "--check-new", profile, copy: COPY_B, flags }) });
assert.equal(r.code, 0, r.out + r.err);
assert.equal(r.out, `case2-unit: check passed: the units for copy ${COPY_B} render as a first install; the installed ones were not compared; nothing was written\n`);
// --check-new still runs the stamped copy's checks: the old flags, or a missing prerequisite, refuse.
refuses(unit(h, { argv: checkArgv(h, { mode: "--check-new", profile, copy: COPY_B }) }), /a stamped copy's Console starts only with SSO accounts|is a stamped copy, and proxy-preview admission/);
rmSync(accountsFile(h));
refuses(unit(h, { argv: checkArgv(h, { mode: "--check-new", profile, copy: COPY_B, flags }) }), /run `mosaic accounts init` as mosaic, a human verb/);
assert.deepEqual(unitsNow(h), units);
assert.equal(logNow(h), log, "no systemctl call");
assert.equal(existsSync(accountsFile(h)), false, "the check never writes accounts.json");
assert.equal(statSync(deployedFile(h)).mode & 0o777, 0o444);
});
}
test("row253 factory refuses retired TCP and a value after the socket marker", (t) => {
const h = hub(t);
refuses(unit(h, { profile: "factory", flags: ['--port','443','--business','acme','--tester-port','7333'] }), /case2-unit.sh retires tester TCP; use --tester-socket/);
@@ -1320,3 +1552,26 @@ test('row334 proxy installed path rejects symlink and non-regular file', t => {
mkdirSync(proxyFile(h));
refuses(proxyUnit(h), /is not a regular file/);
});
// M3: the managed uid is a dedicated non-login service identity. These are
// database fixtures only: no real passwd/group or instance directories change.
test('M3 dedicated host account: login shells, other primary/group members and wrong directory owners refuse', t => {
for (const [kind, shell, extraPasswd, members] of [
['human login','/bin/bash','',''], ['interactive seat','/usr/bin/zsh','',''],
['another primary member','/usr/sbin/nologin',`human:x:${UID+1}:${GID}::/fictional:/bin/bash`,''],
['supplementary primary-group member','/usr/sbin/nologin','','human'],
]) {
const h = hub(t);
script(join(h.bin,'getent'), `case "$1" in
passwd) echo 'mosaic:x:${UID}:${GID}::${h.home}:${shell}'; [ -n "$2" ] || echo '${extraPasswd}';;
group) echo 'mosaic:x:${GID}:${members}';;
esac`);
const r = unit(h); assert.notEqual(r.code,0,kind); assert.match(r.err,/non-login|sole.member/,kind); assert.equal(existsSync(webuiFile(h)),false);
}
const h = hub(t); for (const flag of ['--dev-local-owner','--dev-insecure-bind','--dev-allow-host']) { const r = unit(h, { flags: [...libraryFlags(h), flag, '127.0.0.1'] }); assert.notEqual(r.code,0); assert.match(r.err,/dev-local-owner|serve flag/); }
});
test('M3 host config and data directories belong to the selected uid and both units use it', t => {
const h = hub(t); const ok = unit(h); assert.equal(ok.code,0,ok.err);
for (const file of [busFile(h),webuiFile(h)]) { assert.deepEqual(lines(read(file),'User'),['mosaic']); assert.deepEqual(lines(read(file),'Group'),['mosaic']); }
const wrong = hub(t,{uid:UID+1}); const r = unit(wrong); assert.notEqual(r.code,0); assert.match(r.err,/owned by.*uid|directory.*owner/);
});
@@ -0,0 +1,94 @@
// M3 executable boundary: copies and instances are fixtures only, no live ports.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { cpSync, mkdirSync, mkdtempSync, writeFileSync, chmodSync, rmSync, symlinkSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { fileURLToPath } from 'node:url';
import { fixture } from './helpers.mjs';
const REPO = fileURLToPath(new URL('../../../', import.meta.url));
const SHA = 'a'.repeat(40);
function setup(t) {
const root = mkdtempSync(join(tmpdir(), 'db-')); t.after(() => rmSync(root, { recursive: true, force: true }));
const copy = join(root, SHA); mkdirSync(copy);
for (const dir of ['packages', 'scripts', 'roles', 'adapters']) cpSync(join(REPO, dir), join(copy, dir), { recursive: true, filter: p => !p.split('/').some(x => ['tests', 'node_modules'].includes(x)) });
writeFileSync(join(copy, 'package.json'), '{"type":"module"}');
const stamp = join(copy, '.stack-build.json'); writeFileSync(stamp, JSON.stringify({ commit: SHA }), { mode: 0o444 }); chmodSync(stamp, 0o444);
const f = fixture(join(root, 'instance')); const home = join(root, 'home'); const dir = join(home, '.config/mosaic-test'); mkdirSync(dir, { recursive: true });
const config = join(dir, 'config.json'); cpSync(f.env.MOSAIC_CONFIG, config);
writeFileSync(join(f.dataRoot, 'instance.json'), JSON.stringify({ instanceVersion: 1, name: 'test' }));
const env = { ...process.env, HOME: home, MOSAIC_CONFIG: config };
const run = (args, from = copy) => spawnSync(process.execPath, [join(from, args[0]), ...args.slice(1)], { env, encoding: 'utf8', timeout: 5000 });
return { root, copy, stamp, f, env, config, dir, run };
}
const serve = flags => ['packages/webui/src/cli.mjs', 'serve', '--port', '0', ...flags];
test('stamped serve refuses dev owner, LAN flags, missing --sso and missing .deployed before listening', t => {
const s = setup(t);
for (const flags of [['--dev-local-owner'], ['--dev-insecure-bind','0.0.0.0'], ['--dev-allow-host','test.example'], []]) {
const r = s.run(serve(flags)); assert.equal(r.status, 2); assert.match(r.stderr, /stamped build/); assert.doesNotMatch(r.stdout, /Mosaic Console:/);
}
const r = s.run(serve(['--sso'])); assert.equal(r.status, 2); assert.match(r.stderr, /\.deployed/);
const bus = s.run(['packages/cli/src/cli.mjs','bus','start','acme','--dev-local-owner']); assert.notEqual(bus.status, 0); assert.match(bus.stderr, /stamped build/);
});
test('writable, linked, malformed, wrong-commit and directory stamps refuse every start', t => {
const s = setup(t);
for (const kind of ['writable','symlink','wrong','malformed','directory']) {
rmSync(s.stamp, { force: true, recursive: true });
if (kind === 'symlink') { const other = join(s.root, 'stamp'); writeFileSync(other, JSON.stringify({ commit: SHA }), { mode: 0o444 }); symlinkSync(other, s.stamp); }
else if (kind === 'directory') mkdirSync(s.stamp);
else { writeFileSync(s.stamp, kind === 'malformed' ? '{' : JSON.stringify({ commit: kind === 'wrong' ? 'b'.repeat(40) : SHA })); chmodSync(s.stamp, kind === 'writable' ? 0o644 : 0o444); }
for (const args of [serve([]), serve(['--sso']), ['packages/cli/src/cli.mjs','bus','start','acme']]) { const r = s.run(args); assert.notEqual(r.status, 0, kind); assert.match(r.stderr, /build stamp/, `${kind}: ${r.stderr}`); }
}
});
test('stamped serve requires 0444 metadata naming its instance and a loadable accounts file', t => {
const s = setup(t), file = join(s.f.dataRoot, '.deployed');
for (const [mode, instance] of [[0o644,'test'], [0o444,'other']]) {
writeFileSync(file, JSON.stringify({ instance })); chmodSync(file, mode);
const r = s.run(serve(['--sso'])); assert.equal(r.status, 2); assert.match(r.stderr, /\.deployed/);
}
chmodSync(file, 0o644); writeFileSync(file, JSON.stringify({ instance: 'test' })); chmodSync(file, 0o444);
const r = s.run(serve(['--sso'])); assert.equal(r.status, 2); assert.match(r.stderr, /accounts/);
});
test('checkout dev owner refuses accounts, SSO or deployment presence, including dangling links', t => {
const s = setup(t);
for (const name of ['accounts.json', '.deployed']) {
const file = join(name === 'accounts.json' ? s.dir : s.f.dataRoot, name); symlinkSync(join(s.root, 'missing'), file);
for (const args of [serve(['--dev-local-owner']), ['packages/cli/src/cli.mjs','bus','start','acme','--dev-local-owner']]) { const r = s.run(args, REPO); assert.notEqual(r.status, 0); assert.match(r.stderr, /dev-local-owner.*refuses/); }
rmSync(file);
}
const config = JSON.parse((awaitRead(s.config))); config.console = { sso: { publicOrigin: 'https://console.test', providers: [{ id:'pocket', issuer:'https://id.test',clientId:'test',clientSecret:{slot:'test'},algs:['RS256'],subjects:[{sub:'owner',name:'Owner'}] }] } };
writeFileSync(s.config, JSON.stringify(config));
for (const args of [serve(['--dev-local-owner']), ['packages/cli/src/cli.mjs','bus','start','acme','--dev-local-owner']]) { const r = s.run(args, REPO); assert.notEqual(r.status, 0); assert.match(r.stderr, /dev-local-owner.*refuses.*console.sso/); }
});
import { readFileSync as awaitRead } from 'node:fs';
test('deployment writer preserves matching metadata and refuses linked or foreign destinations', t => {
const s = setup(t), system = { dataRoot: s.f.dataRoot }, file = join(s.f.dataRoot, '.deployed');
rmSync(system.dataRoot, { recursive: true, force: true });
writeDeployment(system, s.dir); assert.deepEqual(JSON.parse(awaitRead(file)), { instance: 'test' });
assert.equal(statSync(system.dataRoot).mode & 0o777, 0o700);
assert.equal(statSync(file).mode & 0o777, 0o444); const inode = statSync(file).ino; writeDeployment(system, s.dir); assert.equal(statSync(file).ino, inode);
rmSync(file); symlinkSync(join(s.root, 'absent'), file); assert.throws(() => writeDeployment(system, s.dir), /\.deployed/); rmSync(file);
writeFileSync(file, JSON.stringify({ instance: 'other' }), { mode: 0o444 }); assert.throws(() => writeDeployment(system, s.dir), /\.deployed/);
});
import { statSync } from 'node:fs';
import { writeDeployment } from '../src/deployment.mjs';
test('valid stamped accounts Console starts with --sso and answers health on its own port zero', async t => {
const { spawn } = await import('node:child_process');
const { once } = await import('node:events');
const { fakeOidc } = await import('../../webui/tests/helpers/fake-oidc.mjs');
const s = setup(t), op = await fakeOidc(); t.after(() => op.close()); const iss = op.issuer('a');
symlinkSync(join(REPO, 'node_modules'), join(s.copy, 'node_modules'));
const config = JSON.parse(awaitRead(s.config)); config.console = { sso: { publicOrigin: 'https://console.test', providers: [{ id:'a',issuer:iss.issuer,clientId:iss.clientId,clientSecret:{slot:'a'},algs:['RS256'],subjects:[{sub:'owner',name:'Owner'}] }] } };
writeFileSync(s.config, JSON.stringify(config)); mkdirSync(join(s.dir, 'secrets/mosaic-stack'), { recursive: true, mode: 0o700 });
writeFileSync(join(s.dir, 'secrets/mosaic-stack/a'), iss.clientSecret, { mode: 0o600 });
writeFileSync(join(s.dir, 'accounts.json'), JSON.stringify({ accountsVersion:1,revision:1,users:[{id:'u-000000000001',subjects:[{provider:'a',sub:'owner'}],name:'Owner',instanceRole:'operator',state:'active',authGen:1,created:'2026-11-10T00:00:00Z'}], memberships:[],invites:[] }), { mode: 0o600 });
writeDeployment({ dataRoot: s.f.dataRoot }, s.dir); const ca = join(s.root, 'ca.pem'); writeFileSync(ca, op.cert);
const child = spawn(process.execPath, [join(s.copy,'packages/webui/src/cli.mjs'), 'serve','--port','0','--sso','--board','http://127.0.0.1:9'], { env: { ...s.env, NODE_EXTRA_CA_CERTS: ca } });
let out = '', err = ''; child.stderr.on('data', c => err += c);
t.after(async () => { if (child.exitCode === null && child.signalCode === null) { const done = once(child, 'close'); child.kill(); await done; } });
await new Promise((resolve, reject) => { const timer = setTimeout(() => reject(new Error('start timeout: '+err)), 10000); child.stdout.on('data', c => { out += c; if(out.includes('Ctrl-C')) {clearTimeout(timer);resolve();} }); child.on('exit', () => {clearTimeout(timer);reject(new Error(err));}); });
const port = Number(out.match(/127\.0\.0\.1:(\d+)\//)[1]); assert.ok(port); assert.equal((await fetch(`http://127.0.0.1:${port}/healthz`)).status, 200);
});
+102
View File
@@ -0,0 +1,102 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const secret = 'sk-test-NOT-A-SECRET-1234';
function scratch(t) { const root = mkdtempSync(join(tmpdir(), 'r4-')); t.after(() => rmSync(root, { recursive: true, force: true })); return root; }
function safe(label, Type, props = {}, omitted = secret) { return error => {
assert.ok(error instanceof Type, error.stack);
for (const [key, value] of Object.entries(props)) assert.equal(error[key], value);
assert.ok(error.message.includes(label), error.message);
assert.doesNotMatch(error.message + error.stack, /sk-test|NOT-A-SECRET|Unexpected token/);
assert.ok(!error.message.includes(omitted), error.message);
assert.equal(error.cause, undefined);
return true;
}; }
import { spawnSync } from 'node:child_process';
import { fixture } from './helpers.mjs';
import { runConfigCommand } from '../../organization/src/config-command.mjs';
const repo = join(import.meta.dirname, '../../..');
const task = () => ({ taskVersion: 1, id: 'valid', prompt: 'hello' });
for (const [field, edit] of [
['task id', d => { d.id = secret; }],
['taskVersion', d => { d.taskVersion = secret; }],
['tool', d => { d.capabilities = { tools: [secret] }; }],
['mission id', (d, root) => { d.mission = 'mission.json'; writeFileSync(join(root, d.mission), JSON.stringify({ missionVersion: 1, id: secret, objective: 'hello' })); }],
['missionVersion', (d, root) => { d.mission = 'mission.json'; writeFileSync(join(root, d.mission), JSON.stringify({ missionVersion: secret, id: 'valid', objective: 'hello' })); }],
['mission tool', (d, root) => { d.mission = 'mission.json'; writeFileSync(join(root, d.mission), JSON.stringify({ missionVersion: 1, id: 'valid', objective: 'hello', capabilities: { tools: [secret] } })); }],
]) test(`task validation ${field} omits file value and keeps exit 2`, t => {
const root = scratch(t), d = task(); edit(d, root); const file = join(root, 'task.json'); writeFileSync(file, JSON.stringify(d));
const r = spawnSync(process.execPath, [join(repo, 'scripts/mosaic-task.mjs'), 'validate', file], { encoding: 'utf8', env: { PATH: process.env.PATH, HOME: process.env.HOME, TMPDIR: process.env.TMPDIR } });
assert.equal(r.status, 2, r.stderr); assert.ok(r.stderr.includes(field), r.stderr); assert.doesNotMatch(r.stderr, /sk-test|NOT-A-SECRET/);
});
const configCases = [
...['configVersion', 'environment', 'dataRoot'].map(k => [k, d => { d[k] = secret; }]),
...['backend', 'provider', 'model', 'adapter'].map(k => [`execution.${k}`, d => { d.execution[k] = secret + '\n'; }]),
['federation.self', d => { d.federation = { self: secret, peers: [] }; }],
['federation.peers[0].id', d => { d.federation = { self: 'self', peers: [{ id: secret, origin: 'https://peer.example', label: 'Peer' }] }; }],
['federation.peers[0].origin', d => { d.federation = { self: 'self', peers: [{ id: 'peer', origin: secret, label: 'Peer' }] }; }],
];
for (const [field, edit] of configCases) test(`config ${field} omits the configured value`, t => {
const root = scratch(t), f = fixture(root), d = JSON.parse(readFileSync(f.config, 'utf8')); edit(d); writeFileSync(f.config, JSON.stringify(d));
const r = spawnSync(process.execPath, [join(repo, 'scripts/mosaic-config.mjs'), 'validate'], { encoding: 'utf8', env: { ...f.env, PATH: process.env.PATH, HOME: process.env.HOME, TMPDIR: process.env.TMPDIR } });
assert.equal(r.status, 2, r.stderr); assert.ok(r.stderr.includes(field), r.stderr); assert.doesNotMatch(r.stderr, /sk-test|NOT-A-SECRET/);
});
test('config-command refuses system config without relaying stderr file values', t => {
const root = scratch(t), f = fixture(root), d = JSON.parse(readFileSync(f.config, 'utf8')); d.execution.model = secret + '\n'; writeFileSync(f.config, JSON.stringify(d));
let err = ''; const status = runConfigCommand(['validate', 'acme'], { env: f.env, stdout: { write() {} }, stderr: { write(s) { err += s; } } });
assert.equal(status, 3); assert.match(err, /system config problem \(mosaic-config exit 2\)/); assert.ok(err.includes(f.config), err); assert.doesNotMatch(err, /sk-test|NOT-A-SECRET/); assert.match(err, /execution.model/); assert.match(err, /expected a 1-200 character name/);
});
test('project id mismatch omits the file value in config-command', t => {
const root = scratch(t), f = fixture(root), file = join(f.doc.projects.stack.root, '.mosaic', 'project.json');
mkdirSync(join(f.doc.projects.stack.root, '.mosaic'));
writeFileSync(file, JSON.stringify({ projectVersion: 1, id: secret.toLowerCase() }));
let err = ''; const status = runConfigCommand(['validate', 'acme'], { env: f.env, stdout: { write() {} }, stderr: { write(s) { err += s; } } });
assert.equal(status, 2); assert.ok(err.includes(file), err); assert.match(err, /id/); assert.doesNotMatch(err, /sk-test|NOT-A-SECRET/);
});
for (const [field, edit] of [
['dataRoot', d => { d.dataRoot = `/x/${secret}/../y`; }],
['federation.peers[0].id', d => { d.federation = { self: secret.toLowerCase(), peers: [{ id: secret.toLowerCase(), origin: 'https://peer.example', label: 'Peer' }] }; }],
]) test(`config ${field} repeated/canonical value ${field.endsWith(".id") ? "names the checked id" : "is omitted"}`, t => {
const root = scratch(t), f = fixture(root), d = JSON.parse(readFileSync(f.config, 'utf8')); edit(d); writeFileSync(f.config, JSON.stringify(d));
const r = spawnSync(process.execPath, [join(repo, 'scripts/mosaic-config.mjs'), 'validate'], { encoding: 'utf8', env: { ...f.env, PATH: process.env.PATH, HOME: process.env.HOME, TMPDIR: process.env.TMPDIR } });
assert.equal(r.status, 2); assert.ok(r.stderr.includes(field), r.stderr);
if (field.endsWith(".id")) assert.ok(r.stderr.includes(secret.toLowerCase()), r.stderr);
else assert.doesNotMatch(r.stderr, /sk-test|NOT-A-SECRET/);
});
for (const mission of [false, true]) test(`duplicate ${mission ? 'mission' : 'task'} tool names the validated value`, t => {
const root = scratch(t), d = task();
if (mission) { d.mission = 'mission.json'; writeFileSync(join(root, d.mission), JSON.stringify({ missionVersion: 1, id: 'valid', objective: 'hello', capabilities: { tools: ['read', 'read'] } })); }
else d.capabilities = { tools: ['read', 'read'] };
const file = join(root, 'task.json'); writeFileSync(file, JSON.stringify(d));
const r = spawnSync(process.execPath, [join(repo, 'scripts/mosaic-task.mjs'), 'validate', file], { encoding: 'utf8', env: { PATH: process.env.PATH, HOME: process.env.HOME, TMPDIR: process.env.TMPDIR } });
assert.equal(r.status, 2); assert.match(r.stderr, /duplicate tool in/); assert.match(r.stderr, /read/);
});
for (const childStderr of [secret, `mosaic-config: execution.model must match ${secret}`]) test(`config-command does not relay arbitrary child stderr${childStderr.startsWith('mosaic-config:') ? ' after a known field' : ''}`, t => {
const root = scratch(t), file = join(root, 'config.json');
const code = `import cp from 'node:child_process'; import { syncBuiltinESMExports } from 'node:module';
cp.spawnSync = () => ({ status: 2, stderr: ${JSON.stringify(childStderr)}, stdout: '' }); syncBuiltinESMExports();
const { runConfigCommand } = await import(${JSON.stringify(new URL('../../organization/src/config-command.mjs', import.meta.url).href)});
process.exitCode = runConfigCommand(['validate', 'acme'], { env: { MOSAIC_CONFIG: ${JSON.stringify(file)} } });`;
const r = spawnSync(process.execPath, ['--input-type=module', '-e', code], { encoding: 'utf8', env: { PATH: process.env.PATH, HOME: process.env.HOME, TMPDIR: process.env.TMPDIR } });
assert.equal(r.status, 3, r.stderr); assert.ok(r.stderr.includes(file), r.stderr); assert.doesNotMatch(r.stderr, /sk-test|NOT-A-SECRET/);
});
import { bootConfig, loadSystem } from '../src/config.mjs';
import { CliError } from '../src/errors.mjs';
test('CLI boot project id mismatch omits the configured value and keeps exit 3', t => {
const root = scratch(t), f = fixture(root), file = join(f.doc.projects.stack.root, '.mosaic', 'project.json');
mkdirSync(join(f.doc.projects.stack.root, '.mosaic'));
writeFileSync(file, JSON.stringify({ projectVersion: 1, id: secret.toLowerCase() }));
assert.throws(() => bootConfig({ system: loadSystem({ env: f.env }), businessId: 'acme', env: f.env }), safe(file, CliError, { exitCode: 3 }, secret.toLowerCase()));
});
for (const mission of [false, true]) test(`duplicate ${mission ? 'mission' : 'task'} tool rejects an unknown value before naming it`, t => {
const root = scratch(t), d = task();
if (mission) { d.mission = 'mission.json'; writeFileSync(join(root, d.mission), JSON.stringify({ missionVersion: 1, id: 'valid', objective: 'hello', capabilities: { tools: [secret, secret] } })); }
else d.capabilities = { tools: [secret, secret] };
const file = join(root, 'task.json'); writeFileSync(file, JSON.stringify(d));
const r = spawnSync(process.execPath, [join(repo, 'scripts/mosaic-task.mjs'), 'validate', file], { encoding: 'utf8', env: { PATH: process.env.PATH, HOME: process.env.HOME, TMPDIR: process.env.TMPDIR } });
assert.equal(r.status, 2); assert.match(r.stderr, /unsupported.*tool/); assert.doesNotMatch(r.stderr, /sk-test|NOT-A-SECRET/);
});
+2 -2
View File
@@ -369,8 +369,8 @@ test("a launched pm's request_staff reaches the chief of staff; no other session
const bad = await call("pm", `TOOL request_staff ${JSON.stringify({ task_ref: "vikunja:1/2", roles: [{ role: "coder", count: 0 }] })}`);
assert.match(bad.body, /invalid-request/);
// The chief of staff has no request_staff, and a pm has no launch.
assert.equal((await call("chief", 'TOOL request_staff {"task_ref":"vikunja:1/2","roles":[{"role":"coder","count":1}]}')).body, "refused: unknown-tool");
assert.equal((await call("pm", 'TOOL launch {"instance":"coder"}')).body, "refused: unknown-tool");
assert.equal((await call("chief", 'TOOL request_staff {"task_ref":"vikunja:1/2","roles":[{"role":"coder","count":1}]}')).body.split("\n")[0], "refused: unknown-tool");
assert.equal((await call("pm", 'TOOL launch {"instance":"coder"}')).body.split("\n")[0], "refused: unknown-tool");
// Nothing was launched by asking: the chief of staff's limits are the same, and sonnet has one place left.
assert.deepEqual(readSessions(f.dataRoot).map((s) => s.instance).sort(), ["chief", "pm"]);
const launched = await call("chief", 'TOOL launch {"instance":"coder"}');
+4 -4
View File
@@ -867,7 +867,7 @@ test("row 194 D3 item 5: the chief's session hires with its tool and launches it
assert.equal(listed.launched, true);
// Hire vars set only model and limits.* (N3). harness and thinking are agent-layer route vars the
// human sets, so until then the launch refuses no-route.
assert.equal(await ask("launch", { instance: "sec-2" }), "refused: no-route");
assert.equal((await ask("launch", { instance: "sec-2" })).split("\n")[0], "refused: no-route");
const route = { harness: "pi", model: "claude-sonnet-5-5", thinking: "high" };
s.call("instances.set", { revision: s.call("instances").revision, instance: "sec-2", op: "change", vars: route });
const launched = JSON.parse(await ask("launch", { instance: "sec-2" }));
@@ -875,13 +875,13 @@ test("row 194 D3 item 5: the chief's session hires with its tool and launches it
assert.deepEqual(s.running("sec-2").map((x) => x.run), [launched.run]);
assert.equal(readSessions(p.f.dataRoot).find((x) => x.run === launched.run).launchedBy, "chief");
// The schema's pattern guides the model. The host refuses any name its file doesn't list.
assert.equal(await ask("launch", { instance: "NOT A NAME" }), "refused: instance-not-listed");
assert.equal(await ask("launch", { instance: "reviewer" }), "refused: instance-not-listed");
assert.equal((await ask("launch", { instance: "NOT A NAME" })).split("\n")[0], "refused: instance-not-listed");
assert.equal((await ask("launch", { instance: "reviewer" })).split("\n")[0], "refused: instance-not-listed");
await s.claimed(launched.run);
const fired = JSON.parse(await ask("fire", { name: "sec-2", reason: "the review is done" }));
assert.ok(fired.transaction, JSON.stringify(fired));
await until(() => s.running("sec-2").length === 0, 30000);
// A fired instance stays in roles, off the listing, so the chief can't launch it again.
assert.equal(s.call("instances").instances["sec-2"].launched, false);
assert.equal(await ask("launch", { instance: "sec-2" }), "refused: instance-not-listed");
assert.equal((await ask("launch", { instance: "sec-2" })).split("\n")[0], "refused: instance-not-listed");
});
@@ -147,7 +147,7 @@ test("rename r2 B2: org config prints the stage A golden v1 bytes, preimage and
}
const normalized = (text) => text.split(root).join("<ROOT>").replace(/[a-f0-9]{64}/g, "<DIGEST>");
const preimageText = `${normalized(canonicalJson(preimage))}\n`;
assert.equal(sha256(preimageText.slice(0, -1)), "5fc9d6258f4725b0e260d99e5cf81058a05d21b8ff298bb53653efbbc8013da9");
assert.equal(sha256(preimageText.slice(0, -1)), "0a12b35f55d033b91bd89745859028d252be52b05ca4083964b778a27ec20fd6");
assert.equal(normalized(validate), golden("installed-validate.json"));
assert.equal(normalized(resolved), golden("installed-resolve.json"));
assert.equal(preimageText, golden("installed-preimage.json"));
@@ -1,3 +1,4 @@
import { ownerAccounts } from '../../webui/tests/helpers/owner-accounts.mjs';
import assert from 'node:assert/strict';
import { fork } from 'node:child_process';
import { once } from 'node:events';
@@ -119,7 +120,7 @@ export async function lifecycleFixture(t) {
const issuer = op.issuer('fixture');
sso = await createSso({ publicOrigin: ORIGIN, providers: [{ id: 'fixture', issuer: issuer.issuer, clientId: issuer.clientId,
clientSecret: { slot: 'fixture' }, algs: ['RS256'], subjects: [{ sub: 'owner', name: 'jason' }] }] },
{ secrets: new Map([['fixture', issuer.clientSecret]]), fetch: op.fetch, log: () => {} });
{ accounts: ownerAccounts({ providers: [{ id: 'fixture', subjects: [{ sub: 'owner', name: 'jason' }] }] }), secrets: new Map([['fixture', issuer.clientSecret]]), fetch: op.fetch, log: () => {} });
const transport = async (verb, args, ctx) => {
try { return await rpc('console', { verb, args, subject: ctx.subject }); }
catch (e) { appendFileSync(join(root, 'console-errors.jsonl'), JSON.stringify({ verb, code: e.code, detail: e.detail })+'\n'); throw new BusReadError(e.code ?? 'outcome-unknown', 503); }
+22 -2
View File
@@ -12,6 +12,8 @@ import {createOwnedRunner} from './fixtures/owned-child.mjs';
import {pathToFileURL} from 'node:url';
import {fixture,notifyConfig,BUSINESSES} from './helpers.mjs';
import {reserveOrg,activateOrg} from '../../business/src/org-registry.mjs';
import {writeDeployment} from '../src/deployment.mjs';
import {fakeOidc} from '../../webui/tests/helpers/fake-oidc.mjs';
const ROOT=fileURLToPath(new URL('../../..',import.meta.url)).replace(/\/$/,'');
const git=(cwd,...args)=>execFileSync('git',['-c','user.name=fixture','-c','user.email=fixture@invalid','-c','commit.gpgsign=false','-c','core.hooksPath=/dev/null','-C',cwd,...args],{encoding:'utf8'}).trim();
export function setup(t, realPin=false){
@@ -58,6 +60,7 @@ test('a copy carries every file the harness modules read from it',async t=>{
test('shared copy: npm ci, pushed reachability, readonly/existing copy, webui delegation',async t=>{
const s=setup(t),r=(await s.run('stack-copy.sh',['copy',s.id,'--copies',s.copies]));assert.equal(r.status,0,r.stderr);
const copy=join(s.copies,s.id);assert.equal(statSync(copy).mode&0o777,0o555);
assert.deepEqual(JSON.parse(readFileSync(join(copy,'.stack-build.json'),'utf8')), {commit:s.id}); assert.equal(statSync(join(copy,'.stack-build.json')).mode&0o222,0);
for(const f of ['scripts/mosaic','adapters/pi/adapter.sh','packages/queue/src/cli.mjs','package.json','package-lock.json','packages/tasks/requirements.json','packages/project/src/index.mjs','packages/library/src/index.mjs','packages/project/schemas/project.schema.json','packages/project/templates/north-star.md','packages/plugins/src/kinds.mjs','packages/plugins/src/loader.mjs','packages/processing-control/src/state.mjs','packages/processing-control/src/containers.mjs','packages/processing-control/src/effects.mjs'])assert.ok(existsSync(join(copy,f)),f);
const npm=join(s.bin,'npm');writeFileSync(npm,'#!/bin/sh\nexit 99\n');chmodSync(npm,0o755);
const existing=(await s.run('webui-service.sh',['copy',s.id,'--copies',s.copies]));assert.equal(existing.status,0);assert.equal(existing.stdout,`exists: ${copy}\n`);unlinkSync(npm);
@@ -139,7 +142,7 @@ async function childResult(command,args,env,input){
const timer=setTimeout(()=>p.kill('SIGKILL'),15000);
try{const [status]=await once(p,'close');return {status,stdout,stderr};}finally{clearTimeout(timer)}
}
test('real pinned install loads bus and Console; same-copy mosaic talk passes proof, other trees refuse', {timeout:180000},async t=>{
test('real pinned install loads bus and Console; the Console starts only with --sso and its deployed prerequisites; same-copy mosaic talk passes proof, other trees refuse', {timeout:180000},async t=>{
const s=setup(t,true);
const copied=(await s.run('stack-copy.sh',['copy',s.id,'--copies',s.copies]));assert.equal(copied.status,0,copied.stderr);
const root=join(s.copies,s.id),url=f=>pathToFileURL(join(root,f)).href;
@@ -156,7 +159,24 @@ test('real pinned install loads bus and Console; same-copy mosaic talk passes pr
host.stdout.on('data',c=>hostOut+=c);host.stderr.on('data',c=>hostErr+=c);
try{await until(()=>hostOut.includes('\"org\":\"acme\"')||host.exitCode!==null);assert.match(hostOut,/\"org\":\"acme\"/,hostErr);}
finally{if(host.exitCode===null&&host.signalCode===null){const done=once(host,'close');const stop=spawnSync(join(root,'scripts/mosaic'),['bus','stop','acme'],{env,encoding:'utf8'});assert.equal(stop.status,0,stop.stderr);await done;}}
const web=spawn(process.execPath,[join(root,'packages/webui/src/cli.mjs'),'serve','--port','0','--business','acme','--board','http://127.0.0.1:9'],{env,stdio:['ignore','pipe','pipe']});let webOut='',webErr='';
// The copy is stamped (M3): its Console starts only with --sso, console.sso and its slot, .deployed and accounts.json.
const serveArgs=sso=>[join(root,'packages/webui/src/cli.mjs'),'serve','--port','0','--business','acme','--board','http://127.0.0.1:9',...(sso?['--sso']:[])];
const op=await fakeOidc();t.after(()=>op.close());const iss=op.issuer('a');
const ca=join(s.root,'ca.pem');writeFileSync(ca,op.cert);const webEnv={...env,NODE_EXTRA_CA_CERTS:ca};
const startRefused=async(sso,why)=>{const r=await childResult(process.execPath,serveArgs(sso),webEnv,'');assert.equal(r.status,2,r.stderr);assert.match(r.stderr,why);assert.doesNotMatch(r.stdout,/Mosaic Console:/);};
await startRefused(false,/stamped build serve requires --sso/);
await startRefused(true,/\.deployed is missing or invalid/);
writeDeployment({dataRoot:f.dataRoot},configDir);
await startRefused(true,/accounts file not found/);
writeFileSync(join(configDir,'accounts.json'),JSON.stringify({accountsVersion:1,revision:1,users:[{id:'u-000000000001',subjects:[{provider:'a',sub:'owner'}],name:'Owner',instanceRole:'operator',state:'active',authGen:1,created:'2026-11-10T00:00:00Z'}],memberships:[],invites:[]}),{mode:0o600});
await startRefused(true,/must be a configured provider/);
const config=JSON.parse(readFileSync(env.MOSAIC_CONFIG,'utf8'));
config.console={sso:{publicOrigin:'https://console.test',providers:[{id:'a',issuer:iss.issuer,clientId:iss.clientId,clientSecret:{slot:'a'},algs:['RS256'],subjects:[{sub:'owner',name:'Owner'}]}]}};
writeFileSync(env.MOSAIC_CONFIG,JSON.stringify(config));
await startRefused(true,/client secret slot is missing/);
mkdirSync(join(configDir,'secrets','mosaic-stack'),{recursive:true,mode:0o700});
const slot=join(configDir,'secrets','mosaic-stack','a');writeFileSync(slot,iss.clientSecret,{mode:0o600});chmodSync(slot,0o600);
const web=spawn(process.execPath,serveArgs(true),{env:webEnv,stdio:['ignore','pipe','pipe']});let webOut='',webErr='';
web.stdout.on('data',c=>webOut+=c);web.stderr.on('data',c=>webErr+=c);
try{await until(()=>/Mosaic Console: http:/.test(webOut)||web.exitCode!==null);const port=webOut.match(/127\.0\.0\.1:(\d+)/)?.[1];assert.ok(port,webErr);assert.equal((await fetch(`http://127.0.0.1:${port}/healthz`)).status,200);}
finally{if(web.exitCode===null&&web.signalCode===null){const done=once(web,'close');web.kill();await done;}}
+530 -145
View File
@@ -1555,51 +1555,110 @@ function setEdit(set,name,from,to,sums=true){
if(sums){const s=join(set,'SHA256SUMS');chmodSync(s,0o600);writeFileSync(s,readFileSync(s,'utf8').replace(new RegExp(`^[0-9a-f]{64}( \\./${name.replace(/[.@]/g,'\\$&')})$`,'m'),`${sha(f)}$1`))}
}
// M3 (#1873): console.sso marks an accounts instance, which runs system units, so a user-unit run
// with it refuses accounts-user-unit before anything stops. An --sso pointer now comes two ways:
// - a pre-M3 host's user-unit run. LEGACY is the fixture repo with the M3 base's stack-release.sh,
// webui-service.sh and stack-copy.sh, so legacy(e) builds the state such a host left behind;
// - the system path: root stops the units, stack-release stages with --units external, root
// installs the units and starts them, and confirm pins the pointer to root's Console flags.
const M3_BASE='c3c192677defa518eabeb324aac82f0757e398c3';
const LEGACY=join(base,'legacy-repo');
cpSync(repo,LEGACY,{recursive:true});
for(const f of ['scripts/stack-release.sh','scripts/webui-service.sh','scripts/stack-copy.sh'])writeFileSync(join(LEGACY,f),execFileSync('git',['-C',ROOT,'show',`${M3_BASE}:${f}`]),{mode:0o755});
const legacy=(e)=>({...e,sr:join(LEGACY,'scripts','stack-release.sh')});
const X=(e)=>({...e,flags:[...e.flags,'--units','external']});
const AUU=(e,op)=>`${e.cfg} holds console.sso; an accounts instance runs system units, so root renders them with case2-unit.sh and the ${op} runs with --units external`;
// Root's part. rootUp installs the bus unit for COPY and writes the Console unit case2-unit.sh
// renders for COPY with FLAGS (webui-service.sh refuses an accounts instance), then starts both.
const rootDown=(e)=>{sysctl(e,'stop',e.con);sysctl(e,'stop',e.bus)};
function rootUp(e,copy,flags){
execFileSync('bash',[join(repo,'scripts','bus-service.sh'),'install',...e.flags,'--copy',copy,'--copies',COPIES,'--claude',e.claude],{env:e.env,stdio:'ignore'});
const f=join(e.units,e.con),s=readFileSync(f,'utf8'),node=/^ExecStart=(\S+) /m.exec(s)[1],dir=join(COPIES,copy);
writeFileSync(f,s.replace(/^WorkingDirectory=.*$/m,`WorkingDirectory=${dir}`).replace(/^Documentation=.*$/m,`Documentation=file://${dir}/packages/webui/README.md`)
.replace(/^ExecStart=.*$/m,`ExecStart=${node} ${dir}/packages/webui/src/cli.mjs serve --checkout ${repo} ${flags.join(' ')}`));
execFileSync(join(SBIN,'systemctl'),['--user','start',e.bus],{env:{...e.env,SD_CLOCK_OFFSET:'0'}});sysctl(e,'start',e.con);
}
// One system-path run: OP (activate TO, or rollback --to TO) planned and staged from the pointer's
// copy, root's units for TO with FLAGS, confirm. The plan, the staged set and both log lines are this
// script's output; the unit flags are root's render (case2-unit.test.mjs pins that render), so the
// pointer pinning them is confirm's read of root's unit, not a conversion of this script's.
async function viaSystem(e,op,to,flags,...more){
const x=X(e),from=pointer(e).copy,p0=readFileSync(e.pointer,'utf8'),w0=sha(join(e.bin,e.wrapper));
const args=op==='activate'?['activate',to,'acme']:['rollback','acme','--to',to],inst=e.flags.length?`${e.flags.join(' ')} `:'';
rootDown(e);
const con0=sha(join(e.units,e.con));
const out=await planOnly(x,0,...args,...more),plan=out.join('\n');
assert.deepEqual([out.find((l)=>l.startsWith('plan: ')),out.find((l)=>l.startsWith('from: ')),out.find((l)=>l.startsWith('to: '))],[`plan: ${op} for acme`,`from: ${from}`,`to: ${to}`],plan);
assert.ok(out.includes(`units: external; root installs the units for ${to} and starts them, then confirm gates them`),plan);
assert.ok(out.includes(`CLI: scripts/stack-copy.sh wrapper ${to} ${inst}--copies ${COPIES} --bin ${e.bin}`),plan);
// Root renders the units, so the plan names no unit, Console flag or refusal of this script's.
assert.deepEqual(out.filter((l)=>/^(bus|pm|proof|send|sso|Console):|^(drop|refuses) /.test(l)),[],plan);
let r=await prun(x,...args,...more);assert.equal(r.code,0,r.out+r.err);
const st=logLines(e).at(-1),act=st.activation,rs=setOf(e,act);
assert.deepEqual([st.op,st.from,st.to,st.result,st.units],[op,from,to,'started','external']);
assert.equal(r.out.split('\n').at(-2),`stack-release: ${op} staged ${to} for acme as ${act}; root installs and starts the units, then runs confirm ${to} acme --units external`);
// Staged, not live: the pointer stays; the set holds the pointer, the wrapper and the Console unit root stopped.
assert.equal(readFileSync(e.pointer,'utf8'),p0);
assert.equal(readFileSync(join(rs,'AWAITING-UNITS'),'utf8'),`${to}\n`);
assert.equal(readFileSync(join(rs,'pointer'),'utf8'),p0);assert.equal(sha(join(rs,'wrapper')),w0);
assert.ok(readFileSync(join(rs,'UNITS.sha256'),'utf8').includes(`${con0} ${join(e.units,e.con)}\n`));
assert.ok(readFileSync(join(e.bin,e.wrapper),'utf8').includes(join(COPIES,to)),'the wrapper names the target copy');
rootUp(e,to,flags);
r=await prun(x,'confirm',to,'acme');assert.equal(r.code,0,r.out+r.err);
assert.equal(r.out.split('\n').at(-2),`stack-release: ${op} ok: ${to} is live for acme`);
const l=logLines(e).at(-1);assert.deepEqual([l.op,l.from,l.to,l.result,l.units,l.activation],[op,from,to,'ok','external',act]);
assert.ok(!existsSync(join(rs,'AWAITING-UNITS')));
assert.deepEqual([pointer(e).copy,pointer(e).console],[to,flags]);
return act;
}
// --plan with an accounts sign: exit 3 with the refusal alone; nothing written, started or stopped, and no log line.
async function planRefused(e,args,code,msg){
const before=fp(e),data=tree(e.data),n=calls(e).length,logs=logLines(e).length;
const r=await prun(e,...args,'--plan');assert.equal(r.code,3,r.out+r.err);assert.equal(r.out,'');
assert.equal(r.err,`stack-release: refusing ${code}: ${msg}\n`);
assert.deepEqual(fp(e),before);assert.deepEqual(tree(e.data),data);assert.deepEqual(startsStops(e,n),[]);assert.equal(logLines(e).length,logs);
}
// The user-unit run and its --plan both refuse accounts-user-unit, before anything stops.
async function auu(e,args,op,msg=AUU(e,op)){await refused(e,args,'accounts-user-unit',msg);await planRefused(e,args,'accounts-user-unit',msg)}
const rootStart=(e)=>{execFileSync(join(SBIN,'systemctl'),['--user','start',e.bus],{env:{...e.env,SD_CLOCK_OFFSET:'0'}});sysctl(e,'start',e.con)};
const shown=(e,act,show=SHOW)=>readFileSync(join(setOf(e,act),show),'utf8');
test('T57 row 200: activate --sso, its refusals before any stop, and the rollback to a Console without --sso',async(t)=>{
await t.test('item 1: a pre-SSO activation drops legacy send; --sso then drops preview and proof before the tester port',async(t)=>{
await t.test('item 1 (M3): a pre-SSO activation drops legacy send; with console.sso a user-unit --sso refuses accounts-user-unit, and the system path pins root\'s --sso Console',async(t)=>{
const e=await mk(t),base=await seedPreview(e,undefined,['--send']),f=await proofFile(e),pre=noSend(base);
let r=await prun(e,'activate',B,'acme','--tester-port','7333',...pair(f));assert.equal(r.code,0,r.out+r.err);
assert.deepEqual(pointer(e).console,[...pre,...pair(f),'--tester-port','7333']);
await ssoConfig(e);
await auu(e,['activate',C1,'acme','--sso'],'activate');
// Root renders the stamped Console with --sso and no preview or proof flags (case2-unit.test.mjs pins that render).
const want=['--port',e.port,'--business','acme','--sso','--tester-port','7333'];
const out=await planOnly(e,0,'activate',C1,'acme','--sso');
assert.deepEqual([...proofLines(out),...sendLines(out),...ssoLines(out)],['proof: dropped; --sso drops header x-console-proof','send: off',
`sso: on; drops ${DROPS}; carries --port ${e.port} --business acme --tester-port 7333`]);
assert.equal(out[out.findIndex((l)=>l.startsWith('sso:'))+1],`Console: scripts/webui-service.sh install --copy ${C1} --copies ${COPIES} ${want.join(' ')}`);
r=await prun(e,'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
const act=await viaSystem(e,'activate',C1,want);
assert.equal(pointer(e).copy,C1);assert.deepEqual(pointer(e).console,want);
assert.ok(conExec(e).endsWith(` --checkout ${repo} ${want.join(' ')}`),conExec(e));
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
const steps=stepText(e);
assert.ok(steps.includes(`Z drop --proof-header x-console-proof --proof-file ${f}; --sso was given\n`),steps);
assert.ok(steps.includes(`Z turn on --sso; drop ${DROPS}\n`),steps);
// The activation's set holds the proxy-preview Console it replaced, pair and tester port included.
assert.ok(shown(e,act).includes(` ${[...pre,...pair(f),'--tester-port','7333'].join(' ')}`),shown(e,act));
await noLeak(e);
});
await t.test('item 1: --sso may re-pin the live copy once; later activates carry it; --no-send still drops --send',async(t)=>{
const e=await mk(t),base=await seedPreview(e,undefined,['--send']);await ssoConfig(e);
let r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
let out=await planOnly(e,0,'activate',C1,'acme','--sso');
assert.deepEqual([...proofLines(out),...sendLines(out),...ssoLines(out)],['proof: off','send: off',`sso: on; drops ${DROPS}; carries --port ${e.port} --business acme`]);
r=await prun(e,'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
const l=logLines(e).at(-1);assert.deepEqual([l.op,l.from,l.to,l.result],['activate',C1,C1,'ok']);
assert.deepEqual(pointer(e).console,['--port',e.port,'--business','acme','--sso']);
assert.doesNotMatch(stepText(e),/proof/);
await refused(e,['activate',C1,'acme','--sso'],'already-active',`${C1} is the pointer's copy`);
await refused(e,['activate',C1,'acme'],'already-active',`${C1} is the pointer's copy`);
r=await prun(e,'activate',C1,'acme','--send');assert.equal(r.code,0,r.out+r.err);
assert.deepEqual(pointer(e).console,['--port',e.port,'--business','acme','--sso','--send']);
out=await planOnly(e,0,'activate',C,'acme');assert.deepEqual(ssoLines(out),['sso: carried']);
r=await prun(e,'activate',C,'acme','--migrate');assert.equal(r.code,0,r.out+r.err);
// Carried, not turned on: no stamped step names sso (the install output's serve line still shows --sso).
assert.deepEqual(pointer(e).console,['--port',e.port,'--business','acme','--sso','--send']);assert.doesNotMatch(stepText(e),/^\S+Z .*sso/m);
assert.match(stepText(e),/^serve: .* --sso --send$/m);
r=await prun(e,'activate',C,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
assert.deepEqual(pointer(e).console,['--port',e.port,'--business','acme','--sso']);
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
await t.test('item 1 (M3): while console.sso is set every user-unit activate refuses accounts-user-unit; the system path carries root\'s --sso --send Console to another copy',async(t)=>{
const e=await mk(t);await seedPreview(e,undefined,['--send']);await ssoConfig(e);
for(const args of [['activate',C1,'acme'],['activate',C1,'acme','--sso'],['activate',C1,'acme','--send'],['activate',C1,'acme','--sso','--no-send'],['activate',C,'acme','--migrate']])await auu(e,args,'activate');
const sso=['--port',e.port,'--business','acme','--sso','--send'];
await viaSystem(e,'activate',C1,sso);
assert.deepEqual(pointer(e).console,sso);
await auu(e,['activate',C1,'acme','--no-send'],'activate');
await auu(e,['rollback','acme'],'rollback');
const act=await viaSystem(e,'activate',C,sso,'--migrate');
assert.equal(pointer(e).copy,C);assert.deepEqual(pointer(e).console,sso);
assert.ok(conExec(e).endsWith(` --checkout ${repo} ${sso.join(' ')}`),conExec(e));
assert.match(shown(e,act),/ --sso --send /);
let r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
// Root's unit without --send: the pointer pins what root rendered.
const g=await mk(t);await seedPreview(g,undefined,['--send']);await ssoConfig(g);
r=await prun(g,'activate',C1,'acme','--sso','--no-send');assert.equal(r.code,0,r.out+r.err);
await viaSystem(g,'activate',C1,['--port',g.port,'--business','acme','--sso']);
assert.deepEqual(pointer(g).console,['--port',g.port,'--business','acme','--sso']);
assert.ok(stepText(g).includes('Z drop --send; --no-send was given\n'));
r=await prun(g,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
for(const x of [e,g])await noLeak(x);
});
await t.test('item 1: --sso with a pair or --no-proof, or on any verb but activate, is a usage error',async(t)=>{
@@ -1613,53 +1672,49 @@ test('T57 row 200: activate --sso, its refusals before any stop, and the rollbac
assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]);assert.equal(logLines(e).length,logs);
await noLeak(e);
});
await t.test('item 2: a target without --sso, a missing or bad console.sso, and each slot problem refuse before any stop',async(t)=>{
const e=await mk(t);await seedPreview(e);await ssoConfig(e,{slot:false});
const slot=slotPath(e),put=(mode=0o600,text='x\n')=>{rmSync(slot,{force:true,recursive:true});writeFileSync(slot,text,{mode});chmodSync(slot,mode)};
put();
await t.test('item 2 (M3): without console.sso, a target without --sso and the missing key refuse before any stop; with console.sso, valid or not and whatever its slot, the user-unit run refuses accounts-user-unit first',async(t)=>{
const e=await mk(t);await seedPreview(e);
await refused(e,['activate',B,'acme','--sso'],'sso-target',`${B}'s Console does not support --sso`);
setKey(e,undefined);
// N4: from another copy, the refusal names the re-pin without --sso as the first step.
await refused(e,['activate',C1,'acme','--sso'],'sso-config',`${C1}'s Console would refuse its --sso start: there is no console.sso in the system config; activate ${C1} without --sso first, then write console.sso, then run activate ${C1} --sso`);
let r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
await refused(e,['activate',C1,'acme','--sso'],'sso-config',`${C1}'s Console would refuse its --sso start: there is no console.sso in the system config`);
setKey(e,{sso:{...SSO_KEY.sso,providers:[{...SSO_KEY.sso.providers[0],algs:['RS256']}]}});
r=await prun(e,'activate',C1,'acme','--sso','--plan');assert.equal(r.code,0,r.out+r.err);
await refused(e,['activate',C,'acme','--sso'],'sso-config',`${C}'s Console would refuse its --sso start: console.sso.providers[0].algs[0] must be one of ${ALGS.filter((a)=>a!=='RS256').join(', ')}; HS* and none are refused`);
setKey(e,SSO_KEY);
const bad=(why)=>`console.sso provider a client secret slot ${slot} ${why}`;
rmSync(slot);
await refused(e,['activate',C,'acme','--sso'],'sso-slot',bad('is missing'));
const elsewhere=join(e.root,'elsewhere');writeFileSync(elsewhere,'x\n',{mode:0o600});symlinkSync(elsewhere,slot);
await refused(e,['activate',C,'acme','--sso'],'sso-slot',bad('is a symbolic link'));
rmSync(slot);mkdirSync(slot,{mode:0o700});
await refused(e,['activate',C,'acme','--sso'],'sso-slot',bad('is not a regular file'));
for(const mode of [0o644,0o640,0o604]){put(mode);await refused(e,['activate',C,'acme','--sso'],'sso-slot',bad(`has mode ${mode.toString(8)}, which gives group or other permission bits`))}
put(0o600,'');
await refused(e,['activate',C,'acme','--sso'],'sso-slot',bad('is empty'));
// The slot directory resolving into the repository: the file is no link, but its real path is.
const inRepo=mkdtempSync(join(repo,'t57-slot-'));t.after(()=>rmSync(inRepo,{recursive:true,force:true}));
writeFileSync(join(inRepo,SLOT),'x\n',{mode:0o600});chmodSync(join(inRepo,SLOT),0o600);
const secrets=dirname(slot);rmSync(secrets,{recursive:true});symlinkSync(inRepo,secrets);
await refused(e,['activate',C,'acme','--sso'],'sso-slot',bad('is inside the repository or the copies'));
// --plan lists the sso refusal with the rest and writes nothing.
const out=await planOnly(e,3,'activate',C,'acme','--sso','--tester-port','80');
assert.deepEqual(out.filter((l)=>l.startsWith('refuses ')),[`refuses tester-port-invalid: --tester-port 80 is not a whole port from 1024 to 65535 other than 7332 and the Console's --port ${e.port}`,`refuses sso-slot: ${bad('is inside the repository or the copies')}`]);
rmSync(secrets);mkdirSync(secrets,{mode:0o700});put();
r=await prun(e,'activate',C,'acme','--sso','--migrate');assert.equal(r.code,0,r.out+r.err);
assert.deepEqual(out.filter((l)=>l.startsWith('refuses ')),[`refuses tester-port-invalid: --tester-port 80 is not a whole port from 1024 to 65535 other than 7332 and the Console's --port ${e.port}`,`refuses sso-config: ${C}'s Console would refuse its --sso start: there is no console.sso in the system config; activate ${C} without --sso first, then write console.sso, then run activate ${C} --sso`]);
// With console.sso the slot is the Console start's check (packages/webui/tests/sso.test.mjs, readSlot);
// no slot state and no bad key gets a user-unit run past accounts-user-unit.
await ssoConfig(e,{slot:false});
const slot=slotPath(e),secrets=dirname(slot),put=(mode=0o600,text='x\n')=>{rmSync(slot,{force:true,recursive:true});writeFileSync(slot,text,{mode});chmodSync(slot,mode)};
const inRepo=mkdtempSync(join(repo,'t57-slot-'));t.after(()=>rmSync(inRepo,{recursive:true,force:true}));
writeFileSync(join(inRepo,SLOT),'x\n',{mode:0o600});chmodSync(join(inRepo,SLOT),0o600);
const elsewhere=join(e.root,'elsewhere');writeFileSync(elsewhere,'x\n',{mode:0o600});
const states=[()=>{},()=>symlinkSync(elsewhere,slot),()=>{rmSync(slot);mkdirSync(slot,{mode:0o700})},()=>put(0o644),()=>put(0o640),()=>put(0o604),()=>put(0o600,''),
()=>{rmSync(secrets,{recursive:true});symlinkSync(inRepo,secrets)},()=>{rmSync(secrets);mkdirSync(secrets,{mode:0o700});put()}];
// A bad tester port is a usage error outside --plan (row 180), so that case runs --plan only:
// accounts-user-unit stops the plan before it lists tester-port-invalid.
for(const s of states){s();for(const args of [['activate',C,'acme','--sso'],['activate',B,'acme','--sso']])await auu(e,args,'activate');await planRefused(e,['activate',C,'acme','--sso','--tester-port','80'],'accounts-user-unit',AUU(e,'activate'))}
setKey(e,{sso:{...SSO_KEY.sso,providers:[{...SSO_KEY.sso.providers[0],algs:['RS256']}]}});
await auu(e,['activate',C,'acme','--sso'],'activate');
setKey(e,undefined);
r=await prun(e,'activate',C,'acme','--migrate');assert.equal(r.code,0,r.out+r.err);
await noLeak(e);
});
await t.test('item 3: a config whose console key the target loader refuses stops an activate or a rollback before any stop, naming the first step',async(t)=>{
const e=await mk(t);await seedPreview(e);await ssoConfig(e);
await t.test('item 3 (M3): a config whose console key the target loader refuses stops an external activate or rollback before any stop, naming the first step; a user-unit run refuses accounts-user-unit first',async(t)=>{
const e=await mk(t);await seedPreview(e);
let r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
const why='mosaic-config: unsupported configuration key: "console"';
await refused(e,['activate',B,'acme'],'config-console-key',KEY_MSG(e,B,'activate',why));
await refused(e,['rollback','acme'],'config-console-key',KEY_MSG(e,B,'rollback',why));
const out=await planOnly(e,3,'rollback','acme');
await ssoConfig(e);
await auu(e,['activate',B,'acme'],'activate');await auu(e,['rollback','acme'],'rollback');
// Root stopped the units; the staged run checks the config before it writes anything.
rootDown(e);
const x=X(e),why='mosaic-config: unsupported configuration key: "console"';
await refused(x,['activate',B,'acme'],'config-console-key',KEY_MSG(e,B,'activate',why));
await refused(x,['rollback','acme','--to',B],'config-console-key',KEY_MSG(e,B,'rollback',why));
const out=await planOnly(x,3,'rollback','acme','--to',B);
assert.deepEqual(out.filter((l)=>l.startsWith('refuses ')),[`refuses config-console-key: ${KEY_MSG(e,B,'rollback',why)}`]);
setKey(e,{sso:{...SSO_KEY.sso,providers:[{...SSO_KEY.sso.providers[0],algs:['RS256']}]}});
await refused(e,['activate',C,'acme'],'config-console-key',KEY_MSG(e,C,'activate',`mosaic-config: console.sso.providers[0].algs[0] must be one of ${ALGS.filter((a)=>a!=='RS256').join(', ')}; HS* and none are refused`));
setKey(e,undefined);
await refused(x,['activate',C,'acme'],'config-console-key',KEY_MSG(e,C,'activate',`mosaic-config: console.sso.providers[0].algs[0] must be one of ${ALGS.filter((a)=>a!=='RS256').join(', ')}; HS* and none are refused`));
setKey(e,undefined);rootStart(e);
r=await prun(e,'rollback','acme');assert.equal(r.code,0,r.out+r.err);assert.equal(pointer(e).copy,B);
await noLeak(e);
});
@@ -1676,17 +1731,17 @@ test('T57 row 200: activate --sso, its refusals before any stop, and the rollbac
r=await prun(e,'rollback','acme');assert.equal(r.code,0,r.out+r.err);assert.equal(pointer(e).copy,B);
await noLeak(e);
});
await t.test('item 3: a rollback from --sso to a Console without it takes the flags from the set of the activation onto the copy, past a same-copy re-pin (sage-1389)',async(t)=>{
const e=await mk(t),base=await seedPreview(e,undefined,['--send','--tester-port','7333']);await ssoConfig(e);
await t.test('item 3 (M3): from a pre-M3 --sso pointer the user-unit rollback refuses accounts-user-unit; without console.sso it takes the flags from the set of the activation onto the copy, past a same-copy re-pin (sage-1389)',async(t)=>{
const e=await mk(t),base=await seedPreview(e,undefined,['--send','--tester-port','7333']);
let r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
const set=arrivalSet(e,C1);
r=await prun(e,'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
r=await prun(e,'activate',C1,'acme','--send');assert.equal(r.code,0,r.out+r.err);
r=await prun(e,'activate',C1,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
// A pre-M3 host turned --sso on in place, then re-pinned twice.
await ssoConfig(e);
for(const f of ['--sso','--send','--no-send']){r=await prun(legacy(e),'activate',C1,'acme',f);assert.equal(r.code,0,r.out+r.err)}
// The latest set holds the --sso Console; the arrival set holds B's.
assert.match(readFileSync(join(setOf(e,pointer(e).activation),SHOW),'utf8'),/ --sso /);
assert.match(shown(e,pointer(e).activation),/ --sso /);
assert.doesNotMatch(readFileSync(join(set,SHOW),'utf8'),/ --sso /);
await refused(e,['rollback','acme'],'config-console-key',KEY_MSG(e,B,'rollback','mosaic-config: unsupported configuration key: "console"'));
await auu(e,['rollback','acme'],'rollback');
setKey(e,undefined);
const want=[...noSend(base.slice(0,-2)),'--tester-port','7333'];
const out=await planOnly(e,0,'rollback','acme');
@@ -1701,15 +1756,17 @@ test('T57 row 200: activate --sso, its refusals before any stop, and the rollbac
const g=await mk(t),gbase=await seedPreview(g,undefined,['--send']),f=await proofFile(g);
r=await prun(g,'activate',B,'acme',...pair(f));assert.equal(r.code,0,r.out+r.err);
await ssoConfig(g);
r=await prun(g,'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
r=await prun(legacy(g),'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
await auu(g,['rollback','acme'],'rollback');
setKey(g,undefined);
r=await prun(g,'rollback','acme');assert.equal(r.code,0,r.out+r.err);
assert.deepEqual(pointer(g).console,[...noSend(gbase),...pair(f)]);
for(const x of [e,g])await noLeak(x);
});
await t.test('item 3: a rollback set that is missing, fails its sums, holds no argv or already ran --sso refuses sso-rollback-flags',async(t)=>{
await t.test('item 3 (M3): from a pre-M3 --sso pointer, a rollback set that is missing, fails its sums, holds no argv or already ran --sso refuses sso-rollback-flags',async(t)=>{
const e=await mk(t);await seedPreview(e);await ssoConfig(e);
let r=await prun(e,'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
let r=await prun(legacy(e),'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
await auu(e,['rollback','acme'],'rollback');
setKey(e,undefined);
const set=arrivalSet(e,C1),show=join(set,SHOW),text=readFileSync(show,'utf8');
setEdit(set,SHOW,' --public-host ',' --sso --public-host ');
@@ -1731,17 +1788,18 @@ test('T57 row 200: activate --sso, its refusals before any stop, and the rollbac
r=await prun(e,'rollback','acme');assert.equal(r.code,0,r.out+r.err);assert.equal(pointer(e).copy,B);
await noLeak(e);
});
await t.test('round 2 (webby-0028 N1): run through a symlinked repository path, a slot or a proof file inside the repository still refuses',async(t)=>{
await t.test('round 2 (webby-0028 N1, M3): run through a symlinked repository path, a proof file inside the repository still refuses, and console.sso with a slot inside the repository refuses accounts-user-unit',async(t)=>{
const link=join(base,'repo-link.200');if(!existsSync(link))symlinkSync(repo,link);
const e=await mk(t);e.sr=join(link,'scripts','stack-release.sh');await seedPreview(e);
const f=await proofFile(e,'proof.200',0o600,mkdtempSync(join(repo,'t57-proof-')));t.after(()=>rmSync(dirname(f),{recursive:true,force:true}));
await refused(e,['activate',C1,'acme',...pair(f)],'proof-file-invalid',`--proof-file ${f} is inside the repository or the copies`);
await ssoConfig(e,{slot:false});
let r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
await ssoConfig(e,{slot:false});
const inRepo=mkdtempSync(join(repo,'t57-slot-'));t.after(()=>rmSync(inRepo,{recursive:true,force:true}));
writeFileSync(join(inRepo,SLOT),'x\n',{mode:0o600});chmodSync(join(inRepo,SLOT),0o600);
const secrets=dirname(slotPath(e));rmSync(secrets,{recursive:true});symlinkSync(inRepo,secrets);
await refused(e,['activate',C1,'acme','--sso'],'sso-slot',`console.sso provider a client secret slot ${slotPath(e)} is inside the repository or the copies`);
// The Console's start refuses the slot (sso.test.mjs: inside the repository or a stack copy).
await auu(e,['activate',C1,'acme','--sso'],'activate');
await noLeak(e);
});
await t.test('round 2 (webby-0028 N2): a config.json that does not parse stops the run before anything is read or stopped',async(t)=>{
@@ -1758,24 +1816,21 @@ test('T57 row 200: activate --sso, its refusals before any stop, and the rollbac
const r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
await noLeak(e);
});
await t.test('round 3 (sage-1556): a named instance turns --sso on with its own config, slot and Console unit, and rolls back from its own set',async(t)=>{
await t.test('round 3 (sage-1556, M3): a named instance refuses a user-unit --sso with its own config, turns --sso on through the system path on its own Console unit, and rolls back from its own set',async(t)=>{
const e=await mk(t),a=await named(e,'alpha');await seed(a);
const show=`show-${a.con}.txt`,unit=()=>readFileSync(join(a.units,a.con),'utf8').split('\n').find((l)=>l.startsWith('ExecStart='));
assert.ok(slotPath(a).startsWith(join(a.env.HOME,'.config','mosaic-alpha','secrets','mosaic-stack')+'/'),slotPath(a));
await ssoConfig(a,{slot:false});
await refused(a,['activate',C1,'acme','--sso'],'sso-slot',`console.sso provider a client secret slot ${slotPath(a)} is missing`);
await proofFile(a,SLOT,0o600,dirname(slotPath(a)));
await ssoConfig(a);
await auu(a,['activate',C1,'acme','--sso'],'activate');
const want=['--port',a.port,'--business','acme','--sso'];
const out=await planOnly(a,0,'activate',C1,'acme','--sso');
assert.equal(out.find((l)=>l.startsWith('Console: ')),`Console: scripts/webui-service.sh install --instance alpha --copy ${C1} --copies ${COPIES} ${want.join(' ')}`);
let r=await prun(a,'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
const act=await viaSystem(a,'activate',C1,want);
assert.equal(pointer(a).copy,C1);assert.deepEqual(pointer(a).console,want);
assert.ok(unit().endsWith(` ${want.join(' ')}`),unit());
assert.ok(readFileSync(join(a.units,a.con),'utf8').includes(`\nEnvironment=MOSAIC_CONFIG=${a.cfg}\n`));
assert.ok(!existsSync(join(e.units,CON)),'the default Console unit was written');
r=await prun(a,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
let r=await prun(a,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
setKey(a,undefined);
const set=arrivalSet(a,C1);assert.ok(existsSync(join(set,show)),show);
const set=setOf(a,act);assert.equal(arrivalSet(a,C1),set);assert.ok(existsSync(join(set,show)),show);
assert.deepEqual(ssoLines(await planOnly(a,0,'rollback','acme')),[`sso: dropped; ${B}'s Console does not support --sso, so the rollback takes the Console flags from ${set}/${show}, with --tester-port as the pointer has it`]);
r=await prun(a,'rollback','acme');assert.equal(r.code,0,r.out+r.err);
assert.equal(pointer(a).copy,B);assert.deepEqual(pointer(a).console,want.slice(0,-1));
@@ -1794,44 +1849,35 @@ test('T57 row 200: activate --sso, its refusals before any stop, and the rollbac
// Row 273 (#1778, mos-1246 F39): activate --send turns send on only beside --sso, given or carried, and
// activate --no-sso turns --sso off with the Console flags from the set of the activation that turned
// it on, dropping --send. The tester follows the pointer.
// it on, dropping --send. The tester follows the pointer. M3 (#1873): with console.sso these are
// accounts-user-unit refusals; a pre-M3 --sso pointer comes from legacy(e), and --no-sso runs once
// console.sso is gone.
const SEND_MSG="--send needs --sso in the run's Console flags, given or carried; under forward auth any same-uid process could send (F39)";
const NOT_ON_MSG="the pointer's Console flags hold no --sso, so --no-sso has nothing to turn off";
const OFF_MSG=(why)=>`the Console flags from before --sso cannot be read: ${why}`;
const OFF_PLAN=(set,restores)=>`sso: off; Console flags from ${set}/${SHOW}; restores ${restores}`;
const NOTE=(to)=>`note: ${to} is the pointer's copy; the run re-pins it and only the Console flags change`;
test('T58 row 273: activate --send and --no-sso',async(t)=>{
await t.test('T1: --sso --send turns send on after --sso and before the tester port, and the plan says so',async(t)=>{
await t.test('T1 (M3): a user-unit --sso --send refuses accounts-user-unit; the system path pins root\'s --sso --send before the tester port',async(t)=>{
const e=await mk(t);await seedPreview(e,undefined,['--tester-port','7333']);await ssoConfig(e);
await auu(e,['activate',C1,'acme','--sso','--send'],'activate');
const want=['--port',e.port,'--business','acme','--sso','--send','--tester-port','7333'];
const out=await planOnly(e,0,'activate',C1,'acme','--sso','--send');
assert.deepEqual([...sendLines(out),...ssoLines(out)],['send: on; --send was given',`sso: on; drops ${DROPS}; carries --port ${e.port} --business acme --send --tester-port 7333`]);
assert.equal(out.find((l)=>l.startsWith('Console: ')),`Console: scripts/webui-service.sh install --copy ${C1} --copies ${COPIES} ${want.join(' ')}`);
let r=await prun(e,'activate',C1,'acme','--sso','--send');assert.equal(r.code,0,r.out+r.err);
await viaSystem(e,'activate',C1,want);
assert.equal(pointer(e).copy,C1);assert.deepEqual(pointer(e).console,want);
assert.ok(conExec(e).endsWith(` --checkout ${repo} ${want.join(' ')}`),conExec(e));
const steps=stepText(e);
assert.ok(steps.includes('Z turn on --send; --send was given\n'),steps);assert.ok(steps.includes(`Z turn on --sso; drop ${DROPS}\n`),steps);
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
// The same copy with send on again is already active.
await refused(e,['activate',C1,'acme','--send'],'already-active',`${C1} is the pointer's copy`);
const r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
await auu(e,['activate',C1,'acme','--send'],'activate');
await noLeak(e);
});
await t.test('T1: --send beside a carried --sso re-pins the live copy once, and the plan names the carried --sso',async(t)=>{
await t.test('T1 (M3): beside a pre-M3 carried --sso, a user-unit --send refuses accounts-user-unit; the system path carries root\'s --sso --send to another copy',async(t)=>{
const e=await mk(t);await seedPreview(e);await ssoConfig(e);
let r=await prun(e,'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
let r=await prun(legacy(e),'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
assert.deepEqual(pointer(e).console,['--port',e.port,'--business','acme','--sso']);
const out=await planOnly(e,0,'activate',C1,'acme','--send');
assert.deepEqual([...sendLines(out),...ssoLines(out)],['send: on; --send was given','sso: carried']);
assert.ok(out.includes(NOTE(C1)),out.join('\n'));
r=await prun(e,'activate',C1,'acme','--send');assert.equal(r.code,0,r.out+r.err);
const l=logLines(e).at(-1);assert.deepEqual([l.op,l.from,l.to,l.result],['activate',C1,C1,'ok']);
await auu(e,['activate',C1,'acme','--send'],'activate');
await auu(e,['activate',C,'acme','--send','--migrate'],'activate');
const want=['--port',e.port,'--business','acme','--sso','--send'];
assert.deepEqual(pointer(e).console,want);assert.ok(conExec(e).endsWith(` --checkout ${repo} ${want.join(' ')}`),conExec(e));
assert.ok(stepText(e).includes('Z turn on --send; --send was given\n'));assert.doesNotMatch(stepText(e),/^\S+Z .*sso/m);
await refused(e,['activate',C1,'acme','--send'],'already-active',`${C1} is the pointer's copy`);
// Another copy carries both.
assert.deepEqual(sendLines(await planOnly(e,0,'activate',C,'acme','--send')),['send: carried']);
await viaSystem(e,'activate',C,want,'--migrate');
assert.equal(pointer(e).copy,C);assert.deepEqual(pointer(e).console,want);assert.ok(conExec(e).endsWith(` --checkout ${repo} ${want.join(' ')}`),conExec(e));
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
await noLeak(e);
});
@@ -1848,11 +1894,12 @@ test('T58 row 273: activate --send and --no-sso',async(t)=>{
await refused(g,['activate',B,'acme','--send'],'already-active',`${B} is the pointer's copy`);
for(const x of [e,g])await noLeak(x);
});
await t.test('T2 (sage-2315 B1): a rollback from an --sso --send pointer to a Console without --sso ends with neither flag',async(t)=>{
await t.test('T2 (sage-2315 B1, M3): from a pre-M3 --sso --send pointer the user-unit rollback refuses accounts-user-unit; without console.sso the rollback to a Console without --sso ends with neither flag',async(t)=>{
const e=await mk(t),base=await seedPreview(e);await ssoConfig(e);
let r=await prun(e,'activate',C1,'acme','--sso','--send');assert.equal(r.code,0,r.out+r.err);
let r=await prun(legacy(e),'activate',C1,'acme','--sso','--send');assert.equal(r.code,0,r.out+r.err);
assert.deepEqual(pointer(e).console,['--port',e.port,'--business','acme','--sso','--send']);
const set=arrivalSet(e,C1);
await auu(e,['rollback','acme'],'rollback');
setKey(e,undefined);
const out=await planOnly(e,0,'rollback','acme');
assert.deepEqual([...sendLines(out),...ssoLines(out)],['send: dropped; the run drops --sso, and --send needs it (F39)',ROLLBACK_PLAN(set)]);
@@ -1866,7 +1913,7 @@ test('T58 row 273: activate --send and --no-sso',async(t)=>{
});
await t.test('T2 and T4: --send with --no-send, --no-sso with --sso, --send, a pair or --no-proof, and either flag on another verb, is a usage error',async(t)=>{
const e=await mk(t);await seedPreview(e);await ssoConfig(e);const f=await proofFile(e);
let r=await prun(e,'activate',C1,'acme','--sso','--send');assert.equal(r.code,0,r.out+r.err);
let r=await prun(legacy(e),'activate',C1,'acme','--sso','--send');assert.equal(r.code,0,r.out+r.err);
const usageText=(await prun(e,'--help')).err,n=calls(e).length,logs=logLines(e).length,before=fp(e);
for(const args of [['activate',C1,'acme','--send','--no-send'],['activate',C1,'acme','--no-send','--send','--plan'],['activate',C1,'acme','--sso','--send','--no-send'],
['activate',C1,'acme','--no-sso','--sso'],['activate',C1,'acme','--sso','--no-sso','--plan'],['activate',C1,'acme','--no-sso','--send'],
@@ -1879,15 +1926,18 @@ test('T58 row 273: activate --send and --no-sso',async(t)=>{
assert.deepEqual(fp(e),before);assert.deepEqual(startsStops(e,n),[]);assert.equal(logLines(e).length,logs);
await noLeak(e);
});
await t.test('T3: --no-sso on the same copy returns the unit and the pointer to the flags from before --sso, without --send',async(t)=>{
const e=await mk(t),base=await seedPreview(e,undefined,['--send','--tester-port','7333']);await ssoConfig(e);
await t.test('T3 (M3): --no-sso refuses accounts-user-unit while console.sso is set; without it, on the same copy it returns the unit and the pointer to the flags from before --sso, without --send',async(t)=>{
const e=await mk(t),base=await seedPreview(e,undefined,['--send','--tester-port','7333']);
let r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
const pre=conExec(e),want=noSend(base);
assert.ok(pre.endsWith(` ${want.join(' ')}`),pre);
r=await prun(e,'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
await ssoConfig(e);
r=await prun(legacy(e),'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
const set=setOf(e,pointer(e).activation);
r=await prun(e,'activate',C1,'acme','--send');assert.equal(r.code,0,r.out+r.err);
r=await prun(legacy(e),'activate',C1,'acme','--send');assert.equal(r.code,0,r.out+r.err);
assert.deepEqual(pointer(e).console,['--port',e.port,'--business','acme','--sso','--send','--tester-port','7333']);
await auu(e,['activate',C1,'acme','--no-sso'],'activate');
setKey(e,undefined);
const out=await planOnly(e,0,'activate',C1,'acme','--no-sso');
assert.deepEqual([...proofLines(out),...sendLines(out),...ssoLines(out)],['proof: off','send: dropped; --no-sso drops --send (F39)',OFF_PLAN(set,want.slice(0,-2).join(' '))]);
assert.ok(out.includes(NOTE(C1)),out.join('\n'));
@@ -1904,14 +1954,16 @@ test('T58 row 273: activate --send and --no-sso',async(t)=>{
await refused(e,['activate',C1,'acme','--no-sso'],'sso-not-on',NOT_ON_MSG);
await noLeak(e);
});
await t.test('T3: the set walk passes later re-pins that ran with --sso, and the tester follows the pointer',async(t)=>{
await t.test('T3 (M3): from pre-M3 re-pins, the set walk passes later re-pins that ran with --sso, and the tester follows the pointer',async(t)=>{
const e=await mk(t),base=await seedPreview(e,undefined,['--send','--tester-port','7333']);await ssoConfig(e);
// The activation that turns --sso on also moves the tester to 7334, so the set and the pointer differ.
let r=await prun(e,'activate',C1,'acme','--sso','--tester-port','7334');assert.equal(r.code,0,r.out+r.err);
let r=await prun(legacy(e),'activate',C1,'acme','--sso','--tester-port','7334');assert.equal(r.code,0,r.out+r.err);
const set=setOf(e,pointer(e).activation);
r=await prun(e,'activate',C1,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
assert.match(readFileSync(join(setOf(e,pointer(e).activation),SHOW),'utf8'),/ --sso /);
r=await prun(legacy(e),'activate',C1,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
assert.match(shown(e,pointer(e).activation),/ --sso /);
assert.deepEqual(pointer(e).console,['--port',e.port,'--business','acme','--sso','--tester-port','7334']);
await auu(e,['activate',C1,'acme','--no-sso'],'activate');
setKey(e,undefined);
const restores=base.slice(0,-3),want=[...restores,'--tester-port','7334'];
const out=await planOnly(e,0,'activate',C1,'acme','--no-sso');
assert.deepEqual([...sendLines(out),...ssoLines(out)],['send: off; --no-sso was given and the pointer holds no --send',OFF_PLAN(set,restores.join(' '))]);
@@ -1921,17 +1973,20 @@ test('T58 row 273: activate --send and --no-sso',async(t)=>{
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
await noLeak(e);
});
await t.test('T3 (sage-2305 a): after --sso, two later re-pins that carry it, then --no-sso restores the flags from before --sso byte for byte',async(t)=>{
const e=await mk(t),base=await seedPreview(e,undefined,['--tester-port','7333']);await ssoConfig(e);
await t.test('T3 (sage-2305 a, M3): after a pre-M3 --sso and two re-pins that carry it, --no-sso without console.sso restores the flags from before --sso byte for byte',async(t)=>{
const e=await mk(t),base=await seedPreview(e,undefined,['--tester-port','7333']);
let r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
const pre=conExec(e);assert.ok(pre.endsWith(` ${base.join(' ')}`),pre);
r=await prun(e,'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
await ssoConfig(e);
r=await prun(legacy(e),'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
const set=setOf(e,pointer(e).activation);
// Two same-copy re-pins carry --sso, so the two newest sets both hold a Console with it.
r=await prun(e,'activate',C1,'acme','--send');assert.equal(r.code,0,r.out+r.err);
r=await prun(e,'activate',C1,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
r=await prun(legacy(e),'activate',C1,'acme','--send');assert.equal(r.code,0,r.out+r.err);
r=await prun(legacy(e),'activate',C1,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
const acts=logLines(e).filter((l)=>l.result==='ok'&&l.op==='activate').map((l)=>l.activation).slice(-2);
for(const a of acts)assert.match(readFileSync(join(setOf(e,a),SHOW),'utf8'),/ --sso /);
for(const a of acts)assert.match(shown(e,a),/ --sso /);
await auu(e,['activate',C1,'acme','--no-sso'],'activate');
setKey(e,undefined);
const out=await planOnly(e,0,'activate',C1,'acme','--no-sso');
assert.deepEqual(ssoLines(out),[OFF_PLAN(set,base.slice(0,-2).join(' '))]);
r=await prun(e,'activate',C1,'acme','--no-sso');assert.equal(r.code,0,r.out+r.err);
@@ -1939,27 +1994,31 @@ test('T58 row 273: activate --send and --no-sso',async(t)=>{
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
await noLeak(e);
});
await t.test('T4 (sage-2305 b): a log in which every set holds a Console with --sso refuses before any unit stops',async(t)=>{
await t.test('T4 (sage-2305 b, M3): a log in which every set holds a Console with --sso refuses before any unit stops',async(t)=>{
const e=await mk(t);await seedPreview(e,undefined,['--send']);await ssoConfig(e);
let r=await prun(e,'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
let r=await prun(legacy(e),'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
const first=setOf(e,pointer(e).activation);
r=await prun(e,'activate',C1,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
assert.match(readFileSync(join(setOf(e,pointer(e).activation),SHOW),'utf8'),/ --sso /);
r=await prun(legacy(e),'activate',C1,'acme','--no-send');assert.equal(r.code,0,r.out+r.err);
assert.match(shown(e,pointer(e).activation),/ --sso /);
// The set of the activation that turned --sso on now claims its Console ran with it too, sums intact.
setEdit(first,SHOW,' --public-host ',' --sso --public-host ');
await auu(e,['activate',C1,'acme','--no-sso'],'activate');
setKey(e,undefined);
const why=OFF_MSG(`the Console in ${join(first,SHOW)} already ran with --sso, and the log names no earlier activation`);
await refused(e,['activate',C1,'acme','--no-sso'],'sso-off-flags',why);
assert.deepEqual((await planOnly(e,3,'activate',C1,'acme','--no-sso')).filter((l)=>l.startsWith('refuses ')),[`refuses sso-off-flags: ${why}`]);
await noLeak(e);
});
await t.test('T4: --no-sso refuses before any stop on a pointer without --sso, a tampered or missing set, a set that ran with --sso, an ok line with no activation, and a log with no set',async(t)=>{
await t.test('T4 (M3): --no-sso refuses before any stop on a pointer without --sso, a tampered or missing set, a set that ran with --sso, an ok line with no activation, and a log with no set',async(t)=>{
const e=await mk(t);await seedPreview(e,undefined,['--send']);
await refused(e,['activate',B,'acme','--no-sso'],'sso-not-on',NOT_ON_MSG);
await refused(e,['activate',C1,'acme','--no-sso'],'sso-not-on',NOT_ON_MSG);
assert.deepEqual((await planOnly(e,3,'activate',C1,'acme','--no-sso')).filter((l)=>l.startsWith('refuses ')),[`refuses sso-not-on: ${NOT_ON_MSG}`]);
// One set, from the activation that turned --sso on.
// One set, from the pre-M3 activation that turned --sso on.
await ssoConfig(e);
let r=await prun(e,'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
let r=await prun(legacy(e),'activate',C1,'acme','--sso');assert.equal(r.code,0,r.out+r.err);
await auu(e,['activate',C1,'acme','--no-sso'],'activate');
setKey(e,undefined);
const set=setOf(e,pointer(e).activation),show=join(set,SHOW),text=readFileSync(show,'utf8');
// An ok line with no activation stops the walk (sage-2315 N1); skipping it would reach the set above.
const logText=readFileSync(e.log,'utf8');
@@ -1985,6 +2044,62 @@ test('T58 row 273: activate --send and --no-sso',async(t)=>{
});
});
// M3 (#1873) item 2: each accounts sign refuses a user-unit activate, rollback or pm-on before
// anything stops; without them the user path runs, and with them the system path runs.
const AUU_TAIL=(op)=>`; an accounts instance runs system units, so root renders them with case2-unit.sh and the ${op} runs with --units external`;
test('M3 accounts-user-unit: each accounts sign refuses a user-unit run before any stop; the user path without them and the system path with them both run',async(t)=>{
await t.test('a stamped target, accounts.json, console.sso and .deployed each refuse activate and rollback, run and --plan',async(t)=>{
const e=await mk(t);await seedPreview(e);
let r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);
const accounts=join(dirname(e.cfg),'accounts.json'),deployed=join(e.data,'.deployed');
const stamp=(copy)=>{const d=join(COPIES,copy),f=join(d,'.stack-build.json'),m=statSync(d).mode&0o777;chmodSync(d,0o755);writeFileSync(f,'{}\n');chmodSync(d,m);
return ()=>{chmodSync(d,0o755);rmSync(f);chmodSync(d,m)}};
const signs=[
[()=>stamp(C),()=>stamp(B),`${join(COPIES,C)} is a stamped copy`,`${join(COPIES,B)} is a stamped copy`],
[()=>{writeFileSync(accounts,'{}\n',{mode:0o600});return ()=>rmSync(accounts)},null,`${accounts} is present`],
[()=>{setKey(e,SSO_KEY);return ()=>setKey(e,undefined)},null,`${e.cfg} holds console.sso`],
[()=>{writeFileSync(deployed,'{}\n');return ()=>rmSync(deployed)},null,`${deployed} is present`],
];
for(const [on,onRb,why,whyRb] of signs){
let off=on();t.after(()=>{try{off()}catch{}});
await auu(e,['activate',C,'acme','--migrate'],'activate',why+AUU_TAIL('activate'));
if(onRb){off();off=onRb()}
await auu(e,['rollback','acme'],'rollback',(whyRb??why)+AUU_TAIL('rollback'));
off();
}
// No sign left: the user path runs both ways.
r=await prun(e,'rollback','acme');assert.equal(r.code,0,r.out+r.err);assert.equal(pointer(e).copy,B);
r=await prun(e,'activate',C1,'acme');assert.equal(r.code,0,r.out+r.err);assert.equal(pointer(e).copy,C1);
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
await noLeak(e);
});
await t.test('pm-on on an accounts instance refuses before it captures or stops anything; without the sign it runs',async(t)=>{
const e=await mk(t);await pending(e);biz(e,HARNESS);
const accounts=join(dirname(e.cfg),'accounts.json');writeFileSync(accounts,'{}\n',{mode:0o600});
// The harness is set, so only the accounts sign stands between pm-on and the user bus unit.
await refused(e,['pm-on','acme'],'accounts-user-unit',`${accounts} is present; an accounts instance runs system units, so root renders them with case2-unit.sh, and the PM is a flag in root's bus unit`);
rmSync(accounts);
const r=await run(e,'pm-on','acme');assert.equal(r.code,0,r.out+r.err);
assert.ok(r.out.endsWith(`stack-release: pm-on ok: the PM is live on ${C1} for acme\n`),r.out);
});
await t.test('with accounts.json and .deployed the system path activates, confirms and rolls back, and the user path still refuses',async(t)=>{
const e=await mk(t),base=await seedPreview(e);
writeFileSync(join(dirname(e.cfg),'accounts.json'),'{}\n',{mode:0o600});writeFileSync(join(e.data,'.deployed'),'{}\n');
await ssoConfig(e);
const sso=['--port',e.port,'--business','acme','--sso','--send'];
await viaSystem(e,'activate',C1,sso);
assert.deepEqual([pointer(e).copy,pointer(e).console],[C1,sso]);
let r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
// B's loader refuses the console key, so a rollback onto it drops console.sso first; the other signs stay.
setKey(e,undefined);
await auu(e,['rollback','acme'],'rollback',`${join(dirname(e.cfg),'accounts.json')} is present`+AUU_TAIL('rollback'));
await viaSystem(e,'rollback',B,base);
assert.deepEqual([pointer(e).copy,pointer(e).console],[B,base]);
r=await prun(e,'status','acme');assert.equal(r.code,0,r.err);assert.match(r.out,/unit flags: match the pointer/);
await noLeak(e);
});
});
// --- rehearse (D2, row 163; the brief's "Stack rehearsal on a scratch root") ---
// Copies of their own in RCOPIES: the real scripts/mosaic, config loader, business loader, roles and
// mosaic-config.mjs, with stand-ins for the bus host (cli.mjs), the Console, the Store and
@@ -2009,6 +2124,7 @@ function rHost(){
}
if(a[0]!=='bus'||a[1]!=='start'||!a.slice(3).every((f)=>flags.includes(f))){console.error(`fixture host: ${a.join(' ')}`);process.exit(4)}
fs.appendFileSync(path.join(home,'host-args.log'),`${a.join(' ')}\n`);
fs.appendFileSync(path.join(home,'host-ca.log'),`${process.env.NODE_EXTRA_CA_CERTS??'none'}\n`);
if(fault(`host-fails-${id}`)){console.error('fixture host: refused');process.exit(1)}
const b=a[2],off=a.includes('--no-launch'),lock=path.join(data,'bus','writer.lock'),at=new Date().toISOString();
fs.writeFileSync(lock,String(process.pid),{flag:'wx'});
@@ -2029,14 +2145,39 @@ function rHost(){
}
// The Console: serve on the given loopback port; /healthz answers 200 unless healthz-<copy> is set.
function rConsole(){
// M3 (#1873): under --sso it runs its copy's real createSso on the scratch console.sso, with the
// scratch slot placeholders, as the real Console does, and /healthz carries sso.health(). Faults:
// console-hold waits for $HOME/release before it starts; console-no-sso reports every provider
// ready without asking any; console-asks-token POSTs to provider a's token endpoint, then asks its
// authorize endpoint with a query, once each.
async function rConsole(){
const fs=process.getBuiltinModule('node:fs'),path=process.getBuiltinModule('node:path'),http=process.getBuiltinModule('node:http');
const id=path.basename(path.resolve(import.meta.dirname,'..','..','..')),home=process.env.HOME,a=process.argv.slice(2);
const fault=(n)=>fs.existsSync(path.join(home,'faults',n));
const c=JSON.parse(fs.readFileSync(process.env.MOSAIC_CONFIG,'utf8'));
fs.appendFileSync(path.join(home,'console-args.log'),`${a.join(' ')}\n`);
fs.appendFileSync(path.join(home,'console-context.jsonl'),JSON.stringify({argv:a,dataRoot:JSON.parse(fs.readFileSync(process.env.MOSAIC_CONFIG,'utf8')).dataRoot})+'\n');
fs.appendFileSync(path.join(home,'console-context.jsonl'),JSON.stringify({argv:a,dataRoot:c.dataRoot,ca:process.env.NODE_EXTRA_CA_CERTS??null})+'\n');
if(a[0]!=='serve'){console.error('fixture Console: refused');process.exit(1)}
const s=http.createServer((q,r)=>{r.writeHead(q.url==='/healthz'&&!fault(`healthz-${id}`)?200:500);r.end()});
while(fault('console-hold')&&!fs.existsSync(path.join(home,'release')))await new Promise((r)=>setTimeout(r,50));
let health=null;
if(a.includes('--sso')){
const ps=c.console.sso.providers;
if(fault('console-no-sso'))health=()=>Object.fromEntries(ps.map((p)=>[p.id,'ready']));
else{
const {createSso}=await import(path.join(import.meta.dirname,'sso.mjs'));
const secrets=new Map(ps.map((p)=>[p.id,fs.readFileSync(path.join(path.dirname(process.env.MOSAIC_CONFIG),'secrets','mosaic-stack',p.clientSecret.slot),'utf8')]));
let sso;try{sso=await createSso(c.console.sso,{secrets,log:()=>{}})}catch(err){console.error(`fixture Console: ${err.message}`);process.exit(2)}
health=()=>sso.health();
}
if(fault('console-asks-token')){
await fetch(`${ps[0].issuer}token`,{method:'POST',headers:{'content-type':'application/x-www-form-urlencoded'},body:'grant_type=authorization_code&code=x'}).catch(()=>{});
await fetch(`${ps[0].issuer}authorize?client_id=rehearsal&response_type=code`).catch(()=>{});
}
}
const s=http.createServer((q,r)=>{
const ok=q.url==='/healthz'&&!fault(`healthz-${id}`);r.writeHead(ok?200:500,{'content-type':'application/json'});
r.end(ok&&health?JSON.stringify({ok:true,sso:health()}):'');
});
s.listen(Number(a[a.indexOf('--port')+1]),'127.0.0.1',()=>console.log(`Mosaic Console: http://127.0.0.1:${s.address().port}/\nBoard: fixture`));
process.on('SIGTERM',()=>process.exit(0));
}
@@ -2079,6 +2220,24 @@ for(const [id,v,froms] of [[B,'3g',['3f']],[C,'3h',['3f','3g']],[A,'3g',['3f']],
for(const [f,text] of [['packages/cli/src/cli.mjs',id===A?'// fixture host from before row 163\nprocess.exit(1);\n':script(rHost)],['packages/webui/src/cli.mjs',script(rConsole)],['packages/bus/src/store.mjs',rStore(v,froms)],['packages/bus/src/migrate-schema.mjs',script(rMigrate)],['packages/bus/src/broker.mjs','export class BusError extends Error { constructor(code) { super(code); this.code = code; } }\nexport function requestArchived() { return false; }\n']]){mkdirSync(dirname(join(d,f)),{recursive:true});writeFileSync(join(d,f),text)}
execFileSync('chmod',['-R','a-w',d]);
}
// M3 (#1873): SCOPIES holds B as a stamped copy: RCOPIES's B with a read-only .stack-build.json
// naming its commit, the Console's sso-config.mjs the config loader needs for a console key, its
// sso.mjs the stand-in Console runs, and the deployment.mjs whose writeDeployment the rehearsal runs.
const SCOPIES=join(base,'scopies');mkdirSync(SCOPIES);
{
const d=join(SCOPIES,B);cpSync(join(RCOPIES,B),d,{recursive:true});execFileSync('chmod',['-R','u+w',d]);
for(const p of ['packages/webui/src/sso-config.mjs','packages/webui/src/sso.mjs','packages/cli/src/deployment.mjs'])cpSync(join(repo,p),join(d,p));
// sso.mjs imports oauth4webapi.
symlinkSync(join(ROOT,'node_modules'),join(d,'node_modules'));
writeFileSync(join(d,'.stack-build.json'),JSON.stringify({commit:B}),{mode:0o444});
execFileSync('chmod',['-R','a-w',d]);
}
// UCOPIES holds B unstamped but able to load a console key: SCOPIES's B without .stack-build.json.
const UCOPIES=join(base,'ucopies');mkdirSync(UCOPIES);
{
const d=join(UCOPIES,B);cpSync(join(SCOPIES,B),d,{recursive:true,verbatimSymlinks:true});execFileSync('chmod',['-R','u+w',d]);
rmSync(join(d,'.stack-build.json'));execFileSync('chmod',['-R','a-w',d]);
}
const TESTER='7499';
// Vikunja #782: this leases a port, closes it and hands the number to `stack-release.sh rehearse`, which
@@ -2091,12 +2250,13 @@ const rfault=(e,name)=>{mkdirSync(rh(e,'faults'),{recursive:true});writeFileSync
// A root for one rehearsal: a stopped live store at `store`, a pointer by hand on `copy` with the
// Console on e.port and the tester listener on TESTER, and a backup as backup_store writes one.
// A rehearsal's leftovers, found by their scratch MOSAIC_CONFIG, are killed after the test.
// A rehearsal's leftovers, found by their scratch MOSAIC_CONFIG or a rehearsal path in their command
// line (the rehearsal provider), are killed after the test.
async function rmk(t,{copy=B,store='3g'}={}){
const e=await mk(t);
t.after(()=>{
for(const pid of readdirSync('/proc').filter((n)=>/^\d+$/.test(n)))
try{if(readFileSync(`/proc/${pid}/environ`,'utf8').includes(`MOSAIC_CONFIG=${e.data}/rehearsals/`))process.kill(Number(pid),'SIGKILL')}catch{}
try{if(readFileSync(`/proc/${pid}/environ`,'utf8').includes(`MOSAIC_CONFIG=${e.data}/rehearsals/`)||readFileSync(`/proc/${pid}/cmdline`,'utf8').includes(`${e.data}/rehearsals/`))process.kill(Number(pid),'SIGKILL')}catch{}
});
const db=new DatabaseSync(e.store);
db.exec('PRAGMA journal_mode=WAL');
@@ -2121,7 +2281,7 @@ async function rmk(t,{copy=B,store='3g'}={}){
const untouched=(e)=>JSON.stringify([...[e.store,e.cfg,e.biz,e.pointer].map((f)=>[f,sha(f)]),existsSync(e.log),...readdirSync(e.backup,{recursive:true}).sort().map((f)=>join(e.backup,f)).filter((f)=>statSync(f).isFile()).map((f)=>[f,sha(f)])]);
async function rehearse(e,id,...args){
const ch=spawn('bash',[e.sr??SR,'rehearse',id,'acme',...args,'--copies',RCOPIES,'--bin',e.bin],{env:e.env,stdio:['ignore','pipe','pipe']});
const ch=spawn('bash',[e.sr??SR,'rehearse',id,'acme',...args,'--copies',e.copies??RCOPIES,'--bin',e.bin],{env:e.env,stdio:['ignore','pipe','pipe']});
let out='',err='';ch.stdout.on('data',(d)=>{out+=d});ch.stderr.on('data',(d)=>{err+=d});
e.during?.(ch);
const timer=setTimeout(()=>ch.kill('SIGKILL'),120000);
@@ -2137,7 +2297,7 @@ function ownScript(e,from,to){
const s=readFileSync(e.sr,'utf8');assert.ok(s.includes(from),`${e.sr} has no ${from}`);writeFileSync(e.sr,s.replace(from,()=>to));
}
const rDirs=(e)=>existsSync(join(e.data,'rehearsals'))?readdirSync(join(e.data,'rehearsals')):[];
const PRE=['PASS copy','PASS port','PASS backup','PASS scratch-root','PASS data-root-live','PASS sessions-live','PASS data-root-scratch','PASS notify-off','PASS tracker-configured','PASS credential-ref-live'];
const PRE=['PASS copy','PASS port','PASS backup','PASS scratch-root','PASS data-root-live','PASS sessions-live','PASS data-root-scratch','PASS notify-off','PASS tracker-configured','PASS credential-ref-live','PASS sso-isolated'];
const POST=['PASS console-stopped','PASS host-stopped','PASS host-released-store','PASS backup-unchanged','PASS live-config-unchanged'];
test('R1 rehearse passes on the same schema; the scratch root holds its files and the live files stay byte-identical',async(t)=>{
@@ -2374,6 +2534,229 @@ test('R15 a rehearsal stopped by TERM, INT or HUP fails signal and still stops t
}
});
// --- M3 (#1873): rehearsing a stamped copy (the brief's item 1) ---
// A stamped copy serves only with --sso, console.sso, accounts.json and a .deployed naming its
// instance. The rehearsal gives its scratch Console all four: the live console.sso with its origin
// on rehearsal.invalid and each issuer at the rehearsal's own provider, a placeholder per slot, a
// copy of the live accounts.json, and a .deployed from the copy's writeDeployment. The live slot
// has a read watch: it is never opened.
const ACCOUNTS={accountsVersion:1,revision:1,users:[{id:'u-000000000001',subjects:[{provider:'a',sub:'subject-1'}],name:'Jason',instanceRole:'operator',state:'active',authGen:1,created:'2026-11-10T00:00:00Z'}],memberships:[],invites:[]};
async function smk(t,{sso=true,accounts=true}={}){
const e=await rmk(t);e.copies=SCOPIES;
if(sso)await ssoConfig(e);
e.accounts=join(dirname(e.cfg),'accounts.json');
if(accounts)writeFileSync(e.accounts,JSON.stringify(ACCOUNTS),{mode:0o600});
return e;
}
const SPRE=[...PRE.slice(0,3),'PASS provider-start',PRE[3],'PASS deployment',...PRE.slice(4)];
const SPOST=['PASS console-stopped','PASS host-stopped','PASS provider-stopped',...POST.slice(2)];
// The rehearsal provider's origin and pid, from its provider-start line.
const provOf=(out)=>{const m=/^PASS provider-start: pid (\d+) at (https:\/\/127\.0\.0\.1:\d+): /m.exec(out);return m?{pid:Number(m[1]),origin:m[2]}:null};
// A real stamped copy of this checkout, as deployment-boundary.test.mjs builds one.
function stampedCopy(t){
const sha40='a'.repeat(40),root=mkdtempSync(join(tmpdir(),'rs-'));t.after(()=>rmSync(root,{recursive:true,force:true}));
const copy=join(root,sha40);mkdirSync(copy);
for(const dir of ['packages','scripts','roles','adapters'])cpSync(join(ROOT,dir),join(copy,dir),{recursive:true,filter:(q)=>!q.split('/').some((x)=>['tests','node_modules'].includes(x))});
writeFileSync(join(copy,'package.json'),'{"type":"module"}');
writeFileSync(join(copy,'.stack-build.json'),JSON.stringify({commit:sha40}),{mode:0o444});chmodSync(join(copy,'.stack-build.json'),0o444);
symlinkSync(join(ROOT,'node_modules'),join(copy,'node_modules'));
return copy;
}
// Starts the copy's real Console on port 0 against the rehearsal's scratch config. Resolves with
// {code,out,err} on exit, or with the bound port once it prints its address line.
async function realServe(t,copy,e,dir,more={}){
const ch=spawn(process.execPath,[join(copy,'packages/webui/src/cli.mjs'),'serve','--port','0','--sso','--board','http://127.0.0.1:9'],{env:{HOME:e.env.HOME,PATH:e.env.PATH,TMPDIR:e.env.TMPDIR,MOSAIC_CONFIG:join(dir,'config','config.json'),...more},stdio:['ignore','pipe','pipe']});
t.after(async()=>{if(ch.exitCode===null&&ch.signalCode===null){const done=once(ch,'close');ch.kill('SIGKILL');await done}});
let out='',err='';ch.stderr.on('data',(d)=>{err+=d});
return new Promise((res)=>{
const timer=setTimeout(()=>{ch.kill('SIGKILL');res({code:'timeout',out,err})},20000);
ch.stdout.on('data',(d)=>{out+=d;const m=/Mosaic Console: http:\/\/127\.0\.0\.1:(\d+)\//.exec(out);if(m&&out.includes('Ctrl-C')){clearTimeout(timer);res({port:Number(m[1]),out,err,ch})}});
ch.on('close',(code)=>{clearTimeout(timer);res({code,out,err})});
});
}
test('M3 R16 a stamped copy rehearses with --sso against its own provider, a scratch accounts copy and its own .deployed; the live files stay as they were',async(t)=>{
const st=(f)=>{const x=statSync(f);return [x.ino,x.size,x.mtimeMs,x.mode]};
const e=await smk(t),before=untouched(e),accounts=sha(e.accounts),slot=st(slotPath(e)),port=await freePort();
const r=await rehearse(e,B,'--backup',e.act,'--port',port,'--timeout','10');
assert.equal(r.code,0,r.out+r.err);
assert.deepEqual(r.names,[...SPRE,'PASS migration','PASS gate','PASS no-session','PASS provider-discovery',...SPOST]);
const R=r.dir,prov=provOf(r.out);assert.ok(prov,r.out);
assert.ok(r.out.includes(`PASS provider-start: pid ${prov.pid} at ${prov.origin}: discovery and a JWKS from keys made for this run, no code and no token; its certificate ${join(R,'provider','cert.pem')} is the trust the scratch Console alone gets\n`),r.out);
assert.ok(r.out.includes(`PASS deployment: ${B} is stamped: ${join(R,'data','.deployed')} from its writeDeployment, the live accounts.json copied to ${join(R,'config')}, the Console starts with --sso\n`),r.out);
assert.ok(r.out.includes(`PASS sso-isolated: 1 provider(s), each at the rehearsal provider ${prov.origin} with a placeholder secret\n`),r.out);
assert.ok(r.out.includes(`PASS gate: host-up, bus-status, console-up, healthz, sso-ready and copy-match on ${B}\n`),r.out);
assert.ok(r.out.includes(`PASS provider-discovery: the Console fetched each provider's discovery and JWKS from ${prov.origin} and asked for no code or token\n`),r.out);
// The Console started with --sso, only on the scratch config, and alone trusts the provider's
// certificate; the host gets no added trust.
assert.deepEqual(lines(rh(e,'console-args.log')),[`serve --port ${port} --business acme --sso`]);
assert.deepEqual(lines(rh(e,'console-context.jsonl')).map((l)=>JSON.parse(l).ca),[join(R,'provider','cert.pem')]);
assert.deepEqual(lines(rh(e,'host-ca.log')),['none']);
// The provider served discovery and the JWKS, got no other request, kept no TLS key and is gone.
assert.deepEqual(lines(join(R,'provider','requests.log')).sort(),['GET /a/.well-known/openid-configuration 200','GET /a/jwks 200']);
assert.deepEqual(readdirSync(join(R,'provider')).sort(),['cert.pem','origin','provider.log','requests.log']);
assert.equal(statSync(join(R,'provider')).mode&0o777,0o700);
assert.equal(existsSync(`/proc/${prov.pid}`),false,`provider pid ${prov.pid} is still there`);
// The scratch config: the live one with dataRoot, origin and issuer moved; provider id, client id,
// algorithms, slot name and subjects as they were. No subject is added.
const live=JSON.parse(readFileSync(e.cfg,'utf8')),sc=JSON.parse(readFileSync(join(R,'config','config.json'),'utf8'));
const want=structuredClone(live);want.dataRoot=join(R,'data');want.console.sso.publicOrigin='https://rehearsal.invalid';want.console.sso.providers[0].issuer=`${prov.origin}/a/`;
assert.deepEqual(sc,want);assert.deepEqual(sc.console.sso.providers[0].subjects,SSO_KEY.sso.providers[0].subjects);
// The slot is a scratch placeholder, 0600 in a 0700 directory.
const ph=join(R,'config','secrets','mosaic-stack',SLOT);
assert.equal(readFileSync(ph,'utf8'),'rehearsal-placeholder-secret\n');assert.equal(statSync(ph).mode&0o777,0o600);
assert.equal(statSync(dirname(ph)).mode&0o777,0o700);assert.deepEqual(readdirSync(dirname(ph)),[SLOT]);
// accounts.json is the live file's bytes; .deployed names the instance the scratch config dir gives.
assert.equal(readFileSync(join(R,'config','accounts.json'),'utf8'),readFileSync(e.accounts,'utf8'));
const dep=join(R,'data','.deployed');
assert.deepEqual(JSON.parse(readFileSync(dep,'utf8')),{instance:'dev'});assert.equal(statSync(dep).mode&0o777,0o444);
// The live store, config, business, pointer, backup, accounts and slot are as they were; the run
// never opened the live slot and its sentinel is nowhere under the data root.
assert.equal(untouched(e),before);assert.equal(sha(e.accounts),accounts);assert.deepEqual(st(slotPath(e)),slot);
assert.ok(readFileSync(join(R,'live.before'),'utf8').includes(e.accounts),'the end check sums the live accounts.json');
await noLeak(e);
});
test('M3 R17 the real stamped Console finds the rehearsal provider ready with the scratch prerequisites, starts degraded once it is gone, and refuses without .deployed or accounts.json',async(t)=>{
const e=await smk(t);rfault(e,'console-hold');
const copy=stampedCopy(t);
// While the stand-in Console holds, the rehearsal provider is up: the real Console of a stamped
// copy passes every startup check (stamp, --sso, .deployed, accounts, console.sso and its slot)
// and, trusting only the provider's certificate, finds provider a ready.
let during=null;
e.during=async()=>{
try{
for(let i=0;i<400&&!existsSync(rh(e,'console-args.log'));i++)await new Promise((r)=>setTimeout(r,50));
const dir=join(e.data,'rehearsals',rDirs(e)[0]);
const up=await realServe(t,copy,e,dir,{NODE_EXTRA_CA_CERTS:join(dir,'provider','cert.pem')});
during={up:up.port?await (await fetch(`http://127.0.0.1:${up.port}/healthz`)).json():null,why:`${up.code} ${up.out} ${up.err}`};
if(up.ch){const done=once(up.ch,'close');up.ch.kill('SIGTERM');await done}
}finally{writeFileSync(rh(e,'release'),'')}
};
const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','30');
assert.equal(r.code,0,r.out+r.err);
assert.deepEqual(during?.up,{ok:true,sso:{a:'ready'}},during?.why);
// After the rehearsal its provider is gone: the same Console serves degraded and /healthz says so.
const up=await realServe(t,copy,e,r.dir);
assert.ok(up.port,`${up.code} ${up.out} ${up.err}`);
const h=await fetch(`http://127.0.0.1:${up.port}/healthz`);
assert.equal(h.status,200);assert.deepEqual(await h.json(),{ok:true,sso:{a:'unreachable'}});
const done=once(up.ch,'close');up.ch.kill('SIGTERM');await done;
// The same start without each prerequisite refuses before it listens.
const dep=join(r.dir,'data','.deployed'),acc=join(r.dir,'config','accounts.json'),keep=readFileSync(dep);
rmSync(dep);
let q=await realServe(t,copy,e,r.dir);assert.equal(q.code,2,q.out+q.err);assert.match(q.err,/\.deployed is missing or invalid/);assert.doesNotMatch(q.out,/Mosaic Console:/);
writeFileSync(dep,keep,{mode:0o444});rmSync(acc);
q=await realServe(t,copy,e,r.dir);assert.equal(q.code,2,q.out+q.err);assert.match(q.err,/accounts/);assert.doesNotMatch(q.out,/Mosaic Console:/);
});
test('M3 R18 a stamped copy refuses sso-not-prepared without a live console.sso and accounts-not-initialized without a live accounts.json, before any rehearsal directory',async(t)=>{
for(const [opts,code,msg] of [
[{sso:false},'sso-not-prepared',(e)=>`${B} is a stamped copy, which serves only with --sso, and ${e.cfg} has no console.sso; prepare SSO for this instance (run sheet) before re-pinning to it`],
[{accounts:false},'accounts-not-initialized',(e)=>`${B} is a stamped copy, which needs an accounts file, and there is no ${e.accounts}; a human runs mosaic accounts init (run sheet) before re-pinning to it`],
]){
const e=await smk(t,opts),before=untouched(e);
const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','3');
assert.equal(r.code,1,code+r.out+r.err);
assert.ok(r.out.includes(`FAIL ${code}: ${msg(e)}\nREHEARSAL_FAIL\n`),r.out);
assert.deepEqual(r.names.filter((n)=>n.startsWith('FAIL')),[`FAIL ${code}`]);
assert.deepEqual(rDirs(e),[]);assert.equal(existsSync(rh(e,'host-args.log')),false);assert.equal(existsSync(rh(e,'console-args.log')),false);
assert.equal(existsSync(e.accounts),code!=='accounts-not-initialized');
assert.equal(untouched(e),before);
}
// An unstamped copy needs neither: B from RCOPIES rehearses without console.sso or accounts.json,
// and its Console gets no --sso.
const u=await rmk(t),port=await freePort();
const r=await rehearse(u,B,'--backup',u.act,'--port',port,'--timeout','10');
assert.equal(r.code,0,r.out+r.err);assert.ok(!r.names.includes('PASS deployment'));
assert.ok(r.out.includes('PASS sso-isolated: no console.sso\n'),r.out);
assert.deepEqual(lines(rh(u,'console-args.log')),[`serve --port ${port} --business acme`]);
assert.equal(existsSync(join(r.dir,'data','.deployed')),false);
});
test('M3 R19 sso-isolated fails before the host starts when the scratch origin, an issuer or a slot is not the rehearsal\'s',async(t)=>{
// stamped: the issuer must be the rehearsal provider's; unstamped (no provider): on rehearsal.invalid.
for(const [stamped,from,to,why] of [
[true,'sso.publicOrigin="https://rehearsal.invalid";','','console.sso.publicOrigin is https://console.example.test, not https://rehearsal.invalid'],
[true,'p.issuer=`${op||','p.issuer=p.issuer||`${op||','console.sso provider a issuer is https://op.example.test/, not ORIGIN/a/ at the rehearsal provider'],
[false,'p.issuer=`${op||"https://rehearsal.invalid"}','p.issuer=`${op||"https://op.example.test"}','console.sso provider a issuer is https://op.example.test/a, not on rehearsal.invalid'],
[true,'"rehearsal-placeholder-secret\\n",{mode:0o600,flag:"wx"}','"other\\n",{mode:0o600,flag:"wx"}','console.sso provider a has no scratch placeholder secret'],
]){
let e;if(stamped)e=await smk(t);else{e=await rmk(t);e.copies=UCOPIES;await ssoConfig(e)}
ownScript(e,from,to);
const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','3');
assert.equal(r.code,1,why+r.out+r.err);
const prov=provOf(r.out);assert.equal(!!prov,stamped,r.out);
assert.ok(r.out.includes(`FAIL sso-isolated: ${why.replace('ORIGIN',prov?.origin)}\n`),r.out);
assert.deepEqual(r.names.slice(stamped?-4:-3),['FAIL sso-isolated',...(stamped?['PASS provider-stopped']:[]),'PASS backup-unchanged','PASS live-config-unchanged']);
assert.equal(existsSync(rh(e,'host-args.log')),false);assert.equal(existsSync(rh(e,'console-args.log')),false);
if(prov)assert.equal(existsSync(`/proc/${prov.pid}`),false);
await noLeak(e);
}
});
test('M3 R20 the rehearsal provider\'s failure controls: no start, no trust, no discovery, a token request, a signal and a SIGKILL each fail and stop it',async(t)=>{
const ends=['PASS backup-unchanged','PASS live-config-unchanged'];
// The provider cannot start, leaves its TLS key, offers a non-loopback origin or never writes one:
// provider-start fails before the scratch build, and no host or Console starts.
for(const [from,to,why] of [
['spawnSync("openssl"','spawnSync("openssl-missing-for-r20"','the rehearsal provider exited: rehearsal provider: cannot make the certificate: openssl exit ENOENT'],
['finally{fs.rmSync(tls,{force:true})}','finally{}','the rehearsal provider left its TLS key at RDIR/provider/tls-key.pem'],
['origin=`https://127.0.0.1:${','origin=`https://localhost:${','the rehearsal provider\'s origin https://localhost:PORT is not https on 127.0.0.1'],
['fs.renameSync(path.join(dir,"origin.tmp"),path.join(dir,"origin"));','','the rehearsal provider wrote no origin within 3s'],
]){
const e=await smk(t);ownScript(e,from,to);
const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','3');
assert.equal(r.code,1,why+r.out+r.err);assert.ok(r.dir,r.out+r.err);
const line=r.out.split('\n').find((l)=>l.startsWith('FAIL provider-start: '))??'';
const re=new RegExp(`^FAIL provider-start: ${why.replace(/[.*+?^${}()|[\]\\]/g,'\\$&').replace('RDIR',r.dir.replace(/[.*+?^${}()|[\]\\]/g,'\\$&')).replace('PORT','\\d+')}$`);
assert.match(line,re,r.out);
assert.deepEqual(r.names,[...PRE.slice(0,3),'FAIL provider-start',...(why.includes('exited')?[]:['PASS provider-stopped']),...ends],r.out);
assert.equal(existsSync(join(r.dir,'config')),false);
assert.equal(existsSync(rh(e,'host-args.log')),false);assert.equal(existsSync(rh(e,'console-args.log')),false);
}
// The Console cannot reach the provider (its discovery drops the connection, or the Console has
// no trust for its certificate): it starts degraded and the gate fails sso-ready.
for(const [from,to] of [
[' send(200,{issuer,authorization_endpoint',' return q.socket.destroy();send(200,{issuer,authorization_endpoint'],
['r_ca=(NODE_EXTRA_CA_CERTS="$R/provider/cert.pem")','r_ca=()'],
]){
const e=await smk(t);ownScript(e,from,to);
const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','5');
assert.equal(r.code,1,from+r.out+r.err);
assert.ok(r.out.includes('FAIL sso-ready: provider a is unreachable, not ready\n'),r.out);
assert.deepEqual(r.names,[...SPRE,'PASS migration','FAIL sso-ready',...SPOST],r.out);
assert.equal(existsSync(`/proc/${provOf(r.out).pid}`),false);
}
// A Console that reports ready without asking the provider, or asks it for a token: the gate
// passes and provider-discovery fails.
for(const [f,why] of [
['console-no-sso','no GET /a/.well-known/openid-configuration 200; no GET /a/jwks 200'],
['console-asks-token','a request for POST /a/token 403; a request for GET /a/authorize?client_id=rehearsal&response_type=code 403'],
]){
const e=await smk(t);rfault(e,f);
const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','10');
assert.equal(r.code,1,f+r.out+r.err);
assert.ok(r.out.includes(`FAIL provider-discovery: ${why}; see ${join(r.dir,'provider','requests.log')}\n`),r.out);
assert.deepEqual(r.names,[...SPRE,'PASS migration','PASS gate','PASS no-session','FAIL provider-discovery',...SPOST],r.out);
}
// A signal while the gate waits stops the Console, host and provider.
const e=await smk(t);rfault(e,`healthz-${B}`);
e.during=async(ch)=>{for(let i=0;i<200&&!existsSync(rh(e,'console-args.log'));i++)await new Promise((r)=>setTimeout(r,50));ch.kill('SIGTERM')};
const r=await rehearse(e,B,'--backup',e.act,'--port',await freePort(),'--timeout','30');
assert.equal(r.code,1,r.out+r.err);
assert.deepEqual(r.names,[...SPRE,'PASS migration','FAIL signal',...SPOST],r.out);
assert.equal(existsSync(`/proc/${provOf(r.out).pid}`),false);
// SIGKILL leaves no trap to run: the provider sees its parent gone and exits by itself.
const k=await smk(t);rfault(k,`healthz-${B}`);
k.during=async(ch)=>{for(let i=0;i<200&&!existsSync(rh(k,'console-args.log'));i++)await new Promise((r)=>setTimeout(r,50));ch.kill('SIGKILL')};
const rk=await rehearse(k,B,'--backup',k.act,'--port',await freePort(),'--timeout','30');
assert.equal(rk.code,null,rk.out+rk.err);
const pk=provOf(rk.out);assert.ok(pk,rk.out);
for(let i=0;i<60&&existsSync(`/proc/${pk.pid}`);i++)await new Promise((r)=>setTimeout(r,100));
assert.equal(existsSync(`/proc/${pk.pid}`),false,`provider pid ${pk.pid} outlived the killed rehearsal`);
});
// --- Row 233 (#1735): --backup last, the newest backup the log records (sage-1650, sage-1653) ---
// The log's lines, as the runs write them, with only the fields from_log reads set.
@@ -2601,7 +2984,8 @@ test('row196 explicit socket conversion refuses inherited TCP before stop and ro
assert.equal(r.code,3,r.out+r.err);assert.match(r.err,/tester-socket-path/);assert.deepEqual(startsStops(long,before),[]);
});
// Row196 merge guard: without --send, SSO insertion must still precede the socket flag.
// Row196 merge guard, M3 (#1873): a user-unit --sso --tester-socket refuses accounts-user-unit; root's
// unit for the copy carries --sso before the socket flag, and the pointer and status pin that order.
test('row196 SSO socket conversion pins pointer and unit argv order without send',async t=>{
const e=await mk(t);await seedPreview(e,{copy:B});await ssoConfig(e);
const file=join(COPIES,C1,'packages/webui/src/cli.mjs'),original=readFileSync(file,'utf8');
@@ -2611,8 +2995,9 @@ test('row196 SSO socket conversion pins pointer and unit argv order without send
chmodSync(dirname(module),0o755);
writeFileSync(module,'export const inspectInterpreter=()=>({path:"/usr/bin/python3",sha256:"fixture"});export const verifyInterpreterReceipt=()=>true;\n');chmodSync(module,0o444);chmodSync(dirname(module),0o555);
t.after(()=>{chmodSync(dirname(module),0o755);rmSync(module);chmodSync(dirname(module),0o555);});
const r=await prun(e,'activate',C1,'acme','--sso','--tester-socket');assert.equal(r.code,0,r.out+r.err);
await auu(e,['activate',C1,'acme','--sso','--tester-socket'],'activate');
const want=['--port',e.port,'--business','acme','--sso','--tester-socket'];
await viaSystem(e,'activate',C1,want);
assert.deepEqual(pointer(e).console,want);
assert.ok(conExec(e).endsWith(` --checkout ${repo} ${want.join(' ')}`),conExec(e));
assert.doesNotMatch(conExec(e),/ --send(?: |$)|--tester-port|--public-host|--proof-header/);
+127 -7
View File
@@ -47,12 +47,19 @@ function fakeSudo(K){
// case2-unit.sh: logs its argv, then installs the copy's units as the fixture's user units (its
// WorkingDirectory under the stack's copies) and starts the optional proxy, bus, then Console,
// as case2-unit.sh does. The business is --business's value, or --org's when that is the spelling. faults/render-fails-<copy> exits 1 first; faults/render-probe-<copy> (one-shot) first runs
// as case2-unit.sh does. The business is --business's value, or --org's when that is the spelling. A first
// --check or --check-new (#1873) logs its argv and exits 1 under faults/check-fails-<copy>, else 0,
// writing nothing. faults/render-fails-<copy> exits 1 first; faults/render-probe-<copy> (one-shot) first runs
// probe.sh, a second driver, and records its exit and stderr.
function fakeRender(K){
const fs=process.getBuiltinModule('node:fs'),path=process.getBuiltinModule('node:path'),cp=process.getBuiltinModule('node:child_process');
const a=process.argv.slice(2),sd=process.env.SD_STATE,f=(n)=>path.join(sd,'faults',n);
fs.appendFileSync(path.join(sd,'render.log'),JSON.stringify(a)+'\n');
if(a[0]==='--check'||a[0]==='--check-new'){
const c=a[a.indexOf('--copy')+1];
if(fs.existsSync(f(`check-fails-${c}`))){console.error(`fixture render: ${a[0]} ${c} fails`);process.exit(1)}
process.exit(0);
}
const cut=a.indexOf('--'),pre=a.slice(0,cut),serve=a.slice(cut+1),get=(k)=>pre[pre.indexOf(k)+1];
const copy=get('--copy'),copies=path.join(get('--home'),'.local','share','mosaic-stack','copies');
if(fs.existsSync(f(`render-probe-${copy}`))){
@@ -147,7 +154,7 @@ const arm=(e,key,text)=>writeFileSync(join(e.sd,'arm',key),text);
// The step log: every line `<time> <step> rc=N ...` or the result line, root:root 0640 (here the
// test user's), and nothing the stack's user printed. Returns the lines without the time.
const STEP=/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ (start|fetch|ff|copy|hostcheck|units|plan|rehearse|rehearse-store|rehearsal-skipped|stop-console|stop-bus|stop-proxy|wait-down|stage|render|confirm|args|revert|render-old|confirm-rollback|gate|start-proxy|start-bus|start-console|stop-units) rc=\d+ |^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ result exit=[0-35] [a-z-]+ /;
const STEP=/^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ (start|fetch|ff|copy|hostcheck|units|plan|rehearse|rehearse-store|rehearsal-skipped|render-old-check|render-check|stop-console|stop-bus|stop-proxy|wait-down|units-aside|units-back|daemon-reload|next-args|stage|render|confirm|args|revert|render-old|confirm-rollback|gate|start-proxy|start-bus|start-console|stop-units) rc=\d+ |^\d{4}-\d\d-\d\dT\d\d:\d\d:\d\dZ result exit=[0-35] [a-z-]+ /;
function slog(e){
const l=lines(e.slog);
for(const x of l){assert.match(x,STEP);assert.ok(!/stack-release:|fixture rehearse|PASS |FAIL |REHEARSAL/.test(x),x)}
@@ -165,7 +172,7 @@ const live=(e)=>[units(e)[e.bus].state,units(e)[e.con].state,units(e)[e.bus].wd.
// Nothing the run did reached the args file: the same bytes and mode, and no temporary file left.
const argsKept=(e)=>{assert.equal(readFileSync(e.args,'utf8'),e.argsText);assert.equal(statSync(e.args).mode&0o777,0o640);assert.deepEqual(readdirSync(e.etc),['dev.args'])};
const argsCopy=(e)=>{const l=readFileSync(e.args,'utf8').split('\n');return l[l.indexOf('--copy')+1]};
const PRE=['start','fetch','ff','copy','hostcheck','units','plan','rehearse','rehearse-store'];
const PRE=['start','fetch','ff','copy','hostcheck','units','plan','render-old-check','render-check','rehearse','rehearse-store'];
const A2=['stop-console','stop-bus','wait-down','wait-down'];
const R=[...A2,'revert','render-old','confirm-rollback'];
@@ -394,7 +401,7 @@ test('row 233: sequence 7: two upgrades and a rollback --to the previous copy wi
const rb=await drive(e,C,'rollback','--instance','dev','--to',C1,'--restore',a2,...TO);assert.equal(rb.code,0,rb.all);
assert.equal(rb.out.split('\n').at(-2),`stack-upgrade: rollback ok: ${C1} is live for acme`);
// A rollback fetches, copies and rehearses nothing.
assert.deepEqual(names(e).slice(n),['start','hostcheck','units','plan',...A2,'stage','render','confirm','args','result']);
assert.deepEqual(names(e).slice(n),['start','hostcheck','units','plan','render-old-check','render-check',...A2,'stage','render','confirm','args','result']);
const ra=lastAct(e),l3=final(e,ra);assert.deepEqual([l3.op,l3.result,l3.from,l3.to,l3.backup],['rollback','ok',C,C1,a2]);
assert.deepEqual([pointer(e).copy,pointer(e).schema],[C1,'3g']);assert.equal(storeVersion(e.store,true),'3g');
assert.equal(argsCopy(e),C1);assert.deepEqual(live(e),['active','active',C1,C1]);
@@ -632,7 +639,9 @@ test('rename r2: an upgrade from an args file that spells both selectors --org k
const after=e.argsText.replace(`--copy\n${B}\n`,`--copy\n${C1}\n`);assert.notEqual(after,e.argsText);
assert.equal(readFileSync(e.args,'utf8'),after);
assert.equal(statSync(e.args).mode&0o777,0o640);assert.deepEqual(readdirSync(e.etc),['dev.args']);
assert.deepEqual(lines(join(e.sd,'render.log')).map((x)=>JSON.parse(x)),[after.trimEnd().split('\n')]);
// #1873: before anything stops, --check renders the installed copy, then the target, from the same lines.
const al=after.trimEnd().split('\n');
assert.deepEqual(lines(join(e.sd,'render.log')).map((x)=>JSON.parse(x)),[['--check',...e.argsText.trimEnd().split('\n')],['--check',...al],al]);
// The stack's user's steps get the business as a word, never a spelling.
for(const k of ['activate-plan','rehearse','activate','confirm'])assert.ok(JSON.parse(lines(join(e.sd,'sudo.log')).find((x)=>JSON.parse(x).key===k)).cmd.includes('acme'),k);
assert.ok(!lines(join(e.sd,'sudo.log')).some((x)=>JSON.parse(x).cmd.includes('--org')));
@@ -718,12 +727,25 @@ const baseCase2=()=>{
// read. args(copy, extra) is a library args file's lines without --send; legacy() renders base's pair into dir.
function case2Host(e,dir){
const home=e.env.HOME,secrets=join(home,'.config','mosaic-dev','secrets');mkdirSync(secrets,{recursive:true,mode:0o700});
copyFileSync(e.cfg,join(home,'.config','mosaic-dev','config.json'));
const proof=join(secrets,'proxy-proof');writeFileSync(proof,'made-up-proof\n');chmodSync(proof,0o600);
const c2=join(e.root,'case2'),bin=join(c2,'bin'),log=join(c2,'systemctl.log');mkdirSync(bin,{recursive:true});mkdirSync(dir,{recursive:true});
const uid=process.getuid(),gid=process.getgid();
writeFileSync(join(bin,'getent'),`#!/bin/sh\ncase "$1 $2" in\n "passwd fixture") echo "fixture:x:${uid}:${gid}::${home}:/bin/bash" ;;\n "group ${gid}") echo "fixture:x:${gid}:" ;;\n *) exit 2 ;;\nesac\n`,{mode:0o755});
writeFileSync(join(bin,'getent'),`#!/bin/sh\ncase "$1 $2" in\n "passwd fixture"|"passwd ") echo "fixture:x:${uid}:${gid}::${home}:/usr/sbin/nologin" ;;\n "group ${gid}") echo "fixture:x:${gid}:" ;;\n *) exit 2 ;;\nesac\n`,{mode:0o755});
writeFileSync(join(bin,'systemctl'),`#!/bin/sh\necho "$*" >> '${log}'\n[ "$1" != is-active ] || exit 3\n`,{mode:0o755});
const env={CASE2_UNIT_DIR:dir,CASE2_GETENT:join(bin,'getent'),CASE2_SYSTEMCTL:join(bin,'systemctl')};
// M3: the current renderer queries the config under the selected uid for every
// profile. These copies deliberately stub the CLI; give that query its own
// fixture loader and sudo rather than invoking the machine's sudo.
for(const id of [B,C1,C]){
const d=join(COPIES,id),packages=join(d,'packages'),cli=join(packages,'cli'),src=join(cli,'src');
if(!existsSync(join(src,'config.mjs'))){
chmodSync(d,0o755);chmodSync(packages,0o755);mkdirSync(src,{recursive:true});
writeFileSync(join(src,'config.mjs'),`import fs from 'node:fs';import os from 'node:os';import path from 'node:path';export const loadSystem=()=>JSON.parse(fs.readFileSync(path.join(os.homedir(),'.config/mosaic-dev/config.json'),'utf8'));`);
chmodSync(join(src,'config.mjs'),0o444);chmodSync(src,0o555);chmodSync(cli,0o555);chmodSync(packages,0o555);chmodSync(d,0o555);
}
}
writeFileSync(join(bin,'sudo'),'#!/bin/sh\n[ "$1" = -u ] && [ "$2" = fixture ] && [ "$3" = -H ] || exit 97\nshift 3\nexec "$@"\n',{mode:0o755});
const env={PATH:`${bin}:${e.env.PATH}`,CASE2_UNIT_DIR:dir,CASE2_GETENT:join(bin,'getent'),CASE2_SYSTEMCTL:join(bin,'systemctl')};
const serve=['--port','443','--business','acme','--public-host','books.example','--auth-header','X-Auth-User','--allow-user','melanie','--proof-header','X-Proxy-Proof','--proof-file',proof];
const args=(copy=B,extra=[])=>['--user','fixture','--home',home,'--business','acme','--copy',copy,'--node',RENDER_NODE,...extra,'--',...serve];
const render=(script,a)=>{const r=spawnSync('bash',[script,...a],{env:{PATH:e.env.PATH,...env},encoding:'utf8'});return {code:r.status,out:r.stdout,err:r.stderr}};
@@ -829,3 +851,101 @@ test('row 233: usage exits 4 before any log or lock',async(t)=>{
}
assert.ok(!existsSync(dirname(e.slog)));assert.ok(!existsSync(e.run));
});
// --- #1873 M3: render checks before anything stops, and --next-args ---
// The installed units' bytes, for the byte-for-byte checks across a moved-aside render.
const unitBytes=(e)=>Object.fromEntries([e.tmpl,e.con].map((u)=>[u,readFileSync(join(e.units,u),'utf8')]));
const renders=(e)=>lines(join(e.sd,'render.log')).map((l)=>JSON.parse(l));
const copyOf=(a)=>a[a.indexOf('--copy')+1];
// The next args: the args file's lines with the serve flags in another order, root's 0640.
const nextText=(e)=>e.argsText.replace(`--\n--port\n${e.port}\n--business\nacme\n`,`--\n--business\nacme\n--port\n${e.port}\n`);
const writeNext=(e,text=nextText(e),mode=0o640)=>{const f=join(e.etc,'dev.args.next');writeFileSync(f,text);chmodSync(f,mode);return f};
const replacedDirs=(e)=>readdirSync(e.etc).filter((n)=>/^dev\.replaced\.[A-Za-z0-9]{6}$/.test(n)).sort();
test('#1873: a target whose render check fails refuses render-refused, naming --next-args, before anything stops',async(t)=>{
const e=await useed(t);
fault(e,`check-fails-${C1}`);
const r=await upgrade(e,C1);assert.equal(r.code,3,r.all);
assert.deepEqual(names(e),['start','fetch','ff','copy','hostcheck','units','plan','render-old-check','render-check','result']);
assert.match(result(e),new RegExp(`^result exit=3 render-refused case2-unit.sh --check refuses ${C1}'s units from .*dev\\.args; .*takes --next-args .*\\(nothing was stopped\\)$`));
assert.deepEqual(renders(e).map((a)=>[a[0],copyOf(a)]),[['--check',B],['--check',C1]]);
assert.ok(!existsSync(join(e.sd,'root-systemctl.log')));assert.deepEqual(live(e),['active','active',B,B]);argsKept(e);
});
test('#1873: an installed copy whose render check fails refuses at render-old-check, before the target\'s check',async(t)=>{
const e=await useed(t);
fault(e,`check-fails-${B}`);
const r=await upgrade(e,C1);assert.equal(r.code,3,r.all);
assert.deepEqual(names(e),['start','fetch','ff','copy','hostcheck','units','plan','render-old-check','result']);
assert.match(result(e),new RegExp(`^result exit=3 render-refused case2-unit.sh --check refuses ${B}'s units from .*dev\\.args, so a rollback could not render them`));
assert.deepEqual(renders(e).map((a)=>[a[0],copyOf(a)]),[['--check',B]]);
assert.ok(!existsSync(join(e.sd,'root-systemctl.log')));assert.deepEqual(live(e),['active','active',B,B]);argsKept(e);
});
test('#1873: --next-args moves the old units aside, renders the new flags as a first install, keeps the old units and args, and rolls back the same way',async(t)=>{
const e=await useed(t);
const before=unitBytes(e),next=nextText(e);writeNext(e);
const r=await upgrade(e,C1,'--next-args');assert.equal(r.code,0,r.all);
assert.deepEqual(names(e),[...PRE,...A2,'stage','units-aside','render','confirm','next-args','args','result']);
const [d]=replacedDirs(e);assert.ok(d,readdirSync(e.etc).join(' '));
assert.equal(result(e),`result exit=0 ok upgrade ok: ${C1} is live for acme; the replaced units and args file are in ${join(e.etc,d)}`);
assert.deepEqual(readdirSync(e.etc).sort(),['dev.args',d]);
assert.equal(readFileSync(e.args,'utf8'),next.replace(`--copy\n${B}\n`,`--copy\n${C1}\n`));assert.equal(statSync(e.args).mode&0o777,0o640);
const rd=join(e.etc,d);assert.equal(statSync(rd).mode&0o777,0o700);
assert.deepEqual(readdirSync(rd).sort(),['dev.args',e.tmpl,e.con].sort());
for(const [u,text] of Object.entries(before))assert.equal(readFileSync(join(rd,u),'utf8'),text,u);
assert.equal(readFileSync(join(rd,'dev.args'),'utf8'),e.argsText);assert.equal(statSync(join(rd,'dev.args')).mode&0o777,0o640);
const serve=(a)=>a.slice(a.indexOf('--')+1);
const rl=renders(e);assert.deepEqual(rl.map((a)=>[a[0],copyOf(a)]),[['--check',B],['--check-new',C1],['--user',C1]]);
assert.deepEqual(serve(rl[0]),['--port',String(e.port),'--business','acme']);
assert.deepEqual(serve(rl[1]),['--business','acme','--port',String(e.port)]);assert.deepEqual(serve(rl[2]),serve(rl[1]));
assert.deepEqual(live(e),['active','active',C1,C1]);
// The run sheet's rollback: the replaced args file as the next args, then rollback --next-args.
copyFileSync(join(rd,'dev.args'),join(e.etc,'dev.args.next'));chmodSync(join(e.etc,'dev.args.next'),0o640);
const after=unitBytes(e),m=slog(e).length,n=renders(e).length;
const rb=await drive(e,C1,'rollback','--instance','dev','--to',B,'--next-args',...TO);assert.equal(rb.code,0,rb.all);
assert.deepEqual(names(e).slice(m),['start','hostcheck','units','plan','render-old-check','render-check',...A2,'stage','units-aside','render','confirm','next-args','args','result']);
assert.deepEqual(renders(e).slice(n).map((a)=>[a[0],copyOf(a)]),[['--check',C1],['--check-new',B],['--user',B]]);
assert.equal(readFileSync(e.args,'utf8'),e.argsText);assert.deepEqual(live(e),['active','active',B,B]);
const d2=replacedDirs(e).find((x)=>x!==d);assert.ok(d2);assert.deepEqual(readdirSync(e.etc).sort(),['dev.args',d,d2].sort());
for(const [u,text] of Object.entries(after))assert.equal(readFileSync(join(e.etc,d2,u),'utf8'),text,u);
assert.equal(readFileSync(join(e.etc,d2,'dev.args'),'utf8'),next.replace(`--copy\n${B}\n`,`--copy\n${C1}\n`));
});
test('#1873: a failed render under --next-args moves the old units back unchanged and restarts them, with no render of the old copy: exit 1',async(t)=>{
const e=await useed(t);
const before=unitBytes(e),next=nextText(e);writeNext(e);
fault(e,`render-fails-${C1}`);
const r=await upgrade(e,C1,'--next-args');assert.equal(r.code,1,r.all);
assert.deepEqual(names(e),[...PRE,...A2,'stage','units-aside','render',...A2,'revert','units-back','daemon-reload','start-bus','start-console','confirm-rollback','result']);
assert.match(result(e),new RegExp(`^result exit=1 failed A4 failed; rolled back to ${B}`));
assert.deepEqual(renders(e).map((a)=>[a[0],copyOf(a)]),[['--check',B],['--check-new',C1],['--user',C1]]);
for(const [u,text] of Object.entries(before))assert.equal(readFileSync(join(e.units,u),'utf8'),text,u);
assert.deepEqual(readdirSync(e.etc).sort(),['dev.args','dev.args.next']);
assert.equal(readFileSync(e.args,'utf8'),e.argsText);assert.equal(readFileSync(join(e.etc,'dev.args.next'),'utf8'),next);
assert.equal(final(e,lastAct(e)).rollback,'ok');assert.deepEqual(live(e),['active','active',B,B]);
});
test('#1873: the next args must be root\'s, equal before -- but for --copy, differ after it, and render as a first install',async(t)=>{
const e=await useed(t);
const tryNext=async(text,mode,code,why,at='plan')=>{
if(text===null)rmSync(join(e.etc,'dev.args.next'),{force:true});else writeNext(e,text,mode);
const n=existsSync(e.slog)?slog(e).length:0;
const r=await upgrade(e,C1,'--next-args');assert.equal(r.code,3,`${why}: ${r.all}`);
assert.match(slog(e).at(-1),new RegExp(`^result exit=3 ${code} `),why);
const ran=names(e).slice(n);assert.ok(!ran.some((x)=>/^(stop|stage|units-aside|render$|confirm)/.test(x)),`${why}: ${ran}`);
if(at)assert.equal(ran.at(-2),at,`${why}: ${ran}`);
};
await tryNext(null,0,'args-unreadable','no next file','start');
await tryNext(nextText(e),0o660,'args-unreadable','group write','start');
await tryNext(nextText(e).replace('--home\n','--pm\n--home\n'),0o640,'next-args-differ','a flag before --','start');
await tryNext(nextText(e).replace('--user\nfixture\n','--user\nother\n'),0o640,'next-args-differ','another user','start');
await tryNext(e.argsText,0o640,'next-args-same','the same lines','start');
await tryNext(e.argsText.replace(`--copy\n${B}\n`,`--copy\n${C}\n`),0o640,'next-args-same','only --copy differs','start');
fault(e,`check-fails-${C1}`);
await tryNext(nextText(e),0o640,'render-refused','the first-install check','render-check');
assert.match(result(e),new RegExp(`^result exit=3 render-refused case2-unit.sh --check-new refuses ${C1}'s units from .*dev\\.args\\.next`));
assert.ok(!existsSync(join(e.sd,'root-systemctl.log')));assert.deepEqual(live(e),['active','active',B,B]);
assert.equal(readFileSync(e.args,'utf8'),e.argsText);assert.deepEqual(replacedDirs(e),[]);
});
+4 -4
View File
@@ -96,7 +96,7 @@ function onlyKeys(obj, allowed, where) {
function requireString(obj, key, where, pattern, what) {
const v = obj[key];
if (typeof v !== "string" || v.length === 0) throw new DiscordError(`${where}: ${key} must be a non-empty string`);
if (pattern && !pattern.test(v)) throw new DiscordError(`${where}: ${key} is not ${what}: ${JSON.stringify(v)}`);
if (pattern && !pattern.test(v)) throw new DiscordError(`${where}: ${key} is not ${what}`);
return v;
}
@@ -148,7 +148,7 @@ export function validateBinding(raw, where = "binding") {
if (!Array.isArray(u.channels) || u.channels.length === 0) throw new DiscordError(`${w}: channels must be a non-empty array of listed channel ids`);
allowed = u.channels.map((cid, j) => {
if (typeof cid !== "string" || !SNOWFLAKE.test(cid)) throw new DiscordError(`${w}.channels[${j}]: not a Discord snowflake id`);
if (!channels.some((c) => c.id === cid)) throw new DiscordError(`${w}.channels[${j}]: ${cid} is not a listed channel`);
if (!channels.some((c) => c.id === cid)) throw new DiscordError(`${w}.channels[${j}]: is not a listed channel`);
return cid;
});
if (new Set(allowed).size !== allowed.length) throw new DiscordError(`${w}: duplicate channel id`);
@@ -204,7 +204,7 @@ export function validateBinding(raw, where = "binding") {
const rname = requireString(r, "name", w, ROOT_NAME, "a root name");
const rpath = requireString(r, "path", w);
if (!isAbsolute(rpath) || rpath.includes("\0")) throw new DiscordError(`${w}: path must be an absolute path`);
if (rpath.split(sep).some((seg) => seg.startsWith(".") && seg.length > 0)) throw new DiscordError(`${w}: path must not have a dot-prefixed segment (${rpath})`);
if (rpath.split(sep).some((seg) => seg.startsWith(".") && seg.length > 0)) throw new DiscordError(`${w}: path must not have a dot-prefixed segment`);
if (resolve(rpath) === sep || resolve(rpath) === homedir()) throw new DiscordError(`${w}: path must not be the filesystem root or the home directory`);
if (r.write !== undefined && r.write !== true && r.write !== false) throw new DiscordError(`${w}: write must be true or false`);
let git = null;
@@ -343,7 +343,7 @@ function setsparkConfig(raw, where, users) {
const approvers = {};
for (const u of users) {
const name = u.name.trim().toLowerCase();
if (Object.hasOwn(approvers, name)) throw new DiscordError(`${where}: two users named ${name}; approver names must be distinct`);
if (Object.hasOwn(approvers, name)) throw new DiscordError(`${where}: two users share a name; approver names must be distinct (case-insensitive)`);
approvers[name] = u.id;
}
try {
+1 -1
View File
@@ -46,7 +46,7 @@ export function createGateway({
random = Math.random,
properties = { os: process.platform, browser: "mosaic-stack", device: "mosaic-stack" },
} = {}) {
if (typeof url !== "string" || !url.startsWith("wss://")) throw new DiscordError(`gateway: url must be wss://, got ${JSON.stringify(url)}`, 1);
if (typeof url !== "string" || !url.startsWith("wss://")) throw new DiscordError(`gateway: url must be wss://`, 1);
if (typeof token !== "string" || token.length === 0) throw new DiscordError("gateway: token required", 1);
if (typeof WebSocketImpl !== "function") throw new DiscordError("gateway: WebSocket implementation required", 1);
+3 -2
View File
@@ -131,7 +131,8 @@ export function writeTurn(dir, id, record) {
}
export function readTurn(dir, id) {
return JSON.parse(readFileSync(turnPath(dir, id), "utf8"));
const path = turnPath(dir, id);
return parseJsonFileText(readFileSync(path, "utf8"), path, "turn record");
}
export function listTurns(dir) {
@@ -139,7 +140,7 @@ export function listTurns(dir) {
if (!existsSync(turns)) return [];
return readdirSync(turns)
.filter((f) => f.endsWith(".json"))
.map((f) => JSON.parse(readFileSync(join(turns, f), "utf8")));
.map((f) => parseJsonFileText(readFileSync(join(turns, f), "utf8"), join(turns, f), "turn record"));
}
// The daily ceiling counts admissions on the current UTC date. An admission
+1 -1
View File
@@ -96,7 +96,7 @@ export function loadSetsparkConfig(raw, where = "setspark") {
function loadApprovers(raw, where) {
if (!isObject(raw) || Object.keys(raw).length > 64) throw new Error(`${where}: must be an object of at most 64 names`);
for (const [name, id] of Object.entries(raw)) {
if (name.length === 0 || name.length > 64 || name !== name.trim().toLowerCase() || /[\u0000-\u001f\u007f]/.test(name)) throw new Error(`${where}: ${JSON.stringify(name.slice(0, 64))} must be a trimmed lower-case name`);
if (name.length === 0 || name.length > 64 || name !== name.trim().toLowerCase() || /[\u0000-\u001f\u007f]/.test(name)) throw new Error(`${where}: an approver name must be trimmed, lower-case, 1-64 characters, with no control characters`);
if (typeof id !== "string" || !SNOWFLAKE.test(id)) throw new Error(`${where}.${name}: must be a Discord user id`);
}
if (new Set(Object.values(raw)).size !== Object.keys(raw).length) throw new Error(`${where}: one Discord user id under two names`);
@@ -0,0 +1,43 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const secret = 'sk-test-NOT-A-SECRET-1234';
function scratch(t) { const root = mkdtempSync(join(tmpdir(), 'r4-')); t.after(() => rmSync(root, { recursive: true, force: true })); return root; }
function safe(label, Type, props = {}, omitted = secret) { return error => {
assert.ok(error instanceof Type, error.stack);
for (const [key, value] of Object.entries(props)) assert.equal(error[key], value);
assert.ok(error.message.includes(label), error.message);
assert.doesNotMatch(error.message + error.stack, /sk-test|NOT-A-SECRET|Unexpected token/);
assert.ok(!error.message.includes(omitted), error.message);
assert.equal(error.cause, undefined);
return true;
}; }
import { validateBinding } from '../src/binding.mjs';
import { createGateway } from '../src/gateway.mjs';
import { readTurn, listTurns } from '../src/journal.mjs';
import { DiscordError } from '../src/errors.mjs';
import { rawBinding } from './helpers.mjs';
for (const [field, edit] of [
['guildId', d => { d.guildId = secret; }],
['name', d => { d.name = secret + '/'; }],
['path', d => { d.tools = { roots: [{ name: 'root', path: `/x/.${secret}`, write: false }] }; }],
]) test(`binding ${field} refusal omits its configured value`, () => {
const d = rawBinding(); edit(d); assert.throws(() => validateBinding(d), safe(field, DiscordError, { exitCode: 2 }));
});
test('gateway url refusal omits its value', () => assert.throws(() => createGateway({ url: secret, token: 'fake' }), safe('url', DiscordError, { exitCode: 1 })));
for (const mode of ['read', 'list']) test(`turn ${mode} parser omits file bytes and keeps SyntaxError`, t => {
const root = scratch(t); mkdirSync(join(root, 'turns'));
const file = join(root, 'turns', 'turn-1.json'); writeFileSync(file, secret);
assert.throws(() => mode === 'read' ? readTurn(root, 'turn-1') : listTurns(root), safe(file, SyntaxError));
});
for (const duplicate of [true, false]) test(`binding approver ${duplicate ? 'duplicate' : 'invalid'} name omits user values`, t => {
const root = scratch(t), keyFile = join(root, 'fixture-key'); writeFileSync(keyFile, 'fixture\n', { mode: 0o600 });
const name = secret.toLowerCase() + (duplicate ? '' : '\u0001');
const users = [{ id: '100000000000000100', name }];
if (duplicate) users.push({ id: '100000000000000101', name });
const raw = rawBinding({ users, tools: { roots: [{ name: 'docs', path: root }], setspark: { baseUrl: 'https://api.setspark.io', keyFile, principal: 'sage' } } });
assert.throws(() => validateBinding(raw), safe(duplicate ? 'users' : 'approvers', DiscordError, { exitCode: 2 }, secret.toLowerCase()));
});
+1 -1
View File
@@ -157,7 +157,7 @@ test("setspark config: approvers come from the binding's users, never from the b
const b = validateBinding(rawBinding({ users, tools: { roots: [{ name: "docs", path: docs }], setspark: { baseUrl: "https://api.setspark.io", keyFile: kf, principal: "sage" } } }));
assert.deepEqual(b.tools.setspark.approvers, { jason: "100000000000000100", carmen: "100000000000000101" });
assert.throws(() => validateBinding(rawBinding({ users, tools: { roots: [{ name: "docs", path: docs }], setspark: { baseUrl: "https://api.setspark.io", keyFile: kf, principal: "sage", approvers: { mallory: "100000000000000199" } } } })), /approvers come from users/);
assert.throws(() => validateBinding(rawBinding({ users: [{ id: "100000000000000100", name: "Jason" }, { id: "100000000000000101", name: "jason" }], tools: { roots: [{ name: "docs", path: docs }], setspark: { baseUrl: "https://api.setspark.io", keyFile: kf, principal: "sage" } } })), /two users named jason/);
assert.throws(() => validateBinding(rawBinding({ users: [{ id: "100000000000000100", name: "Jason" }, { id: "100000000000000101", name: "jason" }], tools: { roots: [{ name: "docs", path: docs }], setspark: { baseUrl: "https://api.setspark.io", keyFile: kf, principal: "sage" } } })), /two users share a name/);
const withCarmenOut = validateBinding(rawBinding({ users: [users[0]], tools: { roots: [{ name: "docs", path: docs }], setspark: { baseUrl: "https://api.setspark.io", keyFile: kf, principal: "sage" } } }));
assert.throws(() => reloadDiff(b, withCarmenOut), /tools cannot change/, "pi's approvers are fixed at start, so a user change needs a restart");
const channelsOnly = validateBinding(rawBinding({ users: [{ ...users[0], channels: [rawBinding().channels[0].id] }, users[1]], tools: { roots: [{ name: "docs", path: docs }], setspark: { baseUrl: "https://api.setspark.io", keyFile: kf, principal: "sage" } } }));
+3 -1
View File
@@ -14,6 +14,7 @@ import { createHash } from "node:crypto";
import { closeSync, constants, fstatSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { railsText, ownRails } from "../../organization/src/rails.mjs";
import { typedTools, untypedActions } from "./tools.mjs";
import { route as defaultProxyRoute } from "../../plugins/model-provider/cliproxy/index.mjs";
import { CREATIVE_DEFINITIONS } from "../../organization/src/vocabulary.mjs";
@@ -281,7 +282,7 @@ function prompt({ contract, resolved, session, tools, untyped, builtins, persona
...checkoutLines(session.checkouts ?? []),
// Row 89 (#1583): the names the harness gives its built-ins, as the manifest lists them.
`- Built-in tools: ${builtins.join(", ") || "none"}.`,
`- Bus tools: ${tools.map((t) => t.name).join(", ")}. The broker decides every call; a refusal names its reason.`,
`- Bus tools: ${tools.map((t) => t.name).join(", ")}. The broker decides every call; a refusal carries its code, the rail it enforces and the next step (plus a reason when available).`,
"- Each message to you arrives as one turn. Your final answer goes back to the sender as the reply, so end each turn with the answer itself.",
"- You never hold a token. You act on the tracker, the forge and other roles only through the bus tools.",
];
@@ -306,6 +307,7 @@ function prompt({ contract, resolved, session, tools, untyped, builtins, persona
if (named.has("request_staff"))
lines.push("- Staff: you don't launch sessions. For intake or a task, request_staff takes exactly one of request or task_ref and names each role with how many. The chief of staff answers by message with the instances it launched, or with the refusal and its reason.");
if (untyped.length) lines.push(`- Actions you hold that have no bus tool yet: ${untyped.join(", ")}. Say so instead of working around it.`);
lines.push("", railsText(resolved.rails ?? ownRails({ instance: resolved.instance, authority: resolved.limits.authority, network: resolved.limits.network })), "");
return lines.join("\n") + "\n";
}
+16 -2
View File
@@ -33,6 +33,7 @@
// capability or the claim stopped working (ended, revoked), 23 the broker
// stayed unreachable, 2 a bad run directory.
import { refusalGuidance } from "../../bus/src/refusals.mjs";
import { spawn } from "node:child_process";
import { setTimeout as delay } from "node:timers/promises";
import { chmodSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, closeSync } from "node:fs";
@@ -366,6 +367,7 @@ export function toolServer({ path, tools, client, local }) {
answer({ ok: true, result });
} catch (e) {
answer({ ok: false, error: /^[a-z-]{1,64}$/.test(e.code ?? "") ? e.code : "tool-failed",
...refusalGuidance(e.code),
...(typeof e.reason === "string" && request.tool === "advance_request" && tool.verb === "request.advance" && e.code === "request-evidence-required"
? { reason: e.reason.slice(0, 300) } : tool.verb === "record.write" && e.code === "project-record-invalid" && typeof e.reason === "string"
? { reason: e.reason.slice(0, 300) } : {}) });
@@ -523,14 +525,20 @@ export async function main(runDir, { stdin = process.stdin, env = process.env, l
process.on("SIGINT", stop);
let session, cap, policy;
let input = "session.json";
try {
session = { ...DEFAULTS, ...JSON.parse(readFileSync(join(runDir, "session.json"), "utf8")), runDir,
proxyFirstTurn: existsSync(join(runDir, "proxy-first-turn-enabled")) };
input = "stdin capability";
cap = JSON.parse(await readLine(stdin)).cap;
if (typeof cap !== "string" || !/^[0-9a-f]{64}$/.test(cap)) throw new Error("no capability on stdin");
input = "policy.json";
policy = JSON.parse(readFileSync(session.bundle.policy, "utf8"));
} catch (e) {
log(`runner: ${e.message}`);
const reason = e.message === "stdin too large" ? "stdin too large (expected at most 4096 characters)"
: e.message === "no capability on stdin" ? "no capability on stdin (expected cap with 64 lowercase hex characters)"
: `${input} must be readable JSON with the expected session, capability or policy fields`;
log(`runner: ${e.code ? `${e.code}: ` : ""}${reason}`);
return EXIT.usage;
}
if (!landingOk(session.landing)) {
@@ -567,7 +575,13 @@ export async function main(runDir, { stdin = process.stdin, env = process.env, l
}
log(`runner: ${session.business}/${session.instance} claimed by run ${session.run}`);
const tools = JSON.parse(readFileSync(session.bundle.tools, "utf8"));
let tools;
try {
tools = JSON.parse(readFileSync(session.bundle.tools, "utf8"));
} catch (error) {
if (error instanceof SyntaxError) throw new SyntaxError("tools.json must be valid JSON");
throw error;
}
const server = await toolServer({
path: session.toolSocket,
tools,
+10 -2
View File
@@ -10,6 +10,7 @@
// socket (MOSAIC_TOOL_SOCKET) with `{tool, args}`; the runner checks the
// name against this list and forwards the call with its own capability.
import { refusalGuidance } from "../../bus/src/refusals.mjs";
import { connect } from "node:net";
import { ACTIONS, ID_PATTERN, THREAD_DEFINITION, CREATIVE_DEFINITIONS } from "../../organization/src/vocabulary.mjs";
@@ -372,10 +373,11 @@ export function typedTools({ resolved, arbiter = false, tracker = false, project
requirement_snapshot: str("Exact approved snapshot ID; omit to read the active snapshot"),
}));
}
add("my_rails", "my_rails", null, "Read only your current actions, routes, resolve levels, network, credential service/scope names and messaging limits. No values or other agents' rails.", obj({}));
// mos-1308 / M3: no generic spec, evidence, tracker, decision or write tools. The
// C2-08 web grant still supplies broker web reads to companion and research only.
if (CREATIVE_DEFINITIONS.includes(resolved.definition))
return tools.filter(tool => tool.name === "send_message" || tool.name.startsWith("library_") ||
return tools.filter(tool => tool.name === "my_rails" || tool.name === "send_message" || tool.name.startsWith("library_") ||
tool.name === "web_search" || tool.name === "web_fetch");
return tools;
}
@@ -429,7 +431,13 @@ export function callTool(path, tool, args = {}, { timeout = 60_000 } = {}) {
if (!input.includes("\n")) return;
try {
const r = JSON.parse(input);
r.ok ? end(null, r.result) : end(new Error(tool === "intake_question" && r.error === "outcome-unknown" ? "outcome-unknown" : tool === "launch" && r.error === "instance-running" ? "refused: instance-running: target instance is already running and will receive its waiting messages when it claims; you must not launch it again or relay the notice" : `refused: ${r.error}${typeof r.reason === "string" && tool === "advance_request" && r.error === "request-evidence-required" ? `: ${r.reason.slice(0, 300)}` : tool === "write_record" && r.error === "project-record-invalid" && typeof r.reason === "string" ? `: ${r.reason.slice(0, 300)}` : ""}`));
if (r.ok) return end(null, r.result);
const guidance = refusalGuidance(r.error);
const error = new Error(tool === "intake_question" && r.error === "outcome-unknown" ? "outcome-unknown" : tool === "launch" && r.error === "instance-running" ? "refused: instance-running: target instance is already running and will receive its waiting messages when it claims; you must not launch it again or relay the notice" : `refused: ${r.error}${typeof r.reason === "string" && tool === "advance_request" && r.error === "request-evidence-required" ? `: ${r.reason.slice(0, 300)}` : tool === "write_record" && r.error === "project-record-invalid" && typeof r.reason === "string" ? `: ${r.reason.slice(0, 300)}` : ""}`);
error.code = r.error;
Object.assign(error, guidance);
error.message += `\nRail: ${guidance.rail}\nNext step: ${guidance.nextStep}`;
end(error);
} catch {
end(unknown("tool reply isn't JSON"));
}
+1 -1
View File
@@ -200,7 +200,7 @@ test("a chief-of-staff bundle lists end_run, change_organization and set_name as
const r = resolvedFor(t, "chief", (d) => (d.roles.chief.vars = { harness, model: "claude-opus-5-5" }));
const b = build(t, r);
const prefix = harness === "pi" ? "" : "mcp__mosaic__";
assert.deepEqual(b.manifest.typed, ["send_message", "launch", "end_run", "hire", "fire", "organization_revision", "change_organization", "set_name", "read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task"].map((n) => prefix + n), harness);
assert.deepEqual(b.manifest.typed, ["send_message", "launch", "end_run", "hire", "fire", "organization_revision", "change_organization", "set_name", "read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task", "my_rails"].map((n) => prefix + n), harness);
assert.deepEqual(b.manifest.untyped, ["decision.resolve.technical", "decision.resolve.delivery"], harness);
assert.match(readFileSync(b.files.prompt, "utf8"), /Bus tools: send_message, launch, end_run, hire, fire, organization_revision, change_organization, set_name, read_spec, write_record, raise_decision, /);
assert.match(readFileSync(b.files.prompt, "utf8"), /Actions you hold that have no bus tool yet/);
@@ -0,0 +1,48 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const secret = 'sk-test-NOT-A-SECRET-1234';
function scratch(t) { const root = mkdtempSync(join(tmpdir(), 'r4-')); t.after(() => rmSync(root, { recursive: true, force: true })); return root; }
function safe(label, Type, props = {}, omitted = secret) { return error => {
assert.ok(error instanceof Type, error.stack);
for (const [key, value] of Object.entries(props)) assert.equal(error[key], value);
assert.ok(error.message.includes(label), error.message);
assert.doesNotMatch(error.message + error.stack, /sk-test|NOT-A-SECRET|Unexpected token/);
assert.ok(!error.message.includes(omitted), error.message);
assert.equal(error.cause, undefined);
return true;
}; }
import { spawnSync } from 'node:child_process';
import { EXIT } from '../src/runner.mjs';
const runner = join(import.meta.dirname, '../src/runner.mjs');
for (const at of ['session.json', 'stdin', 'policy.json']) test(`runner ${at} failure omits parser bytes and keeps usage exit`, t => {
const root = scratch(t), policy = join(root, 'policy.json');
writeFileSync(join(root, 'session.json'), at === 'session.json' ? secret : JSON.stringify({ bundle: { policy } }));
writeFileSync(policy, at === 'policy.json' ? secret : '{}');
const r = spawnSync(process.execPath, [runner, root], { encoding: 'utf8', input: at === 'stdin' ? secret + '\n' : JSON.stringify({ cap: 'a'.repeat(64) }) + '\n', env: { PATH: process.env.PATH, HOME: process.env.HOME, TMPDIR: process.env.TMPDIR } });
assert.equal(r.status, EXIT.usage, r.stderr);
assert.ok(r.stderr.includes(at), r.stderr);
assert.doesNotMatch(r.stdout + r.stderr, /sk-test|NOT-A-SECRET|Unexpected token/);
});
test('runner keeps a read errno without relaying its message', t => {
const root = scratch(t); mkdirSync(join(root, 'session.json'));
const r = spawnSync(process.execPath, [runner, root], { encoding: 'utf8', input: '', env: { PATH: process.env.PATH, HOME: process.env.HOME, TMPDIR: process.env.TMPDIR } });
assert.equal(r.status, EXIT.usage); assert.match(r.stderr, /session.json/); assert.match(r.stderr, /runner: EISDIR:/);
});
test('runner tools parser keeps SyntaxError without file bytes', t => {
const root = scratch(t), policy = join(root, 'policy.json'), tools = join(root, 'tools.json');
writeFileSync(join(root, 'session.json'), JSON.stringify({ bundle: { policy, tools }, turnEnd: 'exit', harnessEnv: {} }));
writeFileSync(policy, '{"needs":[]}'); writeFileSync(tools, secret);
// Isolate the claim boundary: this test exercises the real post-claim file parser,
// and intentionally never starts a broker, adapter or tool server.
const code = `import { Readable } from 'node:stream';
import { Client } from ${JSON.stringify(new URL('../../bus/src/client.mjs', import.meta.url).href)};
import { main } from ${JSON.stringify(new URL('../src/runner.mjs', import.meta.url).href)};
Client.prototype.call = async () => ({});
try { await main(${JSON.stringify(root)}, { stdin: Readable.from([JSON.stringify({ cap: 'a'.repeat(64) }) + '\\n']), env: {}, log() {} }); }
catch (e) { if (!(e instanceof SyntaxError)) throw e; console.error(e.stack); process.exitCode = 9; }`;
const r = spawnSync(process.execPath, ['--input-type=module', '-e', code], { encoding: 'utf8', env: { PATH: process.env.PATH, HOME: process.env.HOME, TMPDIR: process.env.TMPDIR } });
assert.equal(r.status, 9, r.stderr); assert.match(r.stderr, /tools.json/); assert.doesNotMatch(r.stderr, /sk-test|NOT-A-SECRET|Unexpected token/);
});
@@ -14,9 +14,9 @@ const role = (t, definition, harness = 'pi') => resolvedFor(t, 'bean', d => {
d.roles.bean = { definition, credentials: { gitea: { ...d.roles.coder.credentials.gitea } }, vars: { harness, model: 'claude-sonnet-5-5', thinking: 'high' } };
});
const expected = definition => ['send_message', ...(['companion', 'research'].includes(definition)
? ['web_search', 'web_fetch'] : []), ...LOOKUPS, 'library_doc_write', 'library_doc_delete'];
? ['web_search', 'web_fetch'] : []), ...LOOKUPS, 'library_doc_write', 'library_doc_delete', 'my_rails'];
test('M3: per-role snapshots contain eight names, or ten with the C2-08 web grant', t => {
test('M3/TI: per-role snapshots contain nine names, or eleven with the C2-08 web grant', t => {
for (const definition of CREATIVE) {
const tools = typedTools({ resolved: role(t, definition), library: true, tracker: true, arbiter: true });
assert.deepEqual(tools.map(t => t.name), expected(definition), definition);
@@ -72,7 +72,7 @@ test('bundle flag reaches both harness tool manifests and creative prompt withou
test('C2-05c scope schemas expand only find/search/get, keeping manuscript and tool names unchanged', t => {
const tools = typedTools({ resolved: role(t, 'ideas'), library: true });
assert.deepEqual(tools.map(t => t.name), ['send_message', ...LOOKUPS, 'library_doc_write', 'library_doc_delete']);
assert.deepEqual(tools.map(t => t.name), ['send_message', ...LOOKUPS, 'library_doc_write', 'library_doc_delete', 'my_rails']);
for (const name of ['library_find', 'library_search', 'library_get']) {
const tool = tools.find(t => t.name === name);
assert.deepEqual(tool.parameters.properties.scope.enum, ['canon', 'playground', 'supporting']);
@@ -11,12 +11,12 @@ const role = (t, definition) => resolvedFor(t, 'bean', doc => {
doc.roles.bean = { definition, credentials: { gitea: { ...doc.roles.coder.credentials.gitea } }, vars: { harness: 'pi', model: 'claude-sonnet-5-5', thinking: 'high' } };
});
test('C2-02 M3: each creative role has eight tools, or ten with its web grant', t => {
test('C2-02 M3/TI: each creative role has nine tools, or eleven with its web grant', t => {
for (const definition of CREATIVE) {
const resolved = role(t, definition), tools = typedTools({ resolved, library: true, tracker: true, arbiter: true });
const expected = ['send_message', ...(['companion', 'research'].includes(definition) ? ['web_search', 'web_fetch'] : []), ...LOOKUPS, ...WRITES];
const expected = ['send_message', ...(['companion', 'research'].includes(definition) ? ['web_search', 'web_fetch'] : []), ...LOOKUPS, ...WRITES, 'my_rails'];
assert.deepEqual(tools.map(tool => tool.name), expected, definition);
assert.equal(tools.length, ['companion', 'research'].includes(definition) ? 10 : 8);
assert.equal(tools.length, ['companion', 'research'].includes(definition) ? 11 : 9);
assert.equal(untypedActions(resolved, tools).includes('library.write'), false);
}
});
@@ -56,5 +56,5 @@ test('C2-02 tools: no library, noncreative or no within-role write grant exposes
const narrowed = structuredClone(creative);
narrowed.limits.authority.withinRole = narrowed.limits.authority.withinRole.filter(action => action !== 'library.write');
narrowed.limits.authority.crossRole.push('library.write');
assert.deepEqual(typedTools({ resolved: narrowed, library: true }).map(tool => tool.name), ['send_message', ...LOOKUPS]);
assert.deepEqual(typedTools({ resolved: narrowed, library: true }).map(tool => tool.name), ['send_message', ...LOOKUPS, 'my_rails']);
});
+1 -1
View File
@@ -68,7 +68,7 @@ test("tools/call goes through the tool socket; a refusal is an isError result",
assert.deepEqual(JSON.parse(ok.content[0].text), { tool: "list_agents", args: { scope: "all" } });
const refused = (await server.send("tools/call", { name: "launch", arguments: { instance: "coder" } })).result;
assert.equal(refused.isError, true);
assert.equal(refused.content[0].text, "refused: capacity-full");
assert.match(refused.content[0].text, /^refused: capacity-full\nRail: .+\nNext step: .+/);
assert.deepEqual(sock.calls.map((c) => c.tool), ["list_agents", "launch"]);
});
+62
View File
@@ -0,0 +1,62 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, mkdirSync } from 'node:fs';
import { join } from 'node:path';
import { buildBundle } from '../src/bundle.mjs';
import { typedTools } from '../src/tools.mjs';
import { resolvedFor, scratch, HARNESS, REPO_SKILLS } from './helpers.mjs';
import { ownRails, railsText, decisionAutonomy } from '../../organization/src/rails.mjs';
const SECRET = 'sk-test-NOT-A-SECRET-1234';
test('coder prompt lists every held action with its route and only service/scope names', t => {
const resolved = resolvedFor(t, 'coder');
const root = scratch(t), workspace = join(root, 'ws'); mkdirSync(workspace);
const bundle = buildBundle({ dir: join(root, 'bundle'), resolved,
contract: '# Contract', harness: HARNESS.pi, skillsDir: REPO_SKILLS,
session: { run: 'r1', workspace, toolSocket: join(root, 'tool.sock'), turnMarker: join(root, 'turn.done') } });
const prompt = readFileSync(bundle.files.prompt, 'utf8');
assert.ok(prompt.includes(railsText(resolved.rails)));
for (const action of resolved.limits.authority.withinRole) assert.ok(prompt.includes(`- ${action}: alone.`));
assert.match(prompt, /task.scope.change: delivery \(default\): arbiter instance "pm"; technical: arbiter instance "cto"/);
assert.match(prompt, /Network: none/);
assert.match(prompt, /Credential service gitea; scopes:.*write:repository/);
assert.ok(!railsText(resolved.rails).includes(resolved.credentials.gitea.file));
assert.ok(!railsText(resolved.rails).includes(SECRET));
assert.match(prompt, /a refusal carries its code, the rail it enforces and the next step/);
});
test('all roles including creative roles receive a parameterless caller-only my_rails tool', t => {
for (const instance of ['coder', 'chief', 'reviewer']) {
const tool = typedTools({ resolved: resolvedFor(t, instance) }).find(t => t.name === 'my_rails');
assert.equal(tool.verb, 'my_rails');
assert.deepEqual(tool.parameters, { type: 'object', properties: {}, required: [], additionalProperties: false });
}
const creative = typedTools({ resolved: { ...resolvedFor(t, 'coder'), definition: 'companion' } });
assert.ok(creative.some(t => t.name === 'my_rails'));
});
test('resolve rights name their classes and fixed levels through the AU extension point', t => {
const { rails } = resolvedFor(t, 'cto');
assert.deepEqual(rails.resolve, [{ right: 'decision.resolve.technical', classes: [
{ name: 'cross-role', level: decisionAutonomy('cross-role') }, { name: 'merge', level: decisionAutonomy('merge') },
] }]);
assert.match(railsText(rails), /merge: out of the loop/);
});
test('fixed domains, arbiter self-raises and hard waits describe existing routes only', () => {
const rails = ownRails({ instance: 'cto', authority: { withinRole: ['deploy'],
crossRole: ['git.merge.protected', 'policy.change'] }, arbiters: { technical: 'cto', delivery: 'pm' } });
assert.deepEqual(rails.actions, [
{ action: 'deploy', routes: [{ to: 'human' }] },
{ action: 'git.merge.protected', routes: [{ domain: 'technical', to: 'human' }] },
{ action: 'policy.change', routes: [{ domain: 'delivery', to: 'pm' }] },
]);
});
test('hire and fire are unavailable to a chief that is not the configured launcher', t => {
const { rails } = resolvedFor(t, 'chief', doc => { delete doc.launch; });
for (const action of ['role.hire', 'role.fire']) {
assert.deepEqual(rails.actions.find(a => a.action === action).routes, [{ to: 'unavailable' }]);
}
assert.match(railsText(rails), /role.hire: unavailable here; only the configured chief can hire or fire/);
});
@@ -392,7 +392,12 @@ test('advance_request exposes the named task evidence refusal to the PM tool', a
const { Client } = await import('../../bus/src/client.mjs');
const server = await runner.toolServer({ path, tools: [{ name: 'advance_request', verb: 'request.advance' }, { name: 'other_tool', verb: 'request.advance' }], local: {}, client: new Client({ path: busPath, cap: 'fixture' }) });
t.after(() => new Promise(resolve => server.close(resolve)));
await assert.rejects(callTool(path, 'advance_request', {}), { message: `refused: request-evidence-required: ${reason.slice(0, 300)}` });
await assert.rejects(callTool(path, 'advance_request', { other: true }), { message: 'refused: project-record-invalid' });
await assert.rejects(callTool(path, 'other_tool', {}), { message: 'refused: request-evidence-required' });
const refusal = message => error => {
assert.equal(error.message.split('\n')[0], message);
assert.match(error.message, /\nRail: .+\nNext step: .+/);
return true;
};
await assert.rejects(callTool(path, 'advance_request', {}), refusal(`refused: request-evidence-required: ${reason.slice(0, 300)}`));
await assert.rejects(callTool(path, 'advance_request', { other: true }), refusal('refused: project-record-invalid'));
await assert.rejects(callTool(path, 'other_tool', {}), refusal('refused: request-evidence-required'));
});
+16 -3
View File
@@ -477,10 +477,23 @@ test("typed tools carry the runner's capability; launch goes to the host's launc
ctx.call(ctx.pm, "message.send", { to: "coder", body: 'TOOL resolve_decision {"id":"x"}' });
ctx.call(ctx.pm, "message.send", { to: "coder", body: 'TOOL send_message {"to":"nobody","body":"x"}' });
await until(() => replies(ctx)() && ctx.inbox.length >= 4);
assert.deepEqual(ctx.inbox.map((m) => m.body), ["refused: capacity-full", '{"run":"new-run"}', "refused: unknown-tool", "refused: unknown-role"]);
assert.deepEqual(ctx.inbox.map((m) => m.body.split("\n")[0]), ["refused: capacity-full", '{"run":"new-run"}', "refused: unknown-tool", "refused: unknown-role"]);
assert.deepEqual(ctx.launches, [{ cap: ctx.cap, instance: "coder" }, { cap: ctx.cap, instance: "reviewer" }]);
});
test("my_rails reaches the live broker with the runner's own capability and refuses an identity selector", async t => {
const ctx = await setup(t, { tools: typedTools({ resolved: resolvedFor(t, "coder") }) });
ctx.inbox = [];
const r = startRunner(ctx.runDir, ctx.cap);
t.after(() => r.child.kill("SIGKILL"));
ctx.call(ctx.pm, "message.send", { to: "coder", body: 'TOOL my_rails {}' });
await until(() => replies(ctx)() && ctx.inbox.length >= 1);
assert.deepEqual(JSON.parse(ctx.inbox[0].body), ctx.call(ctx.cap, "my_rails", {}));
ctx.call(ctx.pm, "message.send", { to: "coder", body: 'TOOL my_rails {"role":"cto"}' });
await until(() => replies(ctx)() && ctx.inbox.length >= 2);
assert.match(ctx.inbox[1].body, /^refused: invalid-request\nRail: .+\nNext step: .+/);
});
// Row 132 (#1628, REQ-ROLE-2, REQ-COS-1, REQ-LIMIT-1): the chief's two tools reach the broker by their verbs
// with the arguments the session gave, and a refusal comes back with the broker's code.
// Row 134 (#1669): organization_revision reaches the broker's organization.revision and answers the revision alone.
@@ -509,7 +522,7 @@ test("end_run, organization_revision and change_organization reach the broker wi
send("change_organization", { ...change, surprise: 1 });
send("change_organization", { ...change, revision: "e".repeat(64) });
await until(() => replies(ctx)() && ctx.inbox.length >= 8);
assert.deepEqual(ctx.inbox.map((m) => m.body), [
assert.deepEqual(ctx.inbox.map((m) => m.body.split("\n")[0]), [
'{"revoked":true}',
"refused: role-end-refused",
"refused: not-held",
@@ -541,7 +554,7 @@ test("set_name reaches the broker's name.set with the tool's arguments, and refu
send({ instance: "coder", name: "rex" });
send({ instance: "coder", name: "pm" });
await until(() => replies(ctx)() && ctx.inbox.length >= 2);
assert.deepEqual(ctx.inbox.map((m) => m.body), [
assert.deepEqual(ctx.inbox.map((m) => m.body.split("\n")[0]), [
'{"instance":"coder","name":"rex"}',
"refused: name-is-instance",
]);
+8 -8
View File
@@ -18,7 +18,7 @@ test("the PM gets its task verbs and the reads, and no launch", (t) => {
"intake_status", "intake_projects", "intake_open", "intake_question", "intake_propose", "deliver_request", "advance_request", "submit_prd",
"task_create", "task_assign", "task_reassign", "task_schedule", "task_priority_change", "task_scope_change",
"task_update_assigned", "task_close",
"read_spec", "write_record", "raise_decision", "resolve_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task", "list_requirements",
"read_spec", "write_record", "raise_decision", "resolve_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task", "list_requirements", "my_rails",
]);
// Row 119 (#1613): only the chief of staff launches (REQ-COS-1).
assert.equal(pm.launch, null);
@@ -47,7 +47,7 @@ test("send_message offers the BLOCK class, beside the classes it already had", (
test("the chief of staff gets launch, send_message, end_run, hire, fire, change_organization and set_name, and no task verbs", (t) => {
const chief = resolvedFor(t, "chief");
const tools = typedTools({ resolved: chief, tracker: true });
assert.deepEqual(names(tools), ["send_message", "launch", "end_run", "hire", "fire", "organization_revision", "change_organization", "set_name", "read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task"]);
assert.deepEqual(names(tools), ["send_message", "launch", "end_run", "hire", "fire", "organization_revision", "change_organization", "set_name", "read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task", "my_rails"]);
const launch = tools.find((x) => x.name === "launch");
assert.equal(launch.verb, "launch");
assert.equal(launch.action, "role.launch");
@@ -225,7 +225,7 @@ test("no role's tools send instances or instances.set, and change_organization t
test("the assistant gets send_message, the reads and the web tools (it holds the web grant), no launch", (t) => {
const assistant = resolvedFor(t, "assistant", (d) => (d.roles.assistant = { definition: "assistant", holder: "mos" }));
const tools = typedTools({ resolved: assistant, tracker: true });
assert.deepEqual(names(tools), ["send_message", "web_search", "web_fetch", "read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task"]);
assert.deepEqual(names(tools), ["send_message", "web_search", "web_fetch", "read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task", "my_rails"]);
assert.equal(assistant.launch, null);
assert.deepEqual(untypedActions(assistant, tools), []);
});
@@ -260,7 +260,7 @@ test("list_requirements reads the requirement ids, for a task.create holder with
test("a coder gets no launch, no resolve_decision, and no task tools without a tracker", (t) => {
const coder = resolvedFor(t, "coder");
const tools = typedTools({ resolved: coder });
assert.deepEqual(names(tools), ["send_message", "request_review", "read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail"]);
assert.deepEqual(names(tools), ["send_message", "request_review", "read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "my_rails"]);
// Held actions without a tool are listed, so the manifest shows them. With
// no clone to push from, push_working is one of them.
assert.deepEqual(untypedActions(coder, tools), ["task.update.assigned", "git.push.working", "task.scope.change"]);
@@ -270,7 +270,7 @@ test("a coder with a tracker gets its own task tools and no task_reassign", (t)
const coder = resolvedFor(t, "coder");
assert.deepEqual(names(typedTools({ resolved: coder, tracker: true })), [
"send_message", "task_scope_change", "task_update_assigned", "request_review",
"read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task",
"read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task", "my_rails",
]);
});
@@ -283,7 +283,7 @@ test("launch only when the business's launch block names the instance as launche
test("an action outside the instance's authority has no tool", (t) => {
const coder = resolvedFor(t, "coder", (d) => (d.roles.coder.vars["limits.authority"] = ["review.request"]));
assert.deepEqual(names(typedTools({ resolved: coder, tracker: true, projects: ["stack"] })), ["request_review", "read_spec", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task"]);
assert.deepEqual(names(typedTools({ resolved: coder, tracker: true, projects: ["stack"] })), ["request_review", "read_spec", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task", "my_rails"]);
});
// Row 93 (#1587): the review loop's tools, by authority, and the git ones only with a clone.
@@ -292,7 +292,7 @@ test("the review loop: push_working and request_review for the coder, fetch_revi
const tools = typedTools({ resolved: coder, tracker: true, projects: ["stack", "site"] });
assert.deepEqual(names(tools), [
"send_message", "task_scope_change", "task_update_assigned", "push_working", "request_review",
"read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task",
"read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task", "my_rails",
]);
assert.deepEqual(untypedActions(coder, tools), []);
const push = tools.find((x) => x.name === "push_working");
@@ -309,7 +309,7 @@ test("the review loop: push_working and request_review for the coder, fetch_revi
const reviewer = resolvedFor(t, "reviewer");
const rtools = typedTools({ resolved: reviewer, tracker: true, projects: ["stack"] });
assert.deepEqual(names(rtools), ["send_message", "task_update_assigned", "fetch_review", "review_verdict", "read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task"]);
assert.deepEqual(names(rtools), ["send_message", "task_update_assigned", "fetch_review", "review_verdict", "read_spec", "write_record", "raise_decision", "list_agents", "inbox", "trail", "list_tasks", "get_task", "my_rails"]);
const fetch = rtools.find((x) => x.name === "fetch_review");
assert.deepEqual([fetch.verb, fetch.action], ["working.fetch", null]);
assert.deepEqual(fetch.parameters.required, ["task_ref", "request"]);
@@ -62,14 +62,20 @@ for (const [name, value, detail] of [
});
}
test('agent sees validation detail while other refusal codes remain code-only', async t => {
test('agent sees validation detail and static guidance without unrelated refusal reasons', async t => {
const f = await setup(t);
await assert.rejects(f.write('requirements/REQ-1.json', { ...requirement, title: '' }),
/refused: project-record-invalid: requirement\.title: too short/);
await assert.rejects(f.write('spec/secret-marker.md', '# text'), { message: 'refused: project-spec-human-only' });
const refusal = message => error => {
assert.equal(error.message.split('\n')[0], message);
assert.match(error.message, /\nRail: .+\nNext step: .+/);
assert.doesNotMatch(error.message, /secret-marker/);
return true;
};
await assert.rejects(f.write('spec/secret-marker.md', '# text'), refusal('refused: project-spec-human-only'));
const path = join(f.root, 'other-tools.sock');
const server = await toolServer({ path, tools: [{ name: 'write_record', verb: 'record.write' }], local: {},
client: { call() { throw Object.assign(new Error('hidden'), { code: 'project-busy', reason: 'secret-marker' }); } } });
t.after(() => server.close());
await assert.rejects(callTool(path, 'write_record', {}), { message: 'refused: project-busy' });
await assert.rejects(callTool(path, 'write_record', {}), refusal('refused: project-busy'));
});
+7 -7
View File
@@ -75,7 +75,7 @@ function checkUser(user, at, ctx, humans, seenSubjects) {
if (RESERVED_USERS.includes(user.id)) refuse(`${at}.id must not be ${JSON.stringify(user.id)}, a bus principal`);
if (humans.has(user.id)) refuse(`${at}.id must not be a business's human (${JSON.stringify(user.id)})`);
if (user.home !== LOCAL_HOME && !ctx.peers.has(user.home)) {
refuse(`${at}.home must be a configured peer or ${JSON.stringify(LOCAL_HOME)} (got ${JSON.stringify(user.home)})`);
refuse(`${at}.home must be a configured peer or ${JSON.stringify(LOCAL_HOME)}`);
}
const subjects = requireArray(user.subjects, `${at}.subjects`);
if (subjects.length === 0) refuse(`${at}.subjects must not be empty`);
@@ -84,7 +84,7 @@ function checkUser(user, at, ctx, humans, seenSubjects) {
requireObject(s, sat);
rejectUnknownKeys(s, ["provider", "sub"], sat);
if (typeof s.provider !== "string" || !ctx.providers.has(s.provider)) {
refuse(`${sat}.provider must be a configured provider (got ${JSON.stringify(s.provider)})`);
refuse(`${sat}.provider must be a configured provider`);
}
requireText(s.sub, `${sat}.sub`);
const key = subjectKey(s.provider, s.sub);
@@ -124,18 +124,18 @@ function checkGrant(grant, at, ctx, users, seenGrants) {
requireId(grant.id, `${at}.id`);
if (seenGrants.has(grant.id)) refuse(`${at}.id is a duplicate grant id: ${grant.id}`);
seenGrants.add(grant.id);
if (typeof grant.user !== "string" || !users.has(grant.user)) refuse(`${at}.user must name a user in this file (got ${JSON.stringify(grant.user)})`);
if (typeof grant.user !== "string" || !users.has(grant.user)) refuse(`${at}.user must name a user in this file`);
if (typeof grant.business !== "string" || !ctx.businesses.has(grant.business)) {
refuse(`${at}.business must name a declared business (got ${JSON.stringify(grant.business)})`);
refuse(`${at}.business must name a declared business`);
}
const business = ctx.businesses.get(grant.business);
checkScope(grant.projects, `${at}.projects`, (p, where) => {
if (typeof p !== "string" || !UUID.test(p)) refuse(`${where} must be "*" or a bus project id, a lowercase UUID (got ${JSON.stringify(p)})`);
if (typeof p !== "string" || !UUID.test(p)) refuse(`${where} must be "*" or a bus project id, a lowercase UUID`);
});
checkScope(grant.instances, `${at}.instances`, (x, where) => {
if (typeof x !== "string" || !business.instances.has(x)) refuse(`${where} must be "*" or a declared instance of the grant's business`);
});
if (!LEVELS.includes(grant.level)) refuse(`${at}.level must be one of ${LEVELS.join(", ")} (got ${JSON.stringify(grant.level)})`);
if (!LEVELS.includes(grant.level)) refuse(`${at}.level must be one of ${LEVELS.join(", ")}`);
if (grant.expires !== undefined) checkExpires(grant.expires, `${at}.expires`);
}
@@ -147,7 +147,7 @@ export function checkAccess(document, context) {
const humans = new Set([...ctx.businesses.values()].map((b) => b.human));
if (!isPlainObject(document)) refuse("access file must be a JSON object");
rejectUnknownKeys(document, ["accessVersion", "revision", "users", "grants"], "access file");
if (document.accessVersion !== 1) refuse(`accessVersion must be 1 (got ${JSON.stringify(document.accessVersion)})`);
if (document.accessVersion !== 1) refuse(`accessVersion must be 1`);
requirePositiveInt(document.revision, "revision");
const users = new Set();
const seenSubjects = new Set();
+8 -14
View File
@@ -1,9 +1,10 @@
// Phase M2 (#1873): instance accounts, independent of Console sessions and bus authority.
import { constants, lstatSync } from 'node:fs';
import { constants } from 'node:fs';
import * as fs from 'node:fs/promises';
import { dirname, join, parse, resolve } from 'node:path';
import { dirname, join } from 'node:path';
import { randomBytes } from 'node:crypto';
import { OrgError, readRegistryLocked } from './org-registry.mjs';
import { assertTrustedConfigDir } from './config-dir-trust.mjs';
import { readJsonFile, requireObject, rejectUnknownKeys, requireString, requirePositiveInt, requireId, requireDistinctList, deepFreeze, canonicalJson } from './util.mjs';
import { RIGHTS, ROLE_RIGHTS, normalizeRights } from './org-rights.mjs';
@@ -128,23 +129,16 @@ export function accountsRepairs(doc) {
return result;
}
// Check the host boundary before any read or lock. Every ancestor must be a real
// directory; configDir itself belongs to this uid and is not publicly writable.
// Check the host boundary before any read or lock: every ancestor a real directory
// that only root or this uid can change, and configDir this uid's and not publicly
// writable (config-dir-trust.mjs). Refusals name the rule and uid, never the path.
export function assertAccountsOwner(configDir, { uid = process.geteuid() } = {}) {
if (typeof configDir !== 'string' || resolve(configDir) !== configDir) fail('accounts-unsafe', 'config directory must be an absolute normalized path');
let current = parse(configDir).root;
for (const part of configDir.slice(current.length).split('/').filter(Boolean)) {
current = join(current, part); const st = lstatSync(current);
if (!st.isDirectory() || st.isSymbolicLink()) fail('accounts-unsafe', 'config path contains a symlink or non-directory');
}
const st = lstatSync(configDir);
if (st.uid !== uid) fail('accounts-unsafe', `config directory must belong to uid ${uid}: ${configDir}`);
if (st.mode & 0o022) fail('accounts-unsafe', 'config directory must not be writable by group or other');
assertTrustedConfigDir(configDir, { uid, code: 'accounts-unsafe' });
}
export function loadAccounts(file, context, { uid = process.geteuid() } = {}) {
assertAccountsOwner(dirname(file), { uid });
const doc = readJsonFile(file, 'accounts file', st => {
if (st.uid !== uid) fail('accounts-unsafe', `accounts file must belong to uid ${uid}: ${file}`);
if (st.uid !== uid) fail('accounts-unsafe', `accounts file must belong to uid ${uid}`);
if (st.mode & 0o022) fail('accounts-unsafe', 'accounts file must not be writable by group or other');
});
return checkAccounts(doc, context);
+288
View File
@@ -0,0 +1,288 @@
// Root and project agent copies (#1873 Phase A, slice A3; design
// docs/plans/2026-11-10_agent-levels-design.md sections 4 and 9 item 2).
//
// When an organization's bus boots it reads the root file once, through loadRootAgents, and its
// own organization file through its own writer. Each root agent the organization does not exclude,
// and each agent a project defines, becomes a copy. The snapshot this module returns is plain data
// and is fixed until the next boot: nothing here, or anything that holds the snapshot, reads the
// root file again.
//
// A copy runs as an ordinary role instance of the organization: withCopies adds it to the
// organization document's `roles` and validates the whole document, so every copy goes through the
// same checkRoles an org instance takes, with the organization's credential map. A root or project
// definition carries no credential (agent-levels.mjs refuses one), and A3 has no way to bind one,
// so a copy whose role needs a credential service or a tracker bot is unbound: it is not added, and
// a launch refuses it by name, naming the missing services and never a value.
//
// Nothing in a copy's view carries a var value, a credential reference, a path or file bytes.
import { isDeepStrictEqual } from "node:util";
import { HOST_SERVICE } from "./vocabulary.mjs";
import { loadRole } from "./role.mjs";
import { resolveAgentLevels } from "./agent-levels.mjs";
import { mergeVars } from "./vars.mjs";
import { businessFilePath, loadBusiness, validateBusinessDocument } from "./organization-config.mjs";
import { organizationWriter } from "./organization.mjs";
import { deepFreeze } from "./util.mjs";
const ID = /^[a-z0-9][a-z0-9._-]{0,63}$/;
// How a refusal names one claimant of an instance name.
const claimLabel = ({ name, project }) => (project === null ? `root agent ${name}` : `project ${project}'s ${name}`);
const collision = (key, claimants) => `the instance name ${key} is claimed by ${claimants.map(claimLabel).join(" and ")}`;
// The root assistant is shown, not copied (A6); agent-levels.mjs names it the same way.
const ASSISTANT = "assistant";
export const COPY_STATES = Object.freeze(["instance", "unbound", "refused"]);
// The bus instance name of a copy. A root copy keeps its name. A project agent is
// `<name>.<project>`, so two projects may define the same name and a project may define a name an
// organization or root agent already has, each as its own instance. Null when it isn't a valid id.
export function copyInstanceName(name, project = null) {
const instance = project === null ? name : `${name}.${project}`;
return ID.test(instance) ? instance : null;
}
// The credential services a copy of `definition` would need bound in the organization, and whether
// it needs a tracker bot. The host key comes from a grant record at bus start, never from the file,
// so it is not one (organization-config.mjs, checkRoles).
function needs(definition, rolesDir, cache) {
if (!cache.has(definition)) {
const role = loadRole(rolesDir, definition);
const services = role.credentials.map((c) => c.service).filter((s) => s !== HOST_SERVICE);
cache.set(definition, [...new Set(services)].sort());
}
return cache.get(definition);
}
// The copies of one organization, from its raw document (as its own writer read it) and the root
// agents loadRootAgents returned. `root` is null when the root file refused: the root level is then
// hidden everywhere (design section 6), so no root copy is made, and project agents still are.
// Returns a frozen plain-data snapshot { org, root, rootAgents, copies: { instance: copy } } where
// each copy is { name, level, project, definition, vars, state, missing?, reason? }. `rootAgents`
// holds the root agents this organization takes (not excluded, not the root assistant), so a later
// read of the organization file recomputes each project's union without reading the root file.
export function materializeCopies({ orgId, doc, root, rolesDir }) {
const rootState = root === null ? "refused" : root.state;
const levels = resolveAgentLevels({ orgId, doc, root: root ?? { agents: {} }, rolesDir });
const rootAgents = {};
for (const [name, entry] of Object.entries(root?.agents ?? {})) {
if (entry.definition === ASSISTANT || entry.excludeOrgs.includes(orgId)) continue;
rootAgents[name] = { definition: entry.definition, vars: entry.vars ?? {}, excludeOrgs: [] };
}
const cache = new Map();
const copies = {};
const add = (agent, project, refusal = null) => {
const instance = copyInstanceName(agent.name, project);
// An over-long project agent name has no instance; it refuses under its own name.
const key = instance ?? `${agent.name}.${project}`;
const base = { name: agent.name, level: project === null ? "root" : "project", project, definition: agent.definition, vars: agent.vars };
// Two agents whose instance names come out the same (root checker.stack and project stack's
// checker, or two dotted names) refuse both, naming each; neither overwrites the other. The
// entry lists every claimant, so a reader finds each agent's copy by its name and project.
if (Object.hasOwn(copies, key)) {
const claimants = [...(copies[key].claims ?? [{ name: copies[key].name, project: copies[key].project }]), { name: agent.name, project }];
copies[key] = { ...copies[key], state: "refused", reason: collision(key, claimants), claims: claimants };
delete copies[key].missing;
return;
}
// The organization file's own instance of that name wins: no copy is made (design section 3).
if (instance !== null && Object.hasOwn(doc?.roles ?? {}, instance)) return;
if (refusal) { copies[key] = { ...base, state: "refused", reason: refusal }; return; }
if (instance === null) { copies[key] = { ...base, state: "refused", reason: `project ${project}: ${agent.name}.${project} is longer than an instance name may be` }; return; }
const missing = needs(agent.definition, rolesDir, cache);
copies[key] = missing.length ? { ...base, state: "unbound", missing } : { ...base, state: "instance" };
};
// Root copies: the base union's root-level agents. It already leaves out the root assistant (A6),
// the ones this organization is excluded from and the ones an org instance shadows by name.
for (const agent of levels.base.agents) if (agent.level === "root") add(agent, null);
// Project agents: each project's own define entries. When the project's union refuses, every
// agent it defines refuses too, naming the project and the reason.
for (const [project, entries] of Object.entries(levels.projects)) {
const union = levels.unions[project];
for (const [name, entry] of Object.entries(entries)) {
if (entry.form !== "define") continue;
const agent = { name, definition: entry.definition, vars: entry.vars };
add(agent, project, union.ok ? null : `project ${project} refuses its agents: ${union.reason}`);
}
}
return deepFreeze({ org: orgId, root: rootState, rootAgents, copies });
}
// The view of a snapshot that leaves the boot: names, levels, definitions, states, the missing
// service names and refusal reasons. No vars.
export function copiesView(copies) {
return Object.fromEntries(Object.entries(copies ?? {}).map(([instance, c]) => [instance, {
name: c.name, level: c.level, project: c.project, definition: c.definition, state: c.state,
...(c.missing ? { missing: [...c.missing] } : {}), ...(c.reason ? { reason: c.reason } : {}),
...(c.claims ? { claims: c.claims.map(({ name, project }) => ({ name, project })) } : {}),
}]));
}
// Every string the document or the copy holds as a var or a credential reference, longest first.
function valuesOf(document, copy) {
const out = new Set();
const walk = (x) => {
if (typeof x === "string") { if (x.length >= 3) out.add(x); }
else if (x && typeof x === "object") for (const v of Object.values(x)) walk(v);
};
walk(copy.vars);
walk(document.vars);
for (const role of Object.values(document.roles ?? {})) { walk(role?.vars); walk(role?.credentials); }
for (const project of Object.values(document.projects ?? {})) walk(project?.vars);
return [...out].sort((a, b) => b.length - a.length);
}
// A validation refusal for a copy, said without the host path, a quoted "got" value, or any string
// the document or the copy holds as a var or a credential reference.
function reasonOf(error, file, orgId, document, copy) {
let message = error.message.replaceAll(file, `${orgId}.json`).replace(/; got [^)]*\)/g, ")").replace(/ \(got [\s\S]*$/, "");
for (const value of valuesOf(document, copy)) message = message.replaceAll(value, "<value>");
return message.slice(0, 400);
}
const entryOf = (copy, vars = copy.vars) => ({ definition: copy.definition, ...(Object.keys(vars ?? {}).length ? { vars: structuredClone(vars) } : {}) });
// Whether any code project of the raw document has its own agent entries.
const hasProjectAgents = (document) => Object.values(document?.projects ?? {}).some((p) => p?.kind !== "library" && p?.agents !== undefined);
// `vars` narrowed by each project entry in `narrows`, the way projectUnion narrows one: mergeVars
// applies the intersect rule, and only the keys either side set are kept.
function narrowVars(level, vars, narrows) {
return narrows.reduce((current, entry) => {
const merged = mergeVars([{ source: level, vars: current }, { source: "project", vars: entry }]).vars;
const keys = new Set([...Object.keys(current), ...Object.keys(entry)]);
return Object.fromEntries(Object.entries(merged).filter(([key]) => keys.has(key)));
}, vars ?? {});
}
// The instance name each union agent runs as: a project's own agent is its copy, every other agent
// is the instance of its own name.
const unionInstance = (agent, project) => (agent.level === "project" ? copyInstanceName(agent.name, project) ?? `${agent.name}.${project}` : agent.name);
// Why a project copy can't join this document, or null when it can: its project is still a declared
// code project, the project still defines it with the definition and vars the bus started with, and
// the project's union, recomputed from this document, still accepts it. A copy is made at boot, so
// a changed definition or vars waits for the next start; until then the copy refuses rather than
// launch with settings the project no longer gives it.
function projectCopyRefusal(copy, document, levels) {
const p = document.projects?.[copy.project];
if (!p || p.kind === "library") return `project ${copy.project} is no longer declared`;
const union = levels.unions[copy.project];
if (!union.ok) return `project ${copy.project} refuses its agents: ${union.reason}`;
const entry = levels.projects[copy.project]?.[copy.name];
if (entry?.form !== "define" || entry.definition !== copy.definition) return `project ${copy.project} no longer defines ${copy.name} as ${copy.definition}`;
if (!isDeepStrictEqual(entry.vars ?? {}, copy.vars ?? {})) return `project ${copy.project} changed ${copy.name}'s vars since the bus started; the change takes effect at the next start`;
return null;
}
// The organization document with the snapshot's bound copies added to `roles`, validated whole by
// validateBusinessDocument, the loader every org instance goes through. Each project's union is
// recomputed from this document and the snapshot's root agents (never the root file), so:
// - the business carries `unions`: for each code project with agents, { ok: true, agents: [the
// instance names available there] } or { ok: false, reason };
// - an instance a project narrows is narrowed in `roles` itself, so its tools, network and actions
// are the intersection over every project that narrows it (a narrow in one project narrows the
// instance in every launch: fail closed, since one launch mounts every project it may work in);
// - a project copy whose project stops defining it, or whose union now refuses, is refused.
// A copy the document refuses (a model var the organization doesn't list, say) is left out and
// reported refused; the rest still join. A copy whose name the organization file now declares is
// dropped: the file's own instance wins. Returns a frozen business whose `copies` key is the
// snapshot's view, with each copy's state as it is in this document; a business with no copies and
// no project agents is plain validateBusinessDocument.
export function withCopies(document, file, { rolesDir, snapshot }) {
const copies = snapshot?.copies ?? {};
if (Object.keys(copies).length === 0 && !hasProjectAgents(document)) return validateBusinessDocument(document, file, { rolesDir });
// The document's own refusals come first, whole.
validateBusinessDocument(document, file, { rolesDir });
const orgId = snapshot?.org ?? document.id;
const levels = resolveAgentLevels({ orgId, doc: document, root: { agents: snapshot?.rootAgents ?? {} }, rolesDir });
const unions = {};
const narrows = {};
for (const [project, union] of Object.entries(levels.unions)) {
if (!union.ok) { unions[project] = { ok: false, reason: union.reason }; continue; }
unions[project] = { ok: true, agents: union.agents.map((a) => unionInstance(a, project)).sort() };
for (const agent of union.agents) {
if (agent.level !== "project" && agent.narrowed.length) (narrows[agent.name] ??= []).push(levels.projects[project][agent.name].vars);
}
}
const roles = { ...document.roles };
for (const [name, list] of Object.entries(narrows)) {
if (Object.hasOwn(roles, name)) roles[name] = { ...roles[name], vars: narrowVars("org", roles[name].vars, list) };
}
const narrowed = { ...document, roles };
const view = copiesView(copies);
// Who claims each copy's instance name now: the boot's root copies and the projects' define
// entries as this document has them. A project that comes to define an agent whose name collides
// with a copy refuses that copy, naming every claimant, as the boot does.
const claimants = {};
const claim = (key, who) => { if (!(claimants[key] ??= []).some((c) => c.name === who.name && c.project === who.project)) claimants[key].push(who); };
for (const [instance, copy] of Object.entries(copies)) for (const c of copy.claims ?? [{ name: copy.name, project: copy.project }]) if (c.project === null) claim(instance, c);
for (const [project, entries] of Object.entries(levels.projects)) {
for (const [name, entry] of Object.entries(entries)) if (entry.form === "define") claim(copyInstanceName(name, project) ?? `${name}.${project}`, { name, project });
}
const join = {};
for (const [instance, copy] of Object.entries(copies)) {
// The organization file's own instance of this name wins; the copy is gone, not refused.
if (Object.hasOwn(roles, instance)) { delete view[instance]; continue; }
if ((claimants[instance]?.length ?? 0) > 1) {
const all = claimants[instance].map(({ name, project }) => ({ name, project }));
const { missing: _, ...rest } = view[instance];
view[instance] = { ...rest, state: "refused", reason: collision(instance, all), claims: all };
continue;
}
if (copy.state !== "instance") continue;
const refused = copy.project === null ? null : projectCopyRefusal(copy, document, levels);
if (refused) { view[instance] = { ...view[instance], state: "refused", reason: refused }; continue; }
const entry = entryOf(copy, copy.project === null && narrows[copy.name] ? narrowVars("root", copy.vars, narrows[copy.name]) : copy.vars);
try {
validateBusinessDocument({ ...narrowed, roles: { ...roles, [instance]: entry } }, file, { rolesDir });
join[instance] = entry;
} catch (error) {
if (error?.name !== "BusinessError") throw error;
view[instance] = { ...view[instance], state: "refused", reason: reasonOf(error, file, orgId, document, copy) };
}
}
const business = validateBusinessDocument({ ...narrowed, roles: { ...roles, ...join } }, file, { rolesDir });
return deepFreeze({ ...business, ...(Object.keys(copies).length ? { copies: view } : {}), unions });
}
// The organization with its copies, as a bus or launcher loads it. The plain load keeps
// loadBusiness's file checks; when there are copies or project agents, the document is read again
// through the organization's own writer and validated with them, and only that second read is used.
export function loadWithCopies(id, { dir, rolesDir, snapshot }) {
const plain = loadBusiness(id, { dir, rolesDir });
const { document } = organizationWriter({ dir, rolesDir }).read(id);
if ((!snapshot || Object.keys(snapshot.copies ?? {}).length === 0) && !hasProjectAgents(document)) return plain;
return withCopies(document, businessFilePath(id, dir), { rolesDir, snapshot });
}
// Why `instance` can't work in code project `project` of this business, as { code, message }, or
// null when it can. A project copy works only in its own project; an instance a project excludes,
// or any instance in a project whose union refuses, doesn't work there. The message names the
// instance, the project and the reason, never a value.
export function projectRefusal(business, instance, project) {
const copy = copyOf(business, instance);
if (copy?.project != null && copy.project !== project) {
return { code: "project-agent-elsewhere", message: `${instance} works only in project ${copy.project}` };
}
const union = project == null ? undefined : business?.unions?.[project];
if (!union) return null;
if (!union.ok) return { code: "project-refused", message: `${instance} can't work in project ${project}: project ${project} refuses its agents: ${union.reason}` };
if (!union.agents.includes(instance)) return { code: "project-refused", message: `${instance} can't work in project ${project}: the project excludes it` };
return null;
}
// The copy a launch names, or null for an org instance. `business` is one withCopies returned.
export function copyOf(business, instance) {
return business?.copies && Object.hasOwn(business.copies, instance) ? business.copies[instance] : null;
}
// Why a copy can't launch, as { code, message }, or null when it can. The message names the agent,
// its project and the missing services or the refusal, and never a value.
export function copyRefusal(business, instance) {
const copy = copyOf(business, instance);
if (!copy || copy.state === "instance") return null;
if (copy.state === "unbound") {
return { code: "credential-unbound", message: `${instance} has no binding in ${business.id} for ${copy.missing.join(", ")}` };
}
return { code: copy.project === null ? "copy-refused" : "project-refused", message: `${instance} can't launch in ${business.id}: ${copy.reason}` };
}
@@ -0,0 +1,295 @@
// #1873 Phase A slice A2: the root agent file's writer (design
// docs/plans/2026-11-10_agent-levels-design.md sections 6, 7 and 9 item 1).
//
// One instance-wide file, <configDir>/agents.json. Only the signed-in owner writes it: the caller
// passes the actor and the owner principal its own trusted context established (the CLI's local
// owner, the Console's owner session), and anything else refuses `human-required`. The write
// follows M2's locked store (accounts.mjs): a lock file, a re-read and revision check under it, the
// whole new document checked with A1's checkRootAgents, a 0600 temp file, a re-check, then rename
// (or link for a new file), a directory sync and one receipt line. Nothing here launches an agent.
//
// No refusal and no receipt carries a value from the file or the request: a var value can be a
// secret typed into the wrong place. Messages name the file (agents.json) and the key, never a
// host path.
import { constants } from 'node:fs';
import * as fs from 'node:fs/promises';
import { createHash, randomBytes } from 'node:crypto';
import { join } from 'node:path';
import { OrgError } from './org-registry.mjs';
import { assertTrustedConfigDir } from './config-dir-trust.mjs';
import { ROOT_FILE, checkProjectAgents, checkRootAgents, loadRootAgents, projectUnion, rootAgentsPath } from './agent-levels.mjs';
import { canonicalJson, isPlainObject } from './util.mjs';
import { ID_PATTERN } from './vocabulary.mjs';
import { REGISTRY } from './vars.mjs';
export const ROOT_LOCK = 'agents.lock';
export const ROOT_RECEIPTS = 'agents-receipts.jsonl';
const sha256 = bytes => createHash('sha256').update(bytes).digest('hex');
// The revision of a root file that does not exist yet: the digest of no bytes.
export const ABSENT_REVISION = sha256('');
const REVISION = /^[a-f0-9]{64}$/;
const PRINCIPAL = ['provider', 'iss', 'sub'];
const fail = (code, message = code) => { throw new OrgError(code, message); };
// The only actor that writes: the owner principal the caller's trusted context names. The tester
// identity never counts as the owner, whatever a context says.
export function requireRootOwner(actor, owner) {
const ok = p => isPlainObject(p) && PRINCIPAL.every(k => typeof p[k] === 'string' && p[k] !== '');
if (!ok(actor) || !ok(owner) || PRINCIPAL.some(k => actor[k] !== owner[k]) || actor.sub === 'tester' || actor.provider === 'tester')
fail('human-required', 'only the signed-in owner writes the root agent file');
}
// The shared config directory check (config-dir-trust.mjs): every ancestor root's or this uid's and
// not writable by others without the sticky bit, configDir this uid's and not group or other writable.
const assertConfigDir = (configDir, uid) => assertTrustedConfigDir(configDir, { uid, code: 'agents-unsafe' });
// The reader's descriptor checks (loadRootAgents), plus one link: a regular file, no symbolic link,
// this uid, no group or other write. Missing is an empty root level at ABSENT_REVISION.
async function readCurrent(configDir, uid, statFile = fd => fd.stat()) {
const file = rootAgentsPath(configDir);
let fd;
try { fd = await fs.open(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); }
catch (error) {
if (error.code === 'ENOENT') return { exists: false, revision: ABSENT_REVISION, document: { agentsVersion: 1, agents: {} } };
fail('agents-unsafe', `root agent file must be a regular file, not a symbolic link (${ROOT_FILE})`);
}
let bytes;
try {
const st = await statFile(fd);
if (!st.isFile()) fail('agents-unsafe', `root agent file must be a regular file, not a symbolic link (${ROOT_FILE})`);
if (st.uid !== uid) fail('agents-unsafe', `root agent file must belong to uid ${uid} (${ROOT_FILE})`);
if (st.mode & 0o022) fail('agents-unsafe', `root agent file must not be writable by group or other (${ROOT_FILE})`);
if (st.nlink !== 1) fail('agents-unsafe', `root agent file must have exactly one link (${ROOT_FILE})`);
bytes = await fd.readFile();
} finally { await fd.close(); }
let document;
// The parser's text can quote the file's bytes: say only that it is not JSON.
try { document = JSON.parse(bytes.toString('utf8')); } catch { fail('agents-invalid', `root agent file is not valid JSON (${ROOT_FILE})`); }
return { exists: true, revision: sha256(bytes), document };
}
// The file's document and revision, for a form or a CLI list. A file that is not a root agent
// document at all refuses; one with a bad entry reads, so the owner can fix or remove that entry.
// statFile is a test seam for the descriptor stat; it grants nothing a caller's uid does not.
export async function readRootAgentsFile(configDir, { uid = process.geteuid(), statFile } = {}) {
assertConfigDir(configDir, uid);
const current = await readCurrent(configDir, uid, statFile);
shape(current.document);
return current;
}
function shape(document) {
if (!isPlainObject(document) || document.agentsVersion !== 1 || !isPlainObject(document.agents))
fail('agents-invalid', `root agent file must be {"agentsVersion": 1, "agents": {...}} (${ROOT_FILE})`);
}
// Every string a request put in an entry, as a message could quote it bare or as JSON.
// What a refusal must not show. Field names, var names, role ids and org ids may appear; every
// var value, every unknown key and any other string that isn't an id is caller text.
const FIELDS = new Set(['definition', 'vars', 'excludeOrgs', 'exclude', 'credentials', 'holder', 'tracker', 'identity', 'publish', 'hosts']);
function all(value, out) {
if (typeof value === 'string') { if (value.length >= 3) out.push(value, JSON.stringify(value)); }
else if (Array.isArray(value)) value.forEach(v => all(v, out));
else if (isPlainObject(value)) for (const [key, v] of Object.entries(value)) { all(key, out); all(v, out); }
return out;
}
const unlessId = (value, out) => (typeof value === 'string' && ID_PATTERN.test(value) ? out : all(value, out));
function strings(entry, out = []) {
if (!isPlainObject(entry)) return all(entry, out);
for (const [key, value] of Object.entries(entry)) {
if (!FIELDS.has(key)) all(key, out);
if (key === 'definition') unlessId(value, out);
else if (key === 'excludeOrgs' && Array.isArray(value)) value.forEach(v => unlessId(v, out));
else if (key === 'exclude' && typeof value === 'boolean') continue;
else if (key === 'vars' && isPlainObject(value)) for (const [name, v] of Object.entries(value)) { if (!Object.hasOwn(REGISTRY, name)) all(name, out); all(v, out); }
else all(value, out);
}
return out;
}
// A refusal's message with its key path and without any value: an id check's "(got ...)" is cut,
// and a message that still holds a string from the entry is replaced by one naming the key only.
export function withoutValues(message, entry, prefix) {
const cut = String(message).replace(/ \(got [\s\S]*\)$/, '');
if (!strings(entry).some(text => cut.includes(text))) return cut;
const at = new RegExp(`^${prefix.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?:\\.[^\\s:]+)*`).exec(cut)?.[0] ?? prefix;
return `${at}: refused (the value is not shown)`;
}
// A1's checks on the whole new document.
function validate(document, rolesDir, entry) {
try { return checkRootAgents(document, { rolesDir }); }
catch (error) {
if (error?.name !== 'BusinessError') throw error;
fail('agents-invalid', withoutValues(error.message, entry, 'root agents'));
}
}
// What changed in one entry, by key name only.
function changedKeys(before, after) {
if (!before && !after) return [];
if (!before) return ['added'];
if (!after) return ['removed'];
const out = [];
if (before.definition !== after.definition) out.push('definition');
const bv = isPlainObject(before.vars) ? before.vars : {}, av = isPlainObject(after.vars) ? after.vars : {};
for (const key of [...new Set([...Object.keys(bv), ...Object.keys(av)])].sort()) {
if (canonicalJson(bv[key]) !== canonicalJson(av[key])) out.push(`vars.${key}`);
}
if (canonicalJson(before.excludeOrgs ?? []) !== canonicalJson(after.excludeOrgs ?? [])) out.push('excludeOrgs');
return out;
}
// One entry changed by a patch: `definition` replaces, `vars` sets each key it names (null removes
// that key), `excludeOrgs` replaces the list ([] clears it). Keys the patch leaves out are kept.
export function patchRootEntry(old, { definition, vars, excludeOrgs } = {}) {
const next = old ? structuredClone(old) : {};
if (definition !== undefined) next.definition = definition;
if (vars !== undefined) {
if (!isPlainObject(vars)) fail('agents-invalid', 'vars must be an object of keys');
const merged = isPlainObject(next.vars) ? { ...next.vars } : {};
for (const [key, value] of Object.entries(vars)) { if (value === null) delete merged[key]; else merged[key] = value; }
if (Object.keys(merged).length) next.vars = merged; else delete next.vars;
}
if (excludeOrgs !== undefined) { if (Array.isArray(excludeOrgs) && excludeOrgs.length === 0) delete next.excludeOrgs; else next.excludeOrgs = excludeOrgs; }
return next;
}
async function syncDirectory(dir) {
const fd = await fs.open(dir, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
try { await fd.sync(); } finally { await fd.close(); }
}
// Change one root agent. `entry` is the new entry, null to remove it, or a function of the old
// entry (undefined when there is none) returning either. Returns the receipt. An error after the
// rename carries committed: true, so a caller never retries a write that landed.
export async function changeRootAgent(configDir, { revision, name, entry }, { actor, owner, rolesDir, uid = process.geteuid(), beforeReplace, statFile, syncDirectory: syncDir = syncDirectory } = {}) {
requireRootOwner(actor, owner);
if (typeof name !== 'string' || !ID_PATTERN.test(name)) fail('agents-invalid', 'agent name must be an id: lowercase letters, digits, dot, dash or underscore, at most 64');
if (typeof revision !== 'string' || !REVISION.test(revision)) fail('revision-conflict', 'a root agent change needs the revision it was read at');
assertConfigDir(configDir, uid);
const lock = join(configDir, ROOT_LOCK);
let lockFd;
for (let n = 0; n < 200; n++) {
try { lockFd = await fs.open(lock, 'wx', 0o600); break; }
catch (error) { if (error.code !== 'EEXIST') throw error; await new Promise(r => setTimeout(r, 10)); }
}
if (!lockFd) fail('agents-locked', 'the root agent file is locked by another write; inspect agents.lock before retrying');
const file = rootAgentsPath(configDir);
let temp, receiptFd, committed = false;
try {
assertConfigDir(configDir, uid);
const before = await readCurrent(configDir, uid, statFile);
if (before.revision !== revision) fail('revision-conflict', 'the root agent file changed since it was read; read it again');
shape(before.document);
const old = Object.hasOwn(before.document.agents, name) ? before.document.agents[name] : undefined;
const value = typeof entry === 'function' ? entry(old === undefined ? undefined : structuredClone(old)) : entry;
if (value === null && old === undefined) fail('agent-not-found', `root agents.${name} does not exist`);
if (value !== null && !isPlainObject(value)) fail('agents-invalid', `root agents.${name} must be an object`);
const next = structuredClone(before.document);
if (value === null) delete next.agents[name]; else next.agents[name] = value;
const changed = changedKeys(old, value ?? undefined);
if (changed.length === 0) fail('no-change', `root agents.${name} is already that`);
validate(next, rolesDir, value);
const bytes = JSON.stringify(next, null, 2) + '\n';
// Check what lands on disk, not only the object built: JSON can drop or reshape a value.
validate(JSON.parse(bytes), rolesDir, value);
const after = sha256(bytes);
const line = {
at: new Date().toISOString(), uid: process.geteuid(), actor: 'owner',
verb: value === null ? 'root.agent.remove' : 'root.agent.set', agent: name,
...(value === null ? {} : { definition: value.definition }),
revisionBefore: before.revision, revisionAfter: after, changed,
};
// Refuse an unsafe receipt destination before publishing the change.
receiptFd = await fs.open(join(configDir, ROOT_RECEIPTS), constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW | constants.O_NONBLOCK, 0o600);
const st = await receiptFd.stat();
if (!st.isFile() || st.uid !== uid || (st.mode & 0o022) || st.nlink !== 1) fail('agents-unsafe', `unsafe root agent receipt file (${ROOT_RECEIPTS})`);
temp = join(configDir, `.agents-${randomBytes(12).toString('hex')}`);
const fd = await fs.open(temp, 'wx', 0o600);
try { await fd.writeFile(bytes); await fd.sync(); } finally { await fd.close(); }
await beforeReplace?.();
// A writer that ignores the lock still loses: the bytes must be the ones read.
const again = await readCurrent(configDir, uid, statFile);
if (again.exists !== before.exists || again.revision !== before.revision) fail('revision-conflict', 'the root agent file changed before the write; read it again');
// A new file is published with link, which fails rather than replace a file made meanwhile.
if (before.exists) await fs.rename(temp, file);
else await fs.link(temp, file);
committed = true;
if (!before.exists) await fs.unlink(temp);
await syncDir(configDir);
await receiptFd.writeFile(JSON.stringify(line) + '\n'); await receiptFd.sync();
return line;
} catch (error) { if (committed && error instanceof Error) error.committed = true; throw error; }
finally {
if (temp) await fs.rm(temp, { force: true });
if (receiptFd) await receiptFd.close();
await lockFd.close(); await fs.unlink(lock);
}
}
// The entries as a form or a list shows them: names, definition, the model, the other var keys and
// the opted-out organizations. No var value other than the model.
export function rootAgentSummary(document) {
return Object.entries(isPlainObject(document?.agents) ? document.agents : {}).map(([name, entry]) => {
const vars = isPlainObject(entry?.vars) ? entry.vars : {};
return {
name, definition: typeof entry?.definition === 'string' ? entry.definition : null,
model: typeof vars.model === 'string' ? vars.model : null,
varKeys: Object.keys(vars).filter(k => k !== 'model').sort(),
excludeOrgs: Array.isArray(entry?.excludeOrgs) ? entry.excludeOrgs.filter(x => typeof x === 'string') : [],
};
}).sort((a, b) => a.name.localeCompare(b.name));
}
// One row per entry: its form and what it names, never a var value.
export function projectAgentSummary(agents) {
if (!isPlainObject(agents)) return [];
return Object.keys(agents).sort().map(name => {
const entry = isPlainObject(agents[name]) ? agents[name] : {};
if (entry.exclude === true) return { name, form: 'exclude' };
if (entry.definition !== undefined) return { name, form: 'define', definition: typeof entry.definition === 'string' ? entry.definition : null, varKeys: Object.keys(isPlainObject(entry.vars) ? entry.vars : {}).sort() };
return { name, form: 'narrow', varKeys: Object.keys(isPlainObject(entry.vars) ? entry.vars : {}).sort() };
});
}
// The project-agent verb's guard (design section 7, the projects fence's one exception for agents):
// `next` may differ from `old` only in projects.<project>.agents.<name>, the project must exist and
// not be a library, and the project's entries and its union must pass A1's checks. Returns
// [code, message] or null. `root` is the root level as loadRootAgents reads it.
export function projectAgentProblem(old, next, { orgId, project, name, entry, rolesDir, root }) {
const target = old?.projects?.[project];
if (!isPlainObject(target)) return ['project-not-found', `projects.${project} does not exist`];
if (target.kind === 'library') return ['project-agents-library', `projects.${project} is a library project; it takes no agents`];
const without = doc => {
const copy = structuredClone(doc);
const p = copy?.projects?.[project];
if (isPlainObject(p) && isPlainObject(p.agents)) {
delete p.agents[name];
if (Object.keys(p.agents).length === 0) delete p.agents;
}
return canonicalJson(copy);
};
if (without(old) !== without(next)) return ['organization-projects-protected', `only projects.${project}.agents.${name} may change`];
const after = next.projects[project].agents?.[name];
if (canonicalJson(after ?? null) !== canonicalJson(entry ?? null)) return ['organization-projects-protected', `projects.${project}.agents.${name} is not the entry asked for`];
if (canonicalJson(after ?? null) === canonicalJson(target.agents?.[name] ?? null)) return ['no-change', `projects.${project}.agents.${name} is already that`];
const where = `projects.${project}.agents`;
let entries;
try { entries = checkProjectAgents(next.projects[project].agents ?? {}, { rolesDir, where }); }
catch (error) { if (error?.name !== 'BusinessError') throw error; return ['project-agents-invalid', withoutValues(error.message, entry, where)]; }
const org = Object.fromEntries(Object.entries(old.roles ?? {}).map(([n, r]) => [n, { definition: r?.definition, vars: r?.vars ?? {} }]));
try { projectUnion({ orgId, root, org, project: entries, projectId: project }); }
catch (error) { if (error?.name !== 'BusinessError') throw error; return ['project-agents-invalid', withoutValues(error.message, entry, where)]; }
return null;
}
// The root level a project union is checked against. A root file that refuses refuses the project
// write too: an exclusion or narrowing checked against a root level that did not parse could be wrong.
export function rootForProjectCheck(configDir, rolesDir) {
try { return loadRootAgents(configDir, { rolesDir }); }
catch (error) { if (error?.name !== 'BusinessError') throw error; fail('project-agents-invalid', 'the root agent file refuses; fix it before changing project agents'); }
}
// A project entry's form, for the change log and the receipt.
export const entryForm = entry => entry === null ? 'remove' : Object.hasOwn(entry, 'exclude') ? 'exclude' : Object.hasOwn(entry, 'definition') ? 'define' : 'narrow';
+2 -2
View File
@@ -18,10 +18,10 @@ export function checkAppearance(value, where) {
requireObject(value, where);
rejectUnknownKeys(value, ["theme", "mode"], where);
if (Object.hasOwn(value, "theme") && (typeof value.theme !== "string" || value.theme.length > 64 || !NAME.test(value.theme))) {
refuse(`${where}.theme must be an installed theme's name, matching ${NAME} (got ${JSON.stringify(value.theme)})`);
refuse(`${where}.theme must be an installed theme's name, matching ${NAME}`);
}
if (Object.hasOwn(value, "mode") && !APPEARANCE_MODES.includes(value.mode)) {
refuse(`${where}.mode must be one of ${APPEARANCE_MODES.join(", ")} (got ${JSON.stringify(value.mode)})`);
refuse(`${where}.mode must be one of ${APPEARANCE_MODES.join(", ")}`);
}
return Object.freeze({
...(Object.hasOwn(value, "theme") ? { theme: value.theme } : {}),
+15 -2
View File
@@ -67,7 +67,20 @@ export function runConfigCommand(argv, { stdout = process.stdout, stderr = proce
maxBuffer: 1024 * 1024,
env,
});
if (proc.status !== 0) fail(3, `system config problem (mosaic-config exit ${proc.status}): ${proc.stderr.trim()}`);
// Render known key paths with fixed constraints; never relay child stderr.
if (proc.status !== 0) {
const problem = proc.stderr.trim();
const field = problem.startsWith('mosaic-config: execution "dataRoot"') ? "dataRoot"
: /^mosaic-config: (?:unsupported )?"?((?:configVersion|environment|dataRoot|execution|console|federation|compactionReadBytes|compactionStreams)(?:\.[A-Za-z][A-Za-z0-9]*|\[\d+\])*)"?(?=[: ])/u.exec(problem)?.[1];
const name = "a 1-200 character name starting with a letter or digit and containing only letters, digits, . _ : / -";
const expected = { configVersion: "1", environment: "development or production", dataRoot: "a canonical absolute instance data path",
"execution.backend": "docker", "execution.adapter": "pi or mock", "execution.provider": name, "execution.model": name };
const detail = problem.startsWith("mosaic-config: configuration is not valid JSON (")
? "configuration is not valid JSON; expected readable JSON: "
: field ? `${field}: expected ${expected[field] ?? "a value satisfying the system configuration schema"}: `
: "expected valid system configuration: ";
fail(3, `system config problem (mosaic-config exit ${proc.status}): ${detail}${env.MOSAIC_CONFIG || join(env.HOME || "~", ".config", "mosaic-dev", "config.json")}${proc.error?.code ? ` (${proc.error.code})` : ""}`);
}
try {
return JSON.parse(proc.stdout);
} catch {
@@ -106,7 +119,7 @@ export function runConfigCommand(argv, { stdout = process.stdout, stderr = proce
continue;
}
const project = loadProject(entry.root);
if (project.id !== id) fail(2, `project file ${file} has id ${project.id}, but business ${organization.id} declares it as ${id}`);
if (project.id !== id) fail(2, `project file ${file} has id that does not match the declared project key`);
for (const instance of Object.keys(organization.roles)) resolveInstance({ system, business: organization, project, instance });
projects[id] = "valid";
}
@@ -0,0 +1,33 @@
// #1873: the config directory check shared by the accounts store (accounts.mjs) and the root agent
// file's writer (agent-levels-write.mjs). Both open, lock, write and rename files by path under
// configDir, so the path must stay this uid's from the check to the last write.
//
// Every ancestor is a real directory that only root or this uid can change: owned by one of them, and
// group or other writable only with the sticky bit (as /tmp is). Otherwise another user could rename
// configDir out from under the check and put their own file in its place. configDir itself belongs to
// this uid and is not group or other writable.
//
// Each caller keeps its own refusal code. No message quotes a path: a refusal names the rule and the
// uid only (the redaction rule, PR #1916).
import { lstatSync } from 'node:fs';
import { join, parse, resolve } from 'node:path';
import { OrgError } from './org-registry.mjs';
export function assertTrustedConfigDir(configDir, { uid, code }) {
const fail = message => { throw new OrgError(code, message); };
if (typeof configDir !== 'string' || resolve(configDir) !== configDir) fail('config directory must be an absolute normalized path');
const ancestor = `a config directory ancestor must belong to root or uid ${uid} and not be writable by others without the sticky bit`;
const trusted = st => (st.uid === 0 || st.uid === uid) && (!(st.mode & 0o022) || (st.mode & 0o1000));
const stat = path => { try { return lstatSync(path); } catch { return fail('config directory is missing'); } };
let current = parse(configDir).root;
if (!trusted(stat(current))) fail(ancestor);
for (const part of configDir.slice(current.length).split('/').filter(Boolean)) {
current = join(current, part);
const st = stat(current);
if (!st.isDirectory() || st.isSymbolicLink()) fail('config path contains a symbolic link or non-directory');
if (current !== configDir && !trusted(st)) fail(ancestor);
}
const st = stat(configDir);
if (st.uid !== uid) fail(`config directory must belong to uid ${uid}`);
if (st.mode & 0o022) fail('config directory must not be writable by group or other');
}
+37 -3
View File
@@ -13,6 +13,7 @@ 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 { entryForm, projectAgentProblem, rootForProjectCheck } from "./agent-levels-write.mjs";
export class OrganizationError extends Error {
constructor(code, message = code) {
@@ -272,7 +273,9 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
// The one commit path. `guard(old, document)` is what a caller may do to `projects`: write() keeps
// them whole (C2-04 M9) and addProject() allows one exact addition. `document` may be a function of
// the old document, for a writer that builds what it publishes from what it read under the lock.
function commit(id, document, { revision, actor, detail, context, beforeWrite = null, guard }) {
// #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 }) {
requireId(actor, "authenticated actor");
requireId(id, "organization id");
let revoked = false;
@@ -320,7 +323,8 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
syncDir(tx);
const event = {
transaction, actor, ...(context === undefined ? {} : { context }), business: id, before, after,
changes: changes(old, written), ...(detail === undefined ? {} : { detail }), at: new Date().toISOString(),
changes: pathsOnly ? changes(old, written).map(({ path, oldPresent, newPresent }) => ({ path, oldPresent, newPresent })) : changes(old, written),
...(detail === undefined ? {} : { detail }), at: new Date().toISOString(),
};
log(id, { ...event, state: "prepared" });
syncDir(home);
@@ -382,6 +386,36 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
}, { revision, actor, guard, detail: { verb: "project.remote.set", project } });
}
// #1873 A2 (design section 7): the only writer of `projects.<p>.agents`, one entry per call. `entry`
// is a define ({definition, vars?}), a narrow ({vars}), an exclude ({exclude: true}) or null to
// remove the entry. The human owner only: every agent actor, the tester included, refuses
// human-required, and so does any actor that is not the organization's human. The guard checks the
// candidate and the serialized bytes: nothing but that entry changes, the project exists and is not
// a library, and the project's entries and union pass A1's checks against the current root level.
// The change log names paths only; the detail names the project, the agent and the form.
function setProjectAgent(id, { revision, project, name, entry }, actor) {
requireId(id, "organization id");
requireId(project, "project id");
requireId(name, "agent name");
if (entry !== null && (entry === undefined || typeof entry !== "object" || Array.isArray(entry))) fail("project-agents-invalid", "an entry is an object, or null to remove it");
const root = rootForProjectCheck(dir, rolesDir);
const guard = (old, document) => {
const agents = Object.entries(old.roles ?? {}).flatMap(([n, role]) => [n, `${role?.definition}.${n}`]);
if (actor !== old.human || actor === "tester" || 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 problem = projectAgentProblem(old, document, { orgId: id, project, name, entry, rolesDir, root });
if (problem) fail(...problem);
};
return commit(id, old => {
const target = old.projects?.[project];
if (target === null || typeof target !== "object" || Array.isArray(target)) return old;
const agents = { ...(target.agents ?? {}) };
if (entry === null) delete agents[name]; else agents[name] = entry;
const { agents: _drop, ...rest } = target;
return { ...old, projects: { ...old.projects, [project]: Object.keys(agents).length ? { ...rest, agents } : rest } };
}, { revision, actor, guard, pathsOnly: true, detail: { verb: "project.agents.set", project, agent: name, form: entryForm(entry) } });
}
// 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.
@@ -449,5 +483,5 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
});
}
return { logFile, read, readRaw, write, addProject, setProjectRemote, hold, holdAsync, findAddition, record, recover: id => withLock(id, () => recover(id)) };
return { logFile, read, readRaw, write, addProject, setProjectRemote, setProjectAgent, hold, holdAsync, findAddition, record, recover: id => withLock(id, () => recover(id)) };
}
+1 -1
View File
@@ -30,7 +30,7 @@ export function validateProjectDocument(document, file) {
}
export function loadProject(root) {
if (typeof root !== "string" || !isAbsolute(root) || normalize(root) !== root) refuse(`project root must be a normalized absolute path (got ${JSON.stringify(root)})`);
if (typeof root !== "string" || !isAbsolute(root) || normalize(root) !== root) refuse(`project root must be a normalized absolute path`);
const file = projectFilePath(root);
return validateProjectDocument(readJsonFile(file, "project file"), file);
}
+54
View File
@@ -0,0 +1,54 @@
// Caller-only descriptions, shared by launch and live bus reads. Inputs are validated policy,
// never credential references, vars, call arguments or file contents.
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 }) {
const actions = [];
for (const [kind, held] of Object.entries(authority ?? {})) {
for (const action of held) {
const chiefOnly = ['role.hire', 'role.fire'].includes(action) && launcher !== instance;
const hard = GATED_ONLY.includes(action) || (action === 'role.launch' && launcher !== instance);
const domains = DOMAINS[action] ? [DOMAINS[action]] : ['delivery', 'technical'];
const routes = chiefOnly ? [{ to: 'unavailable' }] : hard ? [{ to: 'human' }] : kind === 'withinRole' ? [{ to: 'alone' }]
: domains.map(domain => ({ domain, to: arbiters[domain] === instance ? 'human' : arbiters[domain] ?? 'unconfigured' }));
actions.push({ action, routes });
}
}
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) })) };
});
return {
actions, resolve, network: network ?? 'unknown',
credentials: credentials.map(({ service, scopes }) => ({ service, ...(scopes === undefined ? {} : { scopes: structuredClone(scopes) }) })),
messaging: messaging ? { sendTo: [...messaging.sendTo], receiveFrom: [...messaging.receiveFrom] } : null,
};
}
export function railsText(rails) {
const lines = ['## Your rails', '', 'These are your resolved rails. Call my_rails for the current rails after a narrowing.'];
for (const { action, routes } of rails.actions) {
const route = routes.map(({ domain, to }) => `${domain ? `${domain}${domain === 'delivery' && routes.length > 1 ? ' (default)' : ''}: ` : ''}${to === 'human' ? 'a person' : to === 'alone' ? 'alone' : to === 'unavailable' ? 'unavailable here; only the configured chief can hire or fire' : to === 'unconfigured' ? 'unconfigured; report missing arbiter' : `arbiter instance ${JSON.stringify(to)}`}`).join('; ');
lines.push(`- ${action}: ${route}.`);
}
if (!rails.actions.length) lines.push('- Held actions: none.');
for (const { right, classes } of rails.resolve) lines.push(`- ${right} levels: ${classes.map(c => `${c.name}: ${c.level}`).join('; ') || 'unconfigured'}.`);
lines.push('- A resolve right must be held withinRole, and the decision must route to you. PRD approval also follows the request mode and author checks.');
lines.push('- Unheld actions are gated. Hard waits always go to a person; raise with blocking false when there is no task. A current spec citation can route other gated work to its arbiter.');
lines.push('- Revoking a protected instance is a hard wait; ending it follows the chief/person protection checks. Messaging also checks the recipient\'s policy.');
lines.push(`- Network: ${rails.network}.`);
for (const credential of rails.credentials) lines.push(`- Credential service ${credential.service}; scopes: ${credential.scopes === undefined ? 'none declared' : JSON.stringify(credential.scopes)}.`);
if (!rails.credentials.length) lines.push('- Credential services: none.');
lines.push(`- Messaging sendTo: ${rails.messaging ? rails.messaging.sendTo.join(', ') || 'none' : 'unrestricted by your definition'}.`);
lines.push(`- Messaging receiveFrom: ${rails.messaging ? rails.messaging.receiveFrom.join(', ') || 'none' : 'unrestricted by your definition'}.`);
return lines.join('\n');
}
+2
View File
@@ -3,6 +3,7 @@
// the record a launcher or the broker uses. Plain values: the most specific
// layer wins. Limits: every layer narrows, nothing widens.
import { ownRails } from "./rails.mjs";
import { refuse } from "./errors.mjs";
import { ACTIONS, NETWORKS, isHostKind } from "./vocabulary.mjs";
import { checkVars, mergeVars } from "./vars.mjs";
@@ -89,6 +90,7 @@ export function resolveInstance({ system, business, project = null, instance })
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 }),
credentials: Object.fromEntries(Object.entries(entry.credentials).filter(([kind]) => !isHostKind(kind))),
tracker: entry.tracker,
launch,
+11 -11
View File
@@ -24,7 +24,7 @@ const SKILL_NAME = /^[A-Za-z0-9._-]+$/;
function checkTools(tools) {
if (!Array.isArray(tools) || tools.length === 0) refuse('role "tools" must be a non-empty array of tool names');
return requireDistinctList(tools, "role tools", (tool) => {
if (!TOOLS.includes(tool)) refuse(`unsupported tool: ${JSON.stringify(tool)} (supported: ${TOOLS.join(", ")})`);
if (!TOOLS.includes(tool)) refuse(`unsupported tool (supported: ${TOOLS.join(", ")})`);
});
}
@@ -39,7 +39,7 @@ function checkAuthority(authority) {
const lists = {};
for (const key of ["withinRole", "crossRole"]) {
lists[key] = requireDistinctList(authority[key], `role authority.${key}`, (action) => {
if (!ACTIONS.includes(action)) refuse(`unknown action in authority.${key}: ${JSON.stringify(action)}`);
if (!ACTIONS.includes(action)) refuse(`unknown action in authority.${key}; expected an action from the role vocabulary`);
if (GATED_ONLY.includes(action)) refuse(`authority.${key} lists ${action}, which is always gated`);
});
}
@@ -71,7 +71,7 @@ function checkMessaging(messaging) {
function checkSkills(skills) {
return requireDistinctList(skills, "role skills", (name) => {
if (typeof name !== "string" || !SKILL_NAME.test(name) || name === "." || name === "..") {
refuse(`role skill must be a skill directory name matching ${SKILL_NAME} (got ${JSON.stringify(name)})`);
refuse(`role skill must be a skill directory name matching ${SKILL_NAME}`);
}
});
}
@@ -89,11 +89,11 @@ function checkHireable(hireable, file) {
requireId(name, "role hireable entry");
if (CREATIVE_DEFINITIONS.includes(name)) refuse(`hireable names ${name}, a creative definition only the human adds`);
const path = join(dir, `${name}.json`);
if (!existsSync(path)) refuse(`hireable names ${name}, which has no role file`);
if (!existsSync(path)) refuse(`hireable names a definition which has no role file`);
const named = validateRoleDocument(readJsonFile(path, "role file"), path, { hireable: false });
if (named.roleVersion !== 2) refuse(`hireable names ${name}, which is role version 1`);
if (named.roleVersion !== 2) refuse(`hireable names a definition which is role version 1`);
const outside = unhireableAction(named);
if (outside !== null) refuse(`hireable names ${name}, which holds ${outside}`);
if (outside !== null) refuse(`hireable names ${name}, which holds ${outside} outside the hireable ceiling`);
});
}
@@ -101,7 +101,7 @@ function checkGiteaScopes(scopes) {
const list = requireDistinctList(scopes, "gitea scopes", (scope) => {
const m = typeof scope === "string" ? /^(read|write):([a-z]+)$/.exec(scope) : null;
if (!m || !GITEA_SCOPE_CATEGORIES.includes(m[2])) {
refuse(`unsupported gitea scope: ${JSON.stringify(scope)} (expected read:<category> or write:<category>; categories: ${GITEA_SCOPE_CATEGORIES.join(", ")})`);
refuse(`unsupported gitea scope (expected read:<category> or write:<category>; categories: ${GITEA_SCOPE_CATEGORIES.join(", ")})`);
}
}, { nonEmpty: true });
const categories = list.map((s) => s.split(":")[1]);
@@ -116,9 +116,9 @@ function checkVikunjaScopes(scopes) {
const out = {};
for (const [group, verbs] of Object.entries(scopes)) {
const allowed = VIKUNJA_GRANTABLE[group];
if (!allowed) refuse(`vikunja route group not grantable to a role: ${JSON.stringify(group)}`);
if (!allowed) refuse(`vikunja route group not grantable to a role`);
out[group] = requireDistinctList(verbs, `vikunja scopes.${group}`, (verb) => {
if (!allowed.includes(verb)) refuse(`vikunja verb not grantable to a role: ${group}.${JSON.stringify(verb)}`);
if (!allowed.includes(verb)) refuse(`vikunja verb not grantable to a role`);
}, { nonEmpty: true });
}
return out;
@@ -145,7 +145,7 @@ function checkCredentials(credentials) {
function checkContract(contract, file) {
if (typeof contract !== "string" || !CONTRACT_NAME.test(contract)) {
refuse(`role "contract" must be a Markdown file name in the role's directory, matching ${CONTRACT_NAME} (got ${JSON.stringify(contract)})`);
refuse(`role "contract" must be a Markdown file name in the role's directory, matching ${CONTRACT_NAME}`);
}
const path = join(dirname(file), contract);
let stat;
@@ -193,7 +193,7 @@ export function validateRoleDocument(document, file, options = {}) {
rejectUnknownKeys(document, document.roleVersion === 1 ? V1_KEYS : V2_KEYS, "role");
requireId(document.name, "role name");
const base = basename(file).replace(/\.json$/, "");
if (document.name !== base) refuse(`role "name" (${document.name}) must match its filename (${base}.json)`);
if (document.name !== base) refuse(`role "name" must match its filename (${base}.json)`);
const tools = checkTools(document.tools);
if (document.roleVersion === 1) {
if (CREATIVE_DEFINITIONS.includes(document.name)) refuse(`creative role ${document.name} must be version 2`);
+1 -1
View File
@@ -44,7 +44,7 @@ export function requireDistinctList(value, where, check, { nonEmpty = false } =
const seen = new Set();
for (const item of value) {
check(item);
if (seen.has(item)) refuse(`duplicate entry in ${where}: ${JSON.stringify(item)}`);
if (seen.has(item)) refuse(`duplicate entry in ${where}`);
seen.add(item);
}
return [...seen];
+1 -1
View File
@@ -295,7 +295,7 @@ export const GRANT_ALL = "*";
// and a viewer grant never covers it. An expired grant covers nothing.
export function covers(grant, { business, project, instance }, now, { level } = {}) {
if (!(now instanceof Date) || Number.isNaN(now.getTime())) throw new TypeError("covers needs a valid Date for now");
if (!LEVELS.includes(level)) throw new TypeError(`covers needs a level, one of ${LEVELS.join(", ")} (got ${JSON.stringify(level)})`);
if (!LEVELS.includes(level)) throw new TypeError(`covers needs a level, one of ${LEVELS.join(", ")}`);
if (grant.expires !== undefined && now.getTime() >= Date.parse(grant.expires)) return false;
if (grant.business !== business) return false;
if (!(grant.projects[0] === GRANT_ALL || grant.projects.includes(project))) return false;
+12 -12
View File
@@ -104,19 +104,19 @@ test("users: unknown keys and the id rule", () => {
refuses(() => checkAccess(mutated((d) => { d.users[1].role = "x"; }), context()), /unsupported users\[1\] key: "role"/);
refuses(() => checkAccess(mutated((d) => { d.users[0] = "bean"; }), context()), /users\[0\] must be a JSON object/);
refuses(() => checkAccess(mutated((d) => { d.users[0].id = "Bean"; }), context()), /users\[0\]\.id must match/);
refuses(() => checkAccess(mutated((d) => { d.users[1].id = "bean"; }), context()), /users\[1\]\.id is a duplicate user id: bean/);
refuses(() => checkAccess(mutated((d) => { d.users[1].id = "bean"; }), context()), /users\[1\]\.id is a duplicate user id/);
});
test("users: human, tester and any business's human are refused", () => {
refuses(() => checkAccess(mutated((d) => { d.users[0].id = "human"; d.grants = []; }), context()), /users\[0\]\.id must not be "human"/);
refuses(() => checkAccess(mutated((d) => { d.users[1].id = "tester"; d.grants = []; }), context()), /users\[1\]\.id must not be "tester"/);
refuses(() => checkAccess(mutated((d) => { d.users[0].id = "melanie"; d.grants = []; }), context()), /users\[0\]\.id must not be a business's human \("melanie"\)/);
refuses(() => checkAccess(mutated((d) => { d.users[0].id = "human"; d.grants = []; }), context()), /users\[0\]\.id must not be "human", a bus principal/);
refuses(() => checkAccess(mutated((d) => { d.users[1].id = "tester"; d.grants = []; }), context()), /users\[1\]\.id must not be "tester", a bus principal/);
refuses(() => checkAccess(mutated((d) => { d.users[0].id = "melanie"; d.grants = []; }), context()), /users\[0\]\.id must not be a business's human/);
// The human of a business the grant doesn't name is refused too.
refuses(() => checkAccess(mutated((d) => { d.users[1].id = "jason"; d.grants = []; }), context()), /users\[1\]\.id must not be a business's human \("jason"\)/);
refuses(() => checkAccess(mutated((d) => { d.users[1].id = "jason"; d.grants = []; }), context()), /users\[1\]\.id must not be a business's human/);
});
test("users: home is a given peer or local", () => {
refuses(() => checkAccess(mutated((d) => { d.users[0].home = "elsewhere"; }), context()), /users\[0\]\.home must be a configured peer or "local" \(got "elsewhere"\)/);
refuses(() => checkAccess(mutated((d) => { d.users[0].home = "elsewhere"; }), context()), /users\[0\]\.home must be a configured peer or "local"/);
refuses(() => checkAccess(mutated((d) => { delete d.users[1].home; }), context()), /users\[1\]\.home must be a configured peer/);
refuses(() => checkAccess(doc(), context({ peers: [] })), /users\[0\]\.home must be a configured peer/);
});
@@ -125,7 +125,7 @@ test("users: subjects, provider, sub, uniqueness and owner subjects", () => {
refuses(() => checkAccess(mutated((d) => { d.users[0].subjects = []; }), context()), /users\[0\]\.subjects must not be empty/);
refuses(() => checkAccess(mutated((d) => { delete d.users[0].subjects; }), context()), /users\[0\]\.subjects must be an array/);
refuses(() => checkAccess(mutated((d) => { d.users[0].subjects[0].extra = 1; }), context()), /unsupported users\[0\]\.subjects\[0\] key: "extra"/);
refuses(() => checkAccess(mutated((d) => { d.users[1].subjects[0].provider = "google"; }), context()), /users\[1\]\.subjects\[0\]\.provider must be a configured provider \(got "google"\)/);
refuses(() => checkAccess(mutated((d) => { d.users[1].subjects[0].provider = "google"; }), context()), /users\[1\]\.subjects\[0\]\.provider must be a configured provider/);
refuses(() => checkAccess(mutated((d) => { d.users[0].subjects[0].sub = ""; }), context()), /users\[0\]\.subjects\[0\]\.sub must be a non-empty string/);
refuses(() => checkAccess(mutated((d) => { d.users[0].subjects[0].sub = 7; }), context()), /users\[0\]\.subjects\[0\]\.sub must be a non-empty string/);
// A pair twice in one user, and across two users.
@@ -140,15 +140,15 @@ test("users: subjects, provider, sub, uniqueness and owner subjects", () => {
test("grants: unknown keys, id, user and business", () => {
refuses(() => checkAccess(mutated((d) => { d.grants[0].role = "x"; }), context()), /unsupported grants\[0\] key: "role"/);
refuses(() => checkAccess(mutated((d) => { d.grants[1].id = "g-1"; }), context()), /grants\[1\]\.id is a duplicate grant id: g-1/);
refuses(() => checkAccess(mutated((d) => { d.grants[1].id = "g-1"; }), context()), /grants\[1\]\.id is a duplicate grant id/);
refuses(() => checkAccess(mutated((d) => { d.grants[0].id = "G 1"; }), context()), /grants\[0\]\.id must match/);
refuses(() => checkAccess(mutated((d) => { d.grants[0].user = "nobody"; }), context()), /grants\[0\]\.user must name a user in this file \(got "nobody"\)/);
refuses(() => checkAccess(mutated((d) => { d.grants[1].business = "other"; }), context()), /grants\[1\]\.business must name a declared business \(got "other"\)/);
refuses(() => checkAccess(mutated((d) => { d.grants[0].user = "nobody"; }), context()), /grants\[0\]\.user must name a user in this file/);
refuses(() => checkAccess(mutated((d) => { d.grants[1].business = "other"; }), context()), /grants\[1\]\.business must name a declared business/);
refuses(() => checkAccess(mutated((d) => { d.grants[1].business = "constructor"; }), context()), /grants\[1\]\.business must name a declared business/);
});
test("grants: projects is [\"*\"] or distinct lowercase UUIDs", () => {
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = ["stack"]; }), context()), /grants\[1\]\.projects\[0\] must be "\*" or a bus project id, a lowercase UUID \(got "stack"\)/);
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = ["stack"]; }), context()), /grants\[1\]\.projects\[0\] must be "\*" or a bus project id, a lowercase UUID/);
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = [P1.toUpperCase()]; }), context()), /grants\[1\]\.projects\[0\] must be "\*" or a bus project id/);
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = [P1, 7]; }), context()), /grants\[1\]\.projects\[1\] must be "\*" or a bus project id/);
refuses(() => checkAccess(mutated((d) => { d.grants[1].projects = ["*", P1]; }), context()), /grants\[1\]\.projects must be \["\*"\] alone/);
@@ -172,7 +172,7 @@ test("grants: any level other than collaborator or viewer refuses, naming grants
for (const bad of ["owner", "Collaborator", "COLLABORATOR", "Viewer", "", null, 1]) {
refuses(() => checkAccess(mutated((d) => { d.grants[1].level = bad; }), context()), /grants\[1\]\.level must be one of collaborator, viewer/);
}
refuses(() => checkAccess(mutated((d) => { delete d.grants[0].level; }), context()), /grants\[0\]\.level must be one of collaborator, viewer \(got undefined\)/);
refuses(() => checkAccess(mutated((d) => { delete d.grants[0].level; }), context()), /grants\[0\]\.level must be one of collaborator, viewer/);
});
test("grants: expires is optional ISO time, and a past expiry is not refused", () => {
+74 -2
View File
@@ -1,9 +1,9 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, chmodSync, symlinkSync, unlinkSync, existsSync, readdirSync, rmSync, statSync } from 'node:fs';
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, chmodSync, symlinkSync, unlinkSync, existsSync, readdirSync, rmSync, statSync, lstatSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { checkAccounts, loadAccounts, accountsRepairs, withAccounts, withAccountsLock, withAccountsLocked } from '../src/accounts.mjs';
import { checkAccounts, loadAccounts, accountsRepairs, assertAccountsOwner, withAccounts, withAccountsLock, withAccountsLocked } from '../src/accounts.mjs';
import { templateRender } from '../src/org-service.mjs';
import { loadBusiness } from '../src/organization-config.mjs';
import { REPO_ROLES } from './helpers.mjs';
@@ -178,3 +178,75 @@ test('review P3-6: a real business file and accounts file load with the same hum
const s = setup(t), doc = await templateRender('acme'); doc.human = OP; mkdirSync(join(s.configDir, 'businesses')); writeFileSync(join(s.configDir, 'businesses', 'acme.json'), JSON.stringify(doc), { mode: 0o600 });
assert.equal(loadBusiness('acme', { dir: s.configDir, rolesDir: REPO_ROLES }).human, OP); assert.equal(loadAccounts(s.file, s).users[0].id, OP);
});
// org-acct-anchor (#1873, A2 review P2-1): every ancestor of the config directory must belong to root
// or this uid, and be writable by others only with the sticky bit. Otherwise another user could rename
// the directory away and put their own accounts.json in its place. No refusal quotes the path.
const SECRETISH = 'sk-test-NOT-A-SECRET-1234';
function nested(t) {
const top = mkdtempSync(join(tmpdir(), 'a-')); t.after(() => { chmodSync(top, 0o700); rmSync(top, { recursive: true, force: true }); });
const parent = join(top, SECRETISH), configDir = join(parent, 'cfg'); mkdirSync(configDir, { recursive: true, mode: 0o700 }); chmodSync(parent, 0o700);
const accountsFile = join(configDir, 'accounts.json'); writeFileSync(accountsFile, JSON.stringify(document()), { mode: 0o600 });
return { top, parent, configDir, instance: 'test', ...context, file: accountsFile };
}
const unsafe = rule => e => e.code === 'accounts-unsafe' && rule.test(e.message) && !e.message.includes(SECRETISH) && !e.message.includes('/');
const ANCESTOR = /ancestor must belong to root or uid \d+ and not be writable by others without the sticky bit/;
test('org-acct-anchor: a 0777 ancestor refuses load, init and the lock; 1777 passes', async t => {
const s = nested(t), initDir = join(s.parent, 'new'); mkdirSync(initDir, { mode: 0o700 });
const init = { ...s, configDir: initDir, file: join(initDir, 'accounts.json') };
for (const dir of [s.parent, s.top]) {
chmodSync(dir, 0o777);
try {
assert.throws(() => loadAccounts(s.file, s), unsafe(ANCESTOR));
await assert.rejects(withAccounts(init, { revision: 0, verb: 'init', init: true }, d => d.users.push(user())), unsafe(ANCESTOR));
let entered = false;
await assert.rejects(withAccountsLock(s, async () => { entered = true; }), unsafe(ANCESTOR));
assert.equal(entered, false);
for (const c of [s, init]) for (const name of ['accounts.lock', 'accounts-receipts.jsonl']) assert.equal(existsSync(join(c.configDir, name)), false, name);
assert.equal(existsSync(init.file), false);
chmodSync(dir, 0o1777);
assert.equal(loadAccounts(s.file, s).revision, 1);
await withAccountsLock(s, async () => { entered = true; }); assert.equal(entered, true);
await withAccounts(init, { revision: 0, verb: 'init', init: true }, d => d.users.push(user()));
assert.equal(loadAccounts(init.file, init).revision, 1);
rmSync(init.file); rmSync(join(init.configDir, 'accounts-receipts.jsonl'));
} finally { chmodSync(dir, 0o700); }
}
// A group-writable ancestor without the sticky bit refuses too.
chmodSync(s.parent, 0o770);
try { assert.throws(() => loadAccounts(s.file, s), unsafe(ANCESTOR)); } finally { chmodSync(s.parent, 0o700); }
});
test('org-acct-anchor: an ancestor owned by another uid refuses; root-owned ancestors pass', t => {
const s = nested(t), other = process.geteuid() + 1;
// Simulated as the agent-levels tests do: as another uid, this uid's directories are foreign.
assert.throws(() => assertAccountsOwner(s.configDir, { uid: other }), unsafe(new RegExp(`ancestor must belong to root or uid ${other} `)));
assert.throws(() => loadAccounts(s.file, s, { uid: other }), unsafe(ANCESTOR));
// The real ancestors above the scratch root include root-owned ones (at least /), which pass.
assert.equal(lstatSync('/').uid, 0);
assertAccountsOwner(s.configDir);
assert.equal(loadAccounts(s.file, s).revision, 1);
});
test('org-acct-anchor: a symlinked ancestor refuses, and leaf refusals name no path', async t => {
const s = nested(t), link = join(s.top, 'link'); symlinkSync(s.parent, link);
const via = { ...s, configDir: join(link, 'cfg'), file: join(link, 'cfg', 'accounts.json') };
assert.throws(() => loadAccounts(via.file, via), unsafe(/symbolic link or non-directory/));
await assert.rejects(withAccountsLock(via, async () => {}), unsafe(/symbolic link or non-directory/));
assert.equal(existsSync(join(s.configDir, 'accounts.lock')), false);
// The leaf checks are unchanged, but no longer quote the path.
const other = process.geteuid() + 1;
for (const mode of [0o720, 0o702]) {
chmodSync(s.configDir, mode);
try { assert.throws(() => loadAccounts(s.file, s), unsafe(/^config directory must not be writable by group or other$/)); } finally { chmodSync(s.configDir, 0o700); }
}
// As another uid the ancestors refuse first; the leaf's uid rule is the same no-path form (source check below).
assert.throws(() => assertAccountsOwner(s.configDir, { uid: process.geteuid() + 1 }), e => !e.message.includes(SECRETISH) && !e.message.includes('/'));
const source = readFileSync(new URL('../src/config-dir-trust.mjs', import.meta.url), 'utf8') + readFileSync(new URL('../src/accounts.mjs', import.meta.url), 'utf8');
assert.match(source, /`config directory must belong to uid \$\{uid\}`/);
assert.match(source, /`accounts file must belong to uid \$\{uid\}`/);
assert.doesNotMatch(source, /must belong to uid \$\{uid\}: \$\{/);
assert.throws(() => loadAccounts(join(s.configDir, 'missing', 'accounts.json'), s), unsafe(/^config directory is missing$/));
assert.throws(() => assertAccountsOwner(`${s.configDir}/../cfg`), unsafe(/absolute normalized/));
});
@@ -0,0 +1,329 @@
// #1873 Phase A slice A3: root and project agent copies (design
// docs/plans/2026-11-10_agent-levels-design.md sections 4 and 9 item 2).
import test from "node:test";
import assert from "node:assert/strict";
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { checkRootAgents, loadRootAgents } from "../src/agent-levels.mjs";
import {
copiesView, copyInstanceName, copyRefusal, loadWithCopies, materializeCopies, projectRefusal, withCopies,
} from "../src/agent-copies.mjs";
import { businessFilePath, validateBusinessDocument } from "../src/organization-config.mjs";
import { resolveInstance } from "../src/resolve.mjs";
import { REPO, REPO_ROLES, businessDoc, systemFor, tmp, tokenFile, writeJson } from "./helpers.mjs";
const rolesDir = REPO_ROLES;
const SECRET = "sk-test-NOT-A-SECRET-1234";
const root = (agents) => ({ state: "ok", ...checkRootAgents({ agentsVersion: 1, agents }, { rolesDir }) });
// An organization `id` under `dir`, written where loadBusiness and the writer read it.
function org(dir, id, edit = (d) => d) {
const doc = edit(businessDoc(dir, id));
writeJson(businessFilePath(id, dir), doc);
return doc;
}
const boot = (dir, id, doc, r) => {
const snapshot = materializeCopies({ orgId: id, doc, root: r, rolesDir });
return { snapshot, business: withCopies(doc, businessFilePath(id, dir), { rolesDir, snapshot }) };
};
test("A3: two root agents, one excluded: the other becomes an instance in each organization, each its own", () => {
const dir = tmp();
const r = root({ gate: { definition: "merge-gate" }, sec: { definition: "security-reviewer", excludeOrgs: ["acme"] }, assistant: { definition: "assistant" } });
const acme = boot(dir, "acme", org(dir, "acme"), r);
assert.deepEqual(Object.keys(acme.snapshot.copies), ["gate"]);
assert.equal(acme.business.roles.gate.definition, "merge-gate");
assert.equal(Object.hasOwn(acme.business.roles, "sec"), false, "the excluded root agent is no instance");
// The root assistant stays shown, not launched (A6): never a copy.
assert.equal(Object.hasOwn(acme.business.roles, "assistant"), false);
assert.equal(acme.business.copies.gate.state, "instance");
// A second organization makes its own copies from the same root agents.
const beta = boot(dir, "beta", org(dir, "beta"), r);
assert.deepEqual(Object.keys(beta.snapshot.copies).sort(), ["gate", "sec"]);
assert.equal(beta.snapshot.org, "beta");
assert.notEqual(beta.snapshot.copies.gate, acme.snapshot.copies.gate);
assert.ok(Object.isFrozen(acme.snapshot.copies.gate), "the boot snapshot can't be changed in place");
// Each copy carries only its own organization's entry: the credential map is the organization's
// own, and a copy holds no credential.
assert.deepEqual(acme.business.roles.gate.credentials, {});
assert.deepEqual(beta.business.roles.gate.credentials, {});
});
test("A3: an unbound copy refuses by name and binding, and no value reaches the refusal or the view", () => {
const dir = tmp();
const doc = org(dir, "acme", (d) => {
// The secret is bound elsewhere in this organization: the coder's gitea token file is named it
// and holds it.
const file = tokenFile(join(dir, "secrets"), SECRET);
writeFileSync(file, SECRET);
d.roles.coder.credentials.gitea = { file, rotateBy: "2099-01-01" };
return d;
});
const r = root({ researcher: { definition: "research" }, reviewer2: { definition: "reviewer" } });
const { snapshot, business } = boot(dir, "acme", doc, r);
assert.equal(snapshot.copies.researcher.state, "unbound");
assert.deepEqual(snapshot.copies.researcher.missing, ["gitea"]);
assert.deepEqual(snapshot.copies.reviewer2.missing, ["gitea", "vikunja"]);
assert.equal(Object.hasOwn(business.roles, "researcher"), false, "an unbound copy is no instance");
const refusal = copyRefusal(business, "researcher");
assert.equal(refusal.code, "credential-unbound");
assert.match(refusal.message, /researcher/);
assert.match(refusal.message, /gitea/);
assert.equal(copyRefusal(business, "coder"), null, "an org instance is no copy");
for (const text of [refusal.message, copyRefusal(business, "reviewer2").message, JSON.stringify(business.copies), JSON.stringify(copiesView(snapshot.copies))]) {
assert.equal(text.includes(SECRET), false);
assert.equal(text.includes(dir), false, "no host path");
}
});
test("A3: a copy whose var the organization refuses is refused alone, and its value stays out of the reason", () => {
const dir = tmp();
const doc = org(dir, "acme");
const r = root({ gate: { definition: "merge-gate" }, wide: { definition: "merge-gate", vars: { model: SECRET } } });
const { business } = boot(dir, "acme", doc, r);
assert.equal(business.roles.gate.definition, "merge-gate", "the other copy still joins");
assert.equal(Object.hasOwn(business.roles, "wide"), false);
assert.equal(business.copies.wide.state, "refused");
const refusal = copyRefusal(business, "wide");
assert.equal(refusal.code, "copy-refused");
assert.match(refusal.message, /^wide can't launch in acme: /);
assert.equal(refusal.message.includes(SECRET), false);
assert.equal(JSON.stringify(business.copies).includes(SECRET), false);
});
test("A3: a copy's tools and actions equal an org instance's of the same role; a copy that skips checkRoles fails this", () => {
const dir = tmp();
const doc = org(dir, "acme", (d) => ({ ...d, roles: { ...d.roles, "gate-org": { definition: "merge-gate", vars: { model: "glm-5.3-flash" } } } }));
const r = root({ gate: { definition: "merge-gate", vars: { model: "glm-5.3-flash" } }, wide: { definition: "merge-gate", vars: { model: "not-a-listed-model" } } });
const snapshot = materializeCopies({ orgId: "acme", doc, root: r, rolesDir });
const file = businessFilePath("acme", dir);
const system = systemFor(dir);
const same = (business) => {
assert.deepEqual(business.roles.gate, business.roles["gate-org"]);
const copy = resolveInstance({ system, business, instance: "gate" });
const own = resolveInstance({ system, business, instance: "gate-org" });
assert.deepEqual(copy.limits.tools, own.limits.tools);
assert.deepEqual(copy.limits.authority, own.limits.authority);
assert.deepEqual(copy.credentials, own.credentials);
// A copy the organization's own checks refuse never joins.
assert.equal(Object.hasOwn(business.roles, "wide"), false);
};
same(withCopies(doc, file, { rolesDir, snapshot }));
// The counterfactual: the copies joined as their raw entries, with no checkRoles.
const plain = validateBusinessDocument(doc, file, { rolesDir });
const skipped = { ...plain, roles: { ...plain.roles, gate: { definition: "merge-gate", vars: { model: "glm-5.3-flash" } }, wide: { definition: "merge-gate", vars: { model: "not-a-listed-model" } } } };
assert.throws(() => same(skipped), assert.AssertionError);
});
test("A3: a root file change after boot changes nothing until the next boot", () => {
const dir = tmp();
const doc = org(dir, "acme");
writeJson(join(dir, "agents.json"), { agentsVersion: 1, agents: { gate: { definition: "merge-gate" } } });
const snapshot = materializeCopies({ orgId: "acme", doc, root: loadRootAgents(dir, { rolesDir }), rolesDir });
const first = loadWithCopies("acme", { dir, rolesDir, snapshot });
assert.equal(first.roles.gate.definition, "merge-gate");
// The root file now drops gate, adds one and excludes acme; the bus holds its snapshot.
writeJson(join(dir, "agents.json"), { agentsVersion: 1, agents: { sec: { definition: "security-reviewer" }, gate: { definition: "merge-gate", excludeOrgs: ["acme"] } } });
const later = loadWithCopies("acme", { dir, rolesDir, snapshot });
assert.deepEqual(Object.keys(later.copies), ["gate"]);
assert.equal(later.roles.gate.definition, "merge-gate");
assert.equal(Object.hasOwn(later.roles, "sec"), false);
// A broken root file is no different: nothing after boot reads it.
writeFileSync(join(dir, "agents.json"), "{ not json");
assert.equal(loadWithCopies("acme", { dir, rolesDir, snapshot }).roles.gate.definition, "merge-gate");
// The next boot reads it again.
writeJson(join(dir, "agents.json"), { agentsVersion: 1, agents: { sec: { definition: "security-reviewer" } } });
const next = materializeCopies({ orgId: "acme", doc, root: loadRootAgents(dir, { rolesDir }), rolesDir });
assert.deepEqual(Object.keys(next.copies), ["sec"]);
// Nothing that runs after boot reads the root file: only the cli's boot step names loadRootAgents.
for (const src of ["packages/bus/src/runtime.mjs", "packages/bus/src/broker.mjs", "packages/cli/src/launcher.mjs", "packages/organization/src/agent-copies.mjs"])
assert.equal(/loadRootAgents\s*\(/.test(readFileSync(join(REPO, src), "utf8")), false, src);
});
test("A3: project agents copy into their own project, and a refused union refuses every agent it defines", () => {
const dir = tmp();
mkdirSync(join(dir, "web"), { recursive: true });
const doc = org(dir, "acme", (d) => {
d.projects.stack.agents = { checker: { definition: "merge-gate" } };
// web excludes an agent nothing defines, so its union refuses.
d.projects.web = { root: join(dir, "web"), agents: { checker: { definition: "merge-gate" }, ghost: { exclude: true } } };
return d;
});
const { snapshot, business } = boot(dir, "acme", doc, root({}));
assert.equal(copyInstanceName("checker", "stack"), "checker.stack");
assert.deepEqual({ ...snapshot.copies["checker.stack"], vars: undefined }, { name: "checker", level: "project", project: "stack", definition: "merge-gate", vars: undefined, state: "instance" });
assert.equal(business.roles["checker.stack"].definition, "merge-gate");
assert.equal(Object.hasOwn(business.roles, "checker.web"), false);
const refusal = copyRefusal(business, "checker.web");
assert.equal(refusal.code, "project-refused");
assert.match(refusal.message, /^checker\.web can't launch in acme: project web refuses its agents: /);
assert.match(refusal.message, /ghost/);
// A project that stops being declared after boot takes its copy with it.
const { stack, ...rest } = doc.projects;
const later = withCopies({ ...doc, projects: rest }, businessFilePath("acme", dir), { rolesDir, snapshot });
assert.equal(Object.hasOwn(later.roles, "checker.stack"), false);
assert.equal(copyRefusal(later, "checker.stack").code, "project-refused");
});
test("A3: an organization instance of the same name wins over a root or project copy, at boot and after it, and the copy is gone, not refused", () => {
const dir = tmp();
const doc = org(dir, "acme", (d) => {
d.projects.stack.agents = { checker: { definition: "merge-gate" } };
// A raw org instance already named like the project copy wins at boot.
d.roles["checker.stack"] = { definition: "security-reviewer" };
return d;
});
const { snapshot, business } = boot(dir, "acme", doc, root({ gate: { definition: "merge-gate" } }));
assert.equal(business.copies.gate.state, "instance");
assert.equal(Object.hasOwn(snapshot.copies, "checker.stack"), false);
assert.equal(business.roles["checker.stack"].definition, "security-reviewer");
assert.equal(copyRefusal(business, "checker.stack"), null);
// An org instance added after boot replaces the root copy: the copy leaves the lookup, so nothing
// refuses the organization's own instance.
const later = withCopies({ ...doc, roles: { ...doc.roles, gate: { definition: "security-reviewer" } } }, businessFilePath("acme", dir), { rolesDir, snapshot });
assert.equal(later.roles.gate.definition, "security-reviewer", "the organization file's own instance");
assert.equal(Object.hasOwn(later.copies, "gate"), false);
assert.equal(copyRefusal(later, "gate"), null);
assert.equal(projectRefusal(later, "gate", "stack"), null);
});
test("A3: two agents whose instance names come out the same both refuse, naming each; neither overwrites the other", () => {
const dir = tmp();
mkdirSync(join(dir, "a.b"), { recursive: true });
mkdirSync(join(dir, "b"), { recursive: true });
const doc = org(dir, "acme", (d) => {
d.projects.stack.agents = { checker: { definition: "merge-gate" } };
// Project a.b's c and project b's c.a both come out c.a.b.
d.projects["a.b"] = { root: join(dir, "a.b"), agents: { c: { definition: "merge-gate" } } };
d.projects.b = { root: join(dir, "b"), agents: { "c.a": { definition: "merge-gate" } } };
return d;
});
// Root checker.stack is a different agent from project stack's checker.
const { snapshot, business } = boot(dir, "acme", doc, root({ "checker.stack": { definition: "security-reviewer" } }));
for (const [instance, claims] of [["checker.stack", /root agent checker\.stack and project stack's checker/], ["c.a.b", /project a\.b's c and project b's c\.a/]]) {
assert.equal(snapshot.copies[instance].state, "refused", instance);
assert.match(snapshot.copies[instance].reason, claims);
assert.equal(Object.hasOwn(business.roles, instance), false, instance);
assert.ok(copyRefusal(business, instance), instance);
}
// The view lists every claimant, so a reader finds each agent's copy by its name and project.
assert.deepEqual(business.copies["checker.stack"].claims, [{ name: "checker.stack", project: null }, { name: "checker", project: "stack" }]);
assert.deepEqual(business.copies["c.a.b"].claims, [{ name: "c", project: "a.b" }, { name: "c.a", project: "b" }]);
});
test("A3: a collision the current file brings in after boot refuses every claimant it reaches, and lifts when the file drops it", () => {
const dir = tmp();
mkdirSync(join(dir, "a.b"), { recursive: true });
mkdirSync(join(dir, "b"), { recursive: true });
const file = businessFilePath("acme", dir);
const doc = org(dir, "acme", (d) => {
d.projects["a.b"] = { root: join(dir, "a.b"), agents: { c: { definition: "merge-gate" } } };
d.projects.b = { root: join(dir, "b") };
return d;
});
const { snapshot, business } = boot(dir, "acme", doc, root({ "new.stack": { definition: "security-reviewer" } }));
assert.equal(business.copies["c.a.b"].state, "instance");
assert.equal(business.copies["new.stack"].state, "instance");
// Project b's c.a and project stack's new now come out as the names of copies made at boot.
const edited = structuredClone(doc);
edited.projects.b.agents = { "c.a": { definition: "merge-gate" } };
edited.projects.stack.agents = { new: { definition: "merge-gate" } };
const later = withCopies(edited, file, { rolesDir, snapshot });
for (const [instance, claims, reason] of [
["c.a.b", [{ name: "c", project: "a.b" }, { name: "c.a", project: "b" }], "the instance name c.a.b is claimed by project a.b's c and project b's c.a"],
["new.stack", [{ name: "new.stack", project: null }, { name: "new", project: "stack" }], "the instance name new.stack is claimed by root agent new.stack and project stack's new"],
]) {
assert.equal(later.copies[instance].state, "refused", instance);
assert.deepEqual(later.copies[instance].claims, claims, instance);
assert.equal(later.copies[instance].reason, reason, instance);
assert.equal(Object.hasOwn(later.roles, instance), false, instance);
assert.ok(copyRefusal(later, instance), instance);
}
// Without the new defines, both copies are instances again.
const back = withCopies(doc, file, { rolesDir, snapshot });
assert.equal(back.copies["c.a.b"].state, "instance");
assert.equal(back.copies["new.stack"].state, "instance");
});
test("A3: a project define whose vars change after boot refuses its copy until the next boot", () => {
const dir = tmp();
const file = businessFilePath("acme", dir);
const doc = org(dir, "acme", (d) => { d.projects.stack.agents = { checker: { definition: "merge-gate" } }; return d; });
const { snapshot, business } = boot(dir, "acme", doc, root({}));
assert.equal(business.copies["checker.stack"].state, "instance");
const edited = structuredClone(doc);
edited.projects.stack.agents.checker.vars = { "limits.tools": ["read"] };
const later = withCopies(edited, file, { rolesDir, snapshot });
const r = copyRefusal(later, "checker.stack");
assert.equal(r.code, "project-refused");
assert.match(r.message, /project stack changed checker's vars since the bus started; the change takes effect at the next start$/);
assert.equal(Object.hasOwn(later.roles, "checker.stack"), false);
// The next boot makes the copy from the new vars.
const next = boot(dir, "acme", edited, root({})).business;
assert.equal(next.copies["checker.stack"].state, "instance");
assert.deepEqual(next.roles["checker.stack"].vars["limits.tools"], ["read"]);
});
test("A3: each project's union is recomputed from the current file: a refused union refuses its own copy and every agent in it; exclude and narrow reach the instance", () => {
const dir = tmp();
mkdirSync(join(dir, "web"), { recursive: true });
const file = businessFilePath("acme", dir);
const doc = org(dir, "acme", (d) => {
d.projects.stack.agents = { checker: { definition: "merge-gate" } };
d.projects.web = { root: join(dir, "web") };
d.roles["gate-org"] = { definition: "merge-gate" };
return d;
});
const { snapshot, business } = boot(dir, "acme", doc, root({ gate: { definition: "merge-gate" } }));
assert.deepEqual(business.unions.stack, { ok: true, agents: [...Object.keys(doc.roles), "checker.stack", "gate"].sort() });
assert.equal(projectRefusal(business, "checker.stack", "web").code, "project-agent-elsewhere");
assert.equal(projectRefusal(business, "gate", "web"), null);
const system = systemFor(dir);
const tools = (b, instance) => resolveInstance({ system, business: b, instance }).limits.tools;
assert.ok(tools(business, "gate").includes("grep"));
// After boot: stack excludes something nothing defines, so its union refuses; web excludes gate and
// narrows gate-org's tools.
const edited = structuredClone(doc);
edited.projects.stack.agents.ghost = { exclude: true };
edited.projects.web.agents = { gate: { exclude: true }, "gate-org": { vars: { "limits.tools": ["read"] } } };
const later = withCopies(edited, file, { rolesDir, snapshot });
assert.equal(later.unions.stack.ok, false);
assert.match(later.unions.stack.reason, /ghost/);
// stack's own copy refuses, naming the project and why.
assert.equal(Object.hasOwn(later.roles, "checker.stack"), false);
const own = copyRefusal(later, "checker.stack");
assert.equal(own.code, "project-refused");
assert.match(own.message, /^checker\.stack can't launch in acme: project stack refuses its agents: .*ghost/);
// Every inherited agent refuses there too.
for (const instance of ["gate", "coder", "gate-org"]) {
const r = projectRefusal(later, instance, "stack");
assert.equal(r.code, "project-refused", instance);
assert.match(r.message, new RegExp(`^${instance} can't work in project stack: project stack refuses its agents: .*ghost`));
}
assert.match(projectRefusal(later, "gate", "web").message, /the project excludes it$/);
assert.equal(projectRefusal(later, "coder", "web"), null);
// The narrow reaches the instance itself, so its resolved tools and actions are narrowed.
assert.deepEqual(later.roles["gate-org"].vars["limits.tools"], ["read"]);
assert.deepEqual(tools(later, "gate-org"), ["read"]);
// A project that stops defining its agent takes the copy with it.
const dropped = structuredClone(doc);
dropped.projects.stack.agents = { other: { definition: "merge-gate" } };
assert.match(copyRefusal(withCopies(dropped, file, { rolesDir, snapshot }), "checker.stack").message, /project stack no longer defines checker as merge-gate$/);
});
test("A3: a project narrowing a root copy narrows that copy; an organization with only project entries still gets its unions", () => {
const dir = tmp();
const doc = org(dir, "acme", (d) => {
d.projects.stack.agents = { gate: { vars: { "limits.tools": ["read", "ls"] } } };
return d;
});
const { business } = boot(dir, "acme", doc, root({ gate: { definition: "merge-gate" } }));
assert.deepEqual(resolveInstance({ system: systemFor(dir), business, instance: "gate" }).limits.tools, ["read", "ls"]);
// No root agents and no copy: the project's entries still shape the business.
const only = org(dir, "beta", (d) => { d.projects.stack.agents = { coder: { vars: { "limits.tools": ["read"] } } }; return d; });
const plain = loadWithCopies("beta", { dir, rolesDir, snapshot: null });
assert.deepEqual(plain.unions.stack.agents, Object.keys(only.roles).sort());
assert.deepEqual(plain.roles.coder.vars["limits.tools"], ["read"]);
assert.equal(Object.hasOwn(plain, "copies"), false);
});
@@ -0,0 +1,366 @@
// #1873 Phase A slice A2: the root agent file's writer and the project-agent writer verb (design
// docs/plans/2026-11-10_agent-levels-design.md sections 6, 7 and 9 item 1).
import test from "node:test";
import assert from "node:assert/strict";
import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, symlinkSync, writeFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { loadRootAgents } from "../src/agent-levels.mjs";
import {
ABSENT_REVISION, ROOT_LOCK, ROOT_RECEIPTS, changeRootAgent, patchRootEntry, projectAgentProblem, readRootAgentsFile, rootAgentSummary,
} from "../src/agent-levels-write.mjs";
import * as oldPath from "../../business/src/agent-levels-write.mjs";
import { organizationWriter } from "../src/organization.mjs";
import { businessDoc, REPO_ROLES, rolesCopy, tmp, writeJson } from "./helpers.mjs";
const rolesDir = REPO_ROLES;
const SECRET = "sk-test-NOT-A-SECRET-1234";
const OWNER = Object.freeze({ provider: "local-dev", iss: "local-dev", sub: "local-dev-owner" });
const as = (actor = OWNER) => ({ actor, owner: OWNER, rolesDir });
function config() {
const dir = join(tmp(), "config");
mkdirSync(dir, { recursive: true, mode: 0o700 });
chmodSync(dir, 0o700);
return dir;
}
const file = dir => join(dir, "agents.json");
const bytes = dir => existsSync(file(dir)) ? readFileSync(file(dir), "utf8") : null;
const receipts = dir => existsSync(join(dir, ROOT_RECEIPTS))
? readFileSync(join(dir, ROOT_RECEIPTS), "utf8").trim().split("\n").filter(Boolean).map(line => JSON.parse(line)) : [];
const seeded = async dir => {
await changeRootAgent(dir, { revision: ABSENT_REVISION, name: "researcher", entry: { definition: "research", vars: { model: "claude-opus-5-5" } } }, as());
return readRootAgentsFile(dir);
};
const refuses = (code, check = () => true) => error => error.code === code && check(error);
test("the old business path re-exports the writer", () => {
assert.equal(oldPath.changeRootAgent, changeRootAgent);
});
test("root writer: an owner write lands, reads back through A1's loader, and its receipt names the change", async () => {
const dir = config();
const receipt = await changeRootAgent(dir, { revision: ABSENT_REVISION, name: "researcher", entry: { definition: "research", vars: { model: "claude-opus-5-5" } } }, as());
assert.equal(receipt.verb, "root.agent.set");
assert.equal(receipt.agent, "researcher");
assert.equal(receipt.definition, "research");
assert.deepEqual(receipt.changed, ["added"]);
assert.equal(receipt.revisionBefore, ABSENT_REVISION);
const st = lstatSync(file(dir));
assert.equal(st.mode & 0o777, 0o600);
assert.equal(st.nlink, 1);
const loaded = loadRootAgents(dir, { rolesDir });
assert.equal(loaded.agents.researcher.vars.model, "claude-opus-5-5");
const read = await readRootAgentsFile(dir);
assert.equal(read.revision, receipt.revisionAfter);
// A patch keeps the keys it leaves out and names, by key, what it changed.
const second = await changeRootAgent(dir, { revision: read.revision, name: "researcher", entry: old => patchRootEntry(old, { vars: { "limits.network": "api-only" }, excludeOrgs: ["home"] }) }, as());
assert.deepEqual(second.changed, ["vars.limits.network", "excludeOrgs"]);
assert.deepEqual(rootAgentSummary((await readRootAgentsFile(dir)).document), [{ name: "researcher", definition: "research", model: "claude-opus-5-5", varKeys: ["limits.network"], excludeOrgs: ["home"] }]);
const removed = await changeRootAgent(dir, { revision: second.revisionAfter, name: "researcher", entry: null }, as());
assert.equal(removed.verb, "root.agent.remove");
assert.deepEqual(removed.changed, ["removed"]);
assert.deepEqual(loadRootAgents(dir, { rolesDir }).agents, {});
const lines = receipts(dir);
assert.equal(lines.length, 3);
// Receipts carry names and key paths only: no var value, no host path.
const text = readFileSync(join(dir, ROOT_RECEIPTS), "utf8");
assert.ok(!text.includes("claude-opus-5-5") && !text.includes("api-only") && !text.includes(dir));
assert.ok(lines.every(line => line.actor === "owner" && line.uid === process.geteuid()));
assert.equal(lstatSync(join(dir, ROOT_RECEIPTS)).mode & 0o777, 0o600);
assert.equal(existsSync(join(dir, "agents.lock")), false);
assert.deepEqual(readdirSync(dir).filter(n => n.startsWith(".agents-")), []);
});
test("root writer: every actor but the owner refuses human-required and nothing is written", async () => {
const dir = config();
const others = [
{ provider: "local-dev", iss: "local-dev", sub: "chief" },
{ provider: "local-dev", iss: "local-dev", sub: "chief-of-staff.chief" },
{ provider: "local-dev", iss: "local-dev", sub: "tester" },
{ ...OWNER, provider: "tester" },
{ provider: "https-idp", iss: "https://idp.example", sub: "local-dev-owner" },
"local-dev-owner", "pm", null, undefined, {},
];
for (const actor of others) {
await assert.rejects(changeRootAgent(dir, { revision: ABSENT_REVISION, name: "researcher", entry: { definition: "research" } }, { actor, owner: OWNER, rolesDir }), refuses("human-required"), JSON.stringify(actor ?? null));
}
// An owner context that names the tester is not an owner either.
const tester = { provider: "local-dev", iss: "local-dev", sub: "tester" };
await assert.rejects(changeRootAgent(dir, { revision: ABSENT_REVISION, name: "researcher", entry: { definition: "research" } }, { actor: tester, owner: tester, rolesDir }), refuses("human-required"));
await assert.rejects(changeRootAgent(dir, { revision: ABSENT_REVISION, name: "researcher", entry: { definition: "research" } }, { actor: OWNER, rolesDir }), refuses("human-required"));
assert.equal(bytes(dir), null);
assert.deepEqual(receipts(dir), []);
});
test("root writer: a stale revision refuses and the file is unchanged", async () => {
const dir = config();
const first = await seeded(dir);
const before = bytes(dir);
await assert.rejects(changeRootAgent(dir, { revision: ABSENT_REVISION, name: "reviewer", entry: { definition: "reviewer" } }, as()), refuses("revision-conflict"));
await changeRootAgent(dir, { revision: first.revision, name: "reviewer", entry: { definition: "reviewer" } }, as());
const after = bytes(dir);
// The first revision is now stale too.
await assert.rejects(changeRootAgent(dir, { revision: first.revision, name: "pm", entry: { definition: "pm" } }, as()), refuses("revision-conflict"));
assert.notEqual(before, after);
assert.equal(bytes(dir), after);
await assert.rejects(changeRootAgent(dir, { revision: "nope", name: "pm", entry: { definition: "pm" } }, as()), refuses("revision-conflict"));
});
test("root writer: a symbolic link, a group-writable file and a foreign uid refuse and the file is unchanged", async () => {
const dir = config();
const { revision } = await seeded(dir);
const original = bytes(dir);
// A symbolic link in place of the file: the target is never followed or written.
const target = join(dir, "elsewhere.json");
writeFileSync(target, original); chmodSync(target, 0o600);
const real = join(dir, "real.json");
writeFileSync(real, original); chmodSync(real, 0o600);
const moved = readFileSync(file(dir));
writeFileSync(real, moved);
const { renameSync } = await import("node:fs");
renameSync(file(dir), join(dir, "kept.json"));
symlinkSync(target, file(dir));
await assert.rejects(changeRootAgent(dir, { revision, name: "pm", entry: { definition: "pm" } }, as()), refuses("agents-unsafe", e => !e.message.includes(dir)));
assert.equal(readFileSync(target, "utf8"), original);
assert.ok(lstatSync(file(dir)).isSymbolicLink());
await assert.rejects(readRootAgentsFile(dir), refuses("agents-unsafe"));
// Restore, then make it group-writable.
const { unlinkSync } = await import("node:fs");
unlinkSync(file(dir)); renameSync(join(dir, "kept.json"), file(dir));
chmodSync(file(dir), 0o620);
await assert.rejects(changeRootAgent(dir, { revision, name: "pm", entry: { definition: "pm" } }, as()), refuses("agents-unsafe", e => /group or other/.test(e.message)));
assert.equal(bytes(dir), original);
assert.equal(lstatSync(file(dir)).mode & 0o777, 0o620);
chmodSync(file(dir), 0o600);
// A second link to the file refuses: a write through one name would not be the file another reads.
const { linkSync } = await import("node:fs");
linkSync(file(dir), join(dir, "alias.json"));
await assert.rejects(changeRootAgent(dir, { revision, name: "pm", entry: { definition: "pm" } }, as()), refuses("agents-unsafe", e => /one link/.test(e.message)));
unlinkSync(join(dir, "alias.json"));
// Not this process's uid: the directory checks refuse first (an ancestor this uid owns is foreign).
await assert.rejects(changeRootAgent(dir, { revision, name: "pm", entry: { definition: "pm" } }, { ...as(), uid: process.geteuid() + 1 }), refuses("agents-unsafe", e => /config directory/.test(e.message) && !/root agent file/.test(e.message)));
await assert.rejects(readRootAgentsFile(dir, { uid: process.geteuid() + 1 }), refuses("agents-unsafe", e => /config directory/.test(e.message) && !/root agent file/.test(e.message)));
assert.equal(bytes(dir), original);
// A file another uid owns, in a directory that is this uid's: the file's own owner check refuses.
// Changing a file's owner needs root, so the descriptor stat reports a foreign uid through the seam.
const foreign = async fd => { const st = await fd.stat(); return Object.assign(Object.create(Object.getPrototypeOf(st)), st, { uid: st.uid + 1 }); };
const receiptsBefore = existsSync(join(dir, ROOT_RECEIPTS)) ? readFileSync(join(dir, ROOT_RECEIPTS), "utf8") : null;
await assert.rejects(changeRootAgent(dir, { revision, name: "pm", entry: { definition: "pm" } }, { ...as(), statFile: foreign }), refuses("agents-unsafe", e => /root agent file must belong to uid/.test(e.message)));
await assert.rejects(readRootAgentsFile(dir, { statFile: foreign }), refuses("agents-unsafe", e => /root agent file must belong to uid/.test(e.message)));
assert.equal(bytes(dir), original);
assert.equal(lstatSync(file(dir)).mode & 0o777, 0o600);
assert.equal(existsSync(join(dir, ROOT_RECEIPTS)) ? readFileSync(join(dir, ROOT_RECEIPTS), "utf8") : null, receiptsBefore);
assert.equal(existsSync(join(dir, ROOT_LOCK)), false, "the lock is released");
// A group-writable config directory refuses before any lock or read.
chmodSync(dir, 0o770);
await assert.rejects(changeRootAgent(dir, { revision, name: "pm", entry: { definition: "pm" } }, as()), refuses("agents-unsafe", e => /config directory/.test(e.message)));
chmodSync(dir, 0o700);
// An ancestor another user can write without the sticky bit refuses: that user could rename the
// config directory between the checks and the write. With the sticky bit, as /tmp has, it can't.
const parent = dirname(dir);
const mode = lstatSync(parent).mode & 0o7777;
chmodSync(parent, 0o777);
try {
await assert.rejects(changeRootAgent(dir, { revision, name: "pm", entry: { definition: "pm" } }, as()), refuses("agents-unsafe", e => /ancestor/.test(e.message) && !e.message.includes(dir)));
await assert.rejects(readRootAgentsFile(dir), refuses("agents-unsafe", e => /ancestor/.test(e.message)));
chmodSync(parent, 0o1777);
assert.equal((await readRootAgentsFile(dir)).revision, revision);
} finally { chmodSync(parent, mode); }
assert.equal(bytes(dir), original);
assert.equal(receipts(dir).length, 1);
assert.equal(existsSync(join(dir, "agents.lock")), false);
});
test("root writer: an invalid document refuses with the key named and the value absent", async () => {
const dir = config();
const { revision } = await seeded(dir);
const original = bytes(dir);
const cases = [
[{ definition: "research", vars: { model: SECRET.repeat(10) } }, /variable model/],
[{ definition: "research", vars: { "limits.network": SECRET } }, /variable limits\.network/],
[{ definition: SECRET }, /root agents\.leaky\.definition/],
[{ definition: "research", excludeOrgs: [SECRET.toUpperCase()] }, /root agents\.leaky\.excludeOrgs/],
[{ definition: "research", vars: { [SECRET]: "x" } }, /root agents\.leaky\.vars/],
[{ definition: "research", credentials: { gitea: SECRET } }, /root agents\.leaky\.credentials/],
[{ definition: "chief-of-staff" }, /chief-of-staff can't be a root agent/],
[{ definition: "assistant" }, /assistant definition is the root agent named assistant only/],
];
for (const [entry, pattern] of cases) {
await assert.rejects(changeRootAgent(dir, { revision, name: "leaky", entry }, as()),
refuses("agents-invalid", e => pattern.test(e.message) && !e.message.includes(SECRET) && !e.message.includes(SECRET.toUpperCase()) && !e.message.includes(dir)),
JSON.stringify(Object.keys(entry)));
}
await assert.rejects(changeRootAgent(dir, { revision, name: SECRET.toUpperCase(), entry: { definition: "research" } }, as()), refuses("agents-invalid", e => !e.message.includes(SECRET.toUpperCase())));
await assert.rejects(changeRootAgent(dir, { revision, name: "absent", entry: null }, as()), refuses("agent-not-found"));
await assert.rejects(changeRootAgent(dir, { revision, name: "researcher", entry: { definition: "research", vars: { model: "claude-opus-5-5" } } }, as()), refuses("no-change"));
assert.equal(bytes(dir), original);
assert.equal(receipts(dir).length, 1);
});
test("root writer: a crash between the temp file and the rename leaves the old file whole", async () => {
const dir = config();
const { revision } = await seeded(dir);
const original = bytes(dir);
await assert.rejects(changeRootAgent(dir, { revision, name: "reviewer", entry: { definition: "reviewer" } }, { ...as(), beforeReplace: () => { throw new Error("crash"); } }), /crash/);
assert.equal(bytes(dir), original);
assert.deepEqual(readdirSync(dir).filter(n => n.startsWith(".agents-")), []);
assert.equal(existsSync(join(dir, "agents.lock")), false);
assert.equal(receipts(dir).length, 1);
// A writer that ignores the lock and changes the file between the temp and the rename loses.
await assert.rejects(changeRootAgent(dir, { revision, name: "reviewer", entry: { definition: "reviewer" } }, { ...as(), beforeReplace: () => writeFileSync(file(dir), original.replace("research\"", "research\" ")) }), refuses("revision-conflict"));
// A new file is never published over one created meanwhile.
const fresh = config();
await assert.rejects(changeRootAgent(fresh, { revision: ABSENT_REVISION, name: "pm", entry: { definition: "pm" } }, { ...as(), beforeReplace: () => writeJson(file(fresh), { agentsVersion: 1, agents: {} }) }), refuses("revision-conflict"));
assert.deepEqual(JSON.parse(bytes(fresh)), { agentsVersion: 1, agents: {} });
// A failure after the rename says the change landed.
const landed = config();
await assert.rejects(changeRootAgent(landed, { revision: ABSENT_REVISION, name: "pm", entry: { definition: "pm" } }, { ...as(), syncDirectory: async () => { throw new Error("disk"); } }), e => e.committed === true);
assert.ok(loadRootAgents(landed, { rolesDir }).agents.pm);
});
test("root writer: a held lock refuses agents-locked", async () => {
const dir = config();
writeFileSync(join(dir, "agents.lock"), "");
await assert.rejects(changeRootAgent(dir, { revision: ABSENT_REVISION, name: "pm", entry: { definition: "pm" } }, as()), refuses("agents-locked"));
assert.equal(bytes(dir), null);
});
// The project-agent verb: the organization file's projects.<p>.agents, one entry per call.
function org(mutate = () => {}) {
const root = tmp();
const doc = businessDoc(root);
mutate(doc);
const dir = join(root, "config");
const orgFile = writeJson(join(dir, "businesses/acme.json"), doc);
chmodSync(dir, 0o700);
const roles = rolesCopy(root);
return { dir, orgFile, roles, writer: organizationWriter({ dir, rolesDir: roles }) };
}
const agentsOf = (writer, project = "stack") => writer.read("acme").document.projects[project].agents;
const history = (writer) => readFileSync(writer.logFile("acme"), "utf8");
test("project verb: each entry form lands, the log names paths only, and remove drops the key", () => {
const { writer } = org();
let { revision } = writer.read("acme");
const step = (name, entry) => ({ revision } = writer.setProjectAgent("acme", { revision, project: "stack", name, entry }, "jason"));
step("checker", { definition: "fact-checker", vars: { model: "claude-sonnet-5-5" } });
assert.deepEqual(agentsOf(writer), { checker: { definition: "fact-checker", vars: { model: "claude-sonnet-5-5" } } });
step("coder", { vars: { "limits.network": "none" } });
step("reviewer", { exclude: true });
assert.deepEqual(Object.keys(agentsOf(writer)).sort(), ["checker", "coder", "reviewer"]);
step("checker", null);
step("coder", null);
step("reviewer", null);
assert.equal(writer.read("acme").document.projects.stack.agents, undefined);
const events = history(writer).trim().split("\n").map(line => JSON.parse(line)).filter(e => e.state === "committed");
assert.deepEqual(events.map(e => e.detail.form), ["define", "narrow", "exclude", "remove", "remove", "remove"]);
assert.ok(events.every(e => e.detail.verb === "project.agents.set" && e.detail.project === "stack" && e.actor === "jason"));
assert.ok(events.every(e => e.changes.every(c => !("old" in c) && !("new" in c))));
assert.ok(!history(writer).includes("claude-sonnet-5-5"));
});
test("project verb: a narrow that widens refuses, and refusals never quote a value", () => {
const { writer, orgFile } = org();
const { revision } = writer.read("acme");
const before = readFileSync(orgFile, "utf8");
const refusals = [
[{ vars: { model: SECRET } }, "project-agents-invalid", /can only narrow/],
[{ vars: { "limits.network": SECRET } }, "project-agents-invalid", /variable limits\.network/],
[{ definition: "fact-checker", vars: { model: SECRET.repeat(10) } }, "project-agents-invalid", /variable model/],
[{ definition: SECRET }, "project-agents-invalid", /projects\.stack\.agents\.coder\.definition/],
[{ definition: "fact-checker", credentials: { gitea: SECRET } }, "project-agents-invalid", /credentials/],
[{ exclude: false }, "project-agents-invalid", /exclude entry/],
[{}, "project-agents-invalid", /defines \(definition\), narrows \(vars\) or excludes/],
];
for (const [entry, code, pattern] of refusals) {
assert.throws(() => writer.setProjectAgent("acme", { revision, project: "stack", name: "coder", entry }, "jason"),
e => e.code === code && pattern.test(e.message) && !e.message.includes(SECRET), JSON.stringify(entry));
}
// A narrow or exclude that names nothing it could act on refuses (the union check).
assert.throws(() => writer.setProjectAgent("acme", { revision, project: "stack", name: "ghost", entry: { exclude: true } }, "jason"), e => e.code === "project-agents-invalid" && /names no root or organization agent/.test(e.message));
assert.throws(() => writer.setProjectAgent("acme", { revision, project: "stack", name: "ghost", entry: null }, "jason"), { code: "agent-not-found" });
assert.equal(readFileSync(orgFile, "utf8"), before);
});
test("project verb: the chief and the assistant refuse", () => {
const { writer, orgFile } = org(doc => { doc.roles.assistant = { definition: "assistant" }; });
const { document, revision } = writer.read("acme");
assert.equal(document.roles.assistant.definition, "assistant", "the fixture has an organization assistant");
assert.equal(document.roles.chief.definition, "chief-of-staff", "the fixture has a chief");
const before = readFileSync(orgFile, "utf8");
const protectedAgent = /a project can't change the organization's (chief-of-staff|assistant)/;
const notProjectAgent = /can't be a project agent/;
for (const [name, entry, reason] of [
["chief", { exclude: true }, protectedAgent], ["chief", { vars: { "limits.network": "none" } }, protectedAgent], ["chief", { definition: "reviewer" }, protectedAgent],
["assistant", { exclude: true }, protectedAgent], ["assistant", { vars: { "limits.network": "none" } }, protectedAgent], ["assistant", { definition: "reviewer" }, protectedAgent],
["helper", { definition: "chief-of-staff" }, notProjectAgent], ["helper", { definition: "assistant" }, notProjectAgent],
]) {
assert.throws(() => writer.setProjectAgent("acme", { revision, project: "stack", name, entry }, "jason"), e => e.code === "project-agents-invalid" && reason.test(e.message), `${name} ${JSON.stringify(entry)}`);
}
assert.equal(readFileSync(orgFile, "utf8"), before);
});
test("project verb: every agent actor and every non-owner refuses human-required", () => {
const { writer, orgFile } = org();
const { revision } = writer.read("acme");
const before = readFileSync(orgFile, "utf8");
for (const actor of ["pm", "pm.pm", "chief", "chief-of-staff.chief", "coder", "tester", "local-dev-owner", "someone"]) {
assert.throws(() => writer.setProjectAgent("acme", { revision, project: "stack", name: "checker", entry: { definition: "fact-checker" } }, actor), { code: "human-required" }, actor);
}
assert.equal(readFileSync(orgFile, "utf8"), before);
});
test("project verb: only projects.<p>.agents.<name> may change; the projects fence keeps every other writer out", () => {
const { writer, orgFile, roles } = org();
const { document, revision } = writer.read("acme");
const before = readFileSync(orgFile, "utf8");
// The general writer can't touch project agents, or anything else under projects.
const withAgents = structuredClone(document);
withAgents.projects.stack.agents = { checker: { definition: "fact-checker" } };
assert.throws(() => writer.write("acme", withAgents, { revision, actor: "jason" }), { code: "organization-projects-protected" });
// The verb's guard refuses any candidate that changes another projects key.
const root = { state: "absent", agents: {}, assistant: null };
const args = { orgId: "acme", project: "stack", name: "checker", entry: { definition: "fact-checker" }, rolesDir: roles, root };
const good = structuredClone(document);
good.projects.stack.agents = { checker: { definition: "fact-checker" } };
assert.equal(projectAgentProblem(document, good, args), null);
const mutations = [
d => { d.projects.stack.root = "/elsewhere"; },
d => { d.projects.other = { root: "/elsewhere" }; },
d => { d.projects.stack.agents.extra = { definition: "reviewer" }; },
d => { d.human = "someone"; },
d => { d.projects.stack.agents.checker = { definition: "reviewer" }; },
];
for (const mutate of mutations) {
const next = structuredClone(good);
mutate(next);
assert.equal(projectAgentProblem(document, next, args)?.[0], "organization-projects-protected", mutate.toString());
}
// A missing project and a library project refuse.
assert.throws(() => writer.setProjectAgent("acme", { revision, project: "nope", name: "checker", entry: { definition: "fact-checker" } }, "jason"), { code: "project-not-found" });
const library = structuredClone(document);
library.projects.stack.kind = "library";
assert.equal(projectAgentProblem(library, good, args)?.[0], "project-agents-library");
// A stale revision refuses.
writer.setProjectAgent("acme", { revision, project: "stack", name: "checker", entry: { definition: "fact-checker" } }, "jason");
assert.throws(() => writer.setProjectAgent("acme", { revision, project: "stack", name: "other", entry: { definition: "reviewer" } }, "jason"), { code: "revision-conflict" });
assert.notEqual(readFileSync(orgFile, "utf8"), before);
});
test("project verb: a root file that refuses refuses the project write", () => {
const { writer, dir, orgFile } = org();
writeJson(join(dir, "agents.json"), { agentsVersion: 2 });
const { revision } = writer.read("acme");
const before = readFileSync(orgFile, "utf8");
assert.throws(() => writer.setProjectAgent("acme", { revision, project: "stack", name: "checker", entry: { definition: "fact-checker" } }, "jason"), e => e.code === "project-agents-invalid" && /root agent file/.test(e.message));
assert.equal(readFileSync(orgFile, "utf8"), before);
});
test("project verb: a narrow of a root agent checks against the root level", async () => {
const { writer, dir, roles } = org();
await changeRootAgent(dir, { revision: ABSENT_REVISION, name: "researcher", entry: { definition: "research" } }, { actor: OWNER, owner: OWNER, rolesDir: roles });
const { revision } = writer.read("acme");
writer.setProjectAgent("acme", { revision, project: "stack", name: "researcher", entry: { vars: { "limits.network": "none" } } }, "jason");
assert.deepEqual(agentsOf(writer).researcher, { vars: { "limits.network": "none" } });
});
@@ -59,7 +59,7 @@ test("row 325: the old mosaic-stack project key refuses against those same proje
const r = x.validate();
assert.equal(r.status, 2, r.stdout + r.stderr);
assert.equal(r.stdout, "");
assert.match(r.stderr, /has id stack, but business mosaic-stack declares it as mosaic-stack/);
assert.match(r.stderr, /has id that does not match the declared project key/);
assert.ok(r.stderr.includes(x.projectFile));
assert.doesNotMatch(r.stderr, /no project file/);
});
+1 -1
View File
@@ -77,7 +77,7 @@ test("validate: project files", () => {
assert.deepEqual(JSON.parse(r.out).projects, { stack: "valid" });
writeJson(file, { projectVersion: 1, id: "other" });
assert.match(run(s, "validate", "acme").err, /has id other, but business acme declares it as stack/);
assert.match(run(s, "validate", "acme").err, /has id that does not match the declared project key/);
assert.equal(run(s, "validate", "acme").code, 2);
writeJson(file, { projectVersion: 1, id: "stack", roles: { ghost: {} } });
const ghost = run(s, "validate", "acme");
@@ -0,0 +1,81 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const secret = 'sk-test-NOT-A-SECRET-1234';
function scratch(t) { const root = mkdtempSync(join(tmpdir(), 'r4-')); t.after(() => rmSync(root, { recursive: true, force: true })); return root; }
function safe(label, Type, props = {}, omitted = secret) { return error => {
assert.ok(error instanceof Type, error.stack);
for (const [key, value] of Object.entries(props)) assert.equal(error[key], value);
assert.ok(error.message.includes(label), error.message);
assert.doesNotMatch(error.message + error.stack, /sk-test|NOT-A-SECRET|Unexpected token/);
assert.ok(!error.message.includes(omitted), error.message);
assert.equal(error.cause, undefined);
return true;
}; }
import { checkAccess } from '../src/access.mjs';
import { checkAppearance } from '../src/appearance.mjs';
import { loadProject } from '../src/project.mjs';
import { validateRoleDocument } from '../src/role.mjs';
import { covers } from '../src/vocabulary.mjs';
import { BusinessError } from '../src/errors.mjs';
const context = { providers: ['test'], peers: [], businesses: { acme: { human: 'jason', instances: ['coder'] } }, ssoSubjects: [] };
const access = () => ({ accessVersion: 1, revision: 1, users: [{ id: 'bean', home: 'local', subjects: [{ provider: 'test', sub: 'user' }] }], grants: [{ id: 'grant', user: 'bean', business: 'acme', projects: ['*'], instances: ['coder'], level: 'viewer' }] });
for (const [field, edit] of [
['users[0].home', d => { d.users[0].home = secret; }],
['users[0].subjects[0].provider', d => { d.users[0].subjects[0].provider = secret; }],
...['user', 'business', 'level'].map(k => [`grants[0].${k}`, d => { d.grants[0][k] = secret; }]),
['grants[0].projects[0]', d => { d.grants[0].projects = [secret]; }],
['accessVersion', d => { d.accessVersion = secret; }],
]) test(`access ${field} omits the refused value`, () => {
const d = access(); edit(d); assert.throws(() => checkAccess(d, context), safe(field, BusinessError, { exitCode: 2 }));
});
for (const k of ['theme', 'mode']) test(`appearance ${k} omits its value`, () => {
assert.throws(() => checkAppearance({ [k]: secret }, 'appearance'), safe(k, BusinessError, { exitCode: 2 }));
});
test('project root omits its configured value', () => assert.throws(() => loadProject(secret), safe('project root', BusinessError, { exitCode: 2 })));
test('covers names the level field, not its value', () => assert.throws(() => covers({}, {}, new Date(), { level: secret }), safe('level', TypeError)));
const role = () => ({ roleVersion: 2, name: 'r', title: 'R', contract: 'r.md', tools: ['read'], network: 'none', authority: { withinRole: [], crossRole: [] }, credentials: [] });
for (const [field, edit] of [
['skill', d => { d.skills = [secret + '/']; }],
['contract', d => { d.contract = secret; }],
['tool', d => { d.tools = [secret]; }],
['authority.withinRole', d => { d.authority.withinRole = [secret]; }],
['hireable', d => { d.hireable = [secret.toLowerCase()]; }],
['gitea scope', d => { d.credentials = [{ service: 'gitea', scopes: [secret] }]; }],
['vikunja route group', d => { d.credentials = [{ service: 'vikunja', scopes: { [secret]: ['read'] } }]; }],
['vikunja verb', d => { d.credentials = [{ service: 'vikunja', scopes: { tasks: [secret] } }]; }],
['name', d => { d.name = secret.toLowerCase(); }],
]) test(`role ${field} omits the configured value`, t => {
const root = scratch(t); writeFileSync(join(root, 'r.md'), '# R\n\n## Authority\n');
const d = role(); edit(d);
assert.throws(() => validateRoleDocument(d, join(root, 'r.json')), safe(field, BusinessError, { exitCode: 2 }, secret.toLowerCase()));
});
// This branch is reachable only with already-validated service names; a sentinel service
// is refused earlier. A known service is already displayed in the role's rails.
test('creative service refusal names the validated service list', t => {
const root = scratch(t); writeFileSync(join(root, 'companion.md'), '# R\n\n## Authority\n');
const d = { ...role(), name: 'companion', contract: 'companion.md', messaging: { sendTo: ['human'], receiveFrom: ['human'] }, credentials: [{ service: 'vikunja', scopes: { tasks: ['read_one'] } }] };
assert.throws(() => validateRoleDocument(d, join(root, 'companion.json')), named('credential', 'vikunja'));
});
test('role duplicate skill omits the accepted list value', t => {
const root = scratch(t); writeFileSync(join(root, 'r.md'), '# R\n\n## Authority\n');
const d = { ...role(), skills: [secret, secret] };
assert.throws(() => validateRoleDocument(d, join(root, 'r.json')), safe('role skills', BusinessError, { exitCode: 2 }));
});
for (const at of ['user', 'grant', 'scope', 'human']) test(`access ${at} duplicate/reserved refusal names accepted ids`, () => {
const value = secret.toLowerCase(), d = access(), ctx = structuredClone(context);
if (at === 'user') { d.users[0].id = value; d.users.push({ ...structuredClone(d.users[0]), subjects: [{ provider: 'test', sub: 'second' }] }); }
if (at === 'grant') { d.grants[0].id = value; d.grants.push(structuredClone(d.grants[0])); }
if (at === 'scope') { ctx.businesses.acme.instances = [value]; d.grants[0].instances = [value, value]; }
if (at === 'human') { ctx.businesses.acme.human = value; d.users[0].id = value; }
assert.throws(() => checkAccess(d, ctx), named(at === 'scope' ? 'instances' : '.id', value));
});
function named(label, value) { return error => {
assert.ok(error instanceof BusinessError); assert.equal(error.exitCode, 2);
assert.ok(error.message.includes(label), error.message);
assert.ok(error.message.includes(value), error.message);
return true;
}; }
+1 -1
View File
@@ -164,7 +164,7 @@ test("the record carries what the broker and launcher need", () => {
const s = setup();
const pm = resolve(s, "pm");
assert.deepEqual(Object.keys(pm).sort(), [
"business", "contract", "credentials", "definition", "digest", "holder", "instance", "launch", "limits", "project", "provenance", "skills", "tracker", "vars", "webSource",
"business", "contract", "credentials", "definition", "digest", "holder", "instance", "launch", "limits", "project", "provenance", "rails", "skills", "tracker", "vars", "webSource",
]);
assert.equal(pm.contract, join(REPO_ROLES, "pm.md"));
assert.equal(pm.holder, "sage");
+8 -8
View File
@@ -174,7 +174,7 @@ test("row 64 skills: optional, distinct skill directory names, none on version 1
assert.deepEqual(role.skills, ["ms-unslop", "ms_sdlc.v2"]);
assert.ok(Object.isFrozen(role.skills));
refuses(() => check({ skills: "ms-unslop" }), /role skills must be an array/);
refuses(() => check({ skills: ["ms-unslop", "ms-unslop"] }), /duplicate entry in role skills: "ms-unslop"/);
refuses(() => check({ skills: ["ms-unslop", "ms-unslop"] }), /duplicate entry in role skills/);
for (const bad of [".", "..", "", "a/b", "../ms-unslop", "ms unslop", 7, null]) {
refuses(() => check({ skills: [bad] }), /role skill must be a skill directory name/);
}
@@ -255,7 +255,7 @@ test("M1: a creative role file wider than read and answer is refused", () => {
};
assert.doesNotThrow(creative("ideas", { tools: ["read", "grep", "find", "ls", "web"] }));
for (const tool of ["write", "edit", "bash"]) {
refuses(creative("ideas", { tools: ["read", tool] }), new RegExp(`^creative role ideas may not name tools ${tool} `));
refuses(creative("ideas", { tools: ["read", tool] }), /^creative role ideas may not name tools (?:write|edit|bash) \(allowed: /);
}
const gitea = [{ service: "gitea", scopes: ["read:user", "write:repository"] }];
assert.doesNotThrow(creative("ideas", { authority: { withinRole: ["message.send", "library.write"], crossRole: [] }, credentials: gitea }));
@@ -307,14 +307,14 @@ test("hireable: a list naming a definition that holds any refused action refuses
for (const action of REFUSED_FOR_HIRE) {
for (const key of ["withinRole", "crossRole"]) {
const dir = hireDir({ chief: { hireable: ["worker"] }, worker: { [key]: [action], ...(key === "crossRole" ? { withinRole: [] } : {}) } });
refuses(() => loadRole(dir, "chief"), new RegExp(`hireable names worker, which holds ${action.replace(/\./g, "\\.")}$`));
refuses(() => loadRole(dir, "chief"), /hireable names worker, which holds [a-z.]+ outside the hireable ceiling$/);
}
}
});
test("hireable: a mixed definition refuses, a clean list loads, and the named file's own hireable isn't read", () => {
const mixed = hireDir({ chief: { hireable: ["worker"] }, worker: { withinRole: ["message.send", "role.launch"] } });
refuses(() => loadRole(mixed, "chief"), /hireable names worker, which holds role\.launch/);
refuses(() => loadRole(mixed, "chief"), /hireable names worker, which holds [a-z.]+ outside the hireable ceiling/);
const clean = hireDir({
chief: { hireable: ["worker", "helper"] },
worker: { withinRole: [...HIRE_ALLOWED] },
@@ -323,15 +323,15 @@ test("hireable: a mixed definition refuses, a clean list loads, and the named fi
});
assert.deepEqual(loadRole(clean, "chief").hireable, ["worker", "helper"]);
assert.ok(Object.isFrozen(loadRole(clean, "chief").hireable));
refuses(() => loadRole(clean, "helper"), /hireable names nobody, which has no role file/);
refuses(() => loadRole(clean, "helper"), /hireable names a definition which has no role file/);
});
test("hireable: a missing, version 1, duplicate or badly named entry refuses, and the key is optional", () => {
refuses(() => loadRole(hireDir({ chief: { hireable: ["ghost"] } }), "chief"), /hireable names ghost, which has no role file/);
refuses(() => loadRole(hireDir({ chief: { hireable: ["old"] }, old: 1 }), "chief"), /hireable names old, which is role version 1/);
refuses(() => loadRole(hireDir({ chief: { hireable: ["ghost"] } }), "chief"), /hireable names a definition which has no role file/);
refuses(() => loadRole(hireDir({ chief: { hireable: ["old"] }, old: 1 }), "chief"), /hireable names a definition which is role version 1/);
// Row 213 (M10): a creative definition refuses by name, before its file is read.
for (const name of CREATIVE_DEFINITIONS) {
refuses(() => loadRole(hireDir({ chief: { hireable: [name] } }), "chief"), new RegExp(`hireable names ${name}, a creative definition only the human adds$`));
refuses(() => loadRole(hireDir({ chief: { hireable: [name] } }), "chief"), /hireable names [a-z-]+, a creative definition only the human adds$/);
}
refuses(() => loadRole(hireDir({ chief: { hireable: ["w", "w"] }, w: {} }), "chief"), /duplicate entry in role hireable/);
refuses(() => loadRole(hireDir({ chief: { hireable: ["../w"] } }), "chief"), /role hireable entry/);
@@ -0,0 +1,89 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { validateRoleDocument } from '../src/role.mjs';
import { checkAccess } from '../src/access.mjs';
import { BusinessError } from '../src/errors.mjs';
const bad = 'sk-test-NOT-A-SECRET-1234';
const badId = `${bad.toLowerCase()} "`;
function refusal(fn, names = [], omitted = bad, validation = null) {
assert.throws(fn, error => {
assert.ok(error instanceof BusinessError);
assert.equal(error.exitCode, 2);
for (const name of names) assert.ok(error.message.includes(name), error.message);
assert.doesNotMatch(error.message + error.stack, /sk-test|NOT-A-SECRET/i);
assert.ok(!error.message.includes(omitted), error.message);
if (validation !== null) assert.match(error.message, validation);
return true;
});
}
function role(t, edit = () => {}) {
const root = mkdtempSync(join(tmpdir(), 'names-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
writeFileSync(join(root, 'r.md'), '# R\n\n## Authority\n');
writeFileSync(join(root, 'companion.md'), '# R\n\n## Authority\n');
const doc = { roleVersion: 2, name: 'r', title: 'R', contract: 'r.md', tools: ['read'], network: 'none',
authority: { withinRole: [], crossRole: [] }, credentials: [] };
edit(doc, root);
return () => validateRoleDocument(doc, join(root, `${doc.name}.json`));
}
for (const key of ['withinRole', 'crossRole']) test(`gated ${key} names the known action; unknown action stays unnamed`, t => {
refusal(role(t, d => { d.authority[key] = ['deploy']; }), ['authority.' + key, 'deploy']);
refusal(role(t, d => { d.authority[key] = [bad]; }));
});
test('overlapping authority lists name every known action; invalid entries stop first', t => {
refusal(role(t, d => { d.authority = { withinRole: ['message.send', 'record.write'], crossRole: ['message.send', 'record.write'] }; }), ['message.send', 'record.write']);
refusal(role(t, d => { d.authority = { withinRole: [bad], crossRole: [bad] }; }));
});
for (const kind of ['creative', 'outside']) test(`hireable ${kind} names a known definition; invalid name stops first`, t => {
const edit = (d, root) => {
const name = kind === 'creative' ? 'companion' : 'helper';
d.hireable = [name];
if (kind === 'outside') writeFileSync(join(root, 'helper.json'), JSON.stringify({ ...d, name, hireable: [], authority: { withinRole: ['role.hire'], crossRole: [] } }));
};
refusal(role(t, edit), kind === 'creative' ? ['companion'] : ['helper', 'role.hire']);
refusal(role(t, d => { d.hireable = [badId]; }), [], badId, /role hireable entry must match/);
});
test('hireable wider action rejects unknown action before naming it', t => {
refusal(role(t, (d, root) => {
d.hireable = ['helper'];
writeFileSync(join(root, 'helper.json'), JSON.stringify({ ...d, name: 'helper', hireable: [], authority: { withinRole: [bad], crossRole: [] } }));
}));
});
test('gitea category conflict names the checked category; invalid scope stops first', t => {
refusal(role(t, d => { d.credentials = [{ service: 'gitea', scopes: ['read:issue', 'write:issue'] }]; }), ['issue']);
refusal(role(t, d => { d.credentials = [{ service: 'gitea', scopes: [bad] }]; }));
});
test('duplicate credential names the checked service; invalid service stops first', t => {
refusal(role(t, d => { d.credentials = [{ service: 'gitea', scopes: ['read:issue'] }, { service: 'gitea', scopes: ['read:issue'] }]; }), ['gitea']);
refusal(role(t, d => { d.credentials = [{ service: bad }, { service: bad }]; }));
});
for (const field of ['tools', 'credentials']) test(`creative ${field} names the checked wider grant; invalid grant stops first`, t => {
const edit = d => { d.name = 'companion'; d.contract = 'companion.md'; d.messaging = { sendTo: ['human'], receiveFrom: ['human'] }; };
refusal(role(t, d => { edit(d); if (field === 'tools') d.tools.push('bash'); else d.credentials = [{ service: 'vikunja', scopes: { tasks: ['read_one'] } }]; }), [field === 'tools' ? 'bash' : 'vikunja']);
refusal(role(t, d => { edit(d); if (field === 'tools') d.tools = [bad]; else d.credentials = [{ service: bad }]; }));
});
const context = () => ({ providers: ['test'], peers: [], businesses: { acme: { human: 'jason', instances: ['coder'] } }, ssoSubjects: [] });
const access = () => ({ accessVersion: 1, revision: 1,
users: [{ id: 'bean', home: 'local', subjects: [{ provider: 'test', sub: 'one' }] }],
grants: [{ id: 'grant', user: 'bean', business: 'acme', projects: ['*'], instances: ['coder'], level: 'viewer' }] });
for (const kind of ['reserved', 'human', 'user', 'grant', 'project', 'instance']) test(`access ${kind} conflict names checked id; malformed replacement stops first`, () => {
function run(value) {
const d = access(), ctx = context();
if (kind === 'reserved' || kind === 'human') d.users[0].id = value;
if (kind === 'user') d.users.push({ ...structuredClone(d.users[0]), id: value, subjects: [{ provider: 'test', sub: 'two' }] });
if (kind === 'grant') { d.grants[0].id = value; d.grants.push(structuredClone(d.grants[0])); }
if (kind === 'project') d.grants[0].projects = [value, value];
if (kind === 'instance') d.grants[0].instances = [value, value];
return () => checkAccess(d, ctx);
}
const value = { reserved: 'human', human: 'jason', user: 'bean', grant: 'g-known-1', project: '11111111-2222-4333-8444-555555555555', instance: 'coder' }[kind];
refusal(run(value), [value]);
const validation = kind === 'project' ? /lowercase UUID/ : kind === 'instance' ? /declared instance/ : /\.id must match/;
refusal(run(badId), [], badId, validation);
});
@@ -4,6 +4,7 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { REPO_ROLES } from "./helpers.mjs";
@@ -41,3 +42,22 @@ test("the assistant summarizes a spec review in its own words and doesn't repeat
// The user's own rulings still go to the chief of staff verbatim; only a review result is summarized.
assert.match(text, /Relay each ruling the user gives verbatim to the chief\s+of\s+staff/);
});
// TI: exact authorized contract wording, independently pinned for review.
test("TI contracts point at the generated and live caller rails", () => {
const pins = {
"coder": "9a31e19cbea5953fd9598def446e0e5e351869498f593f46468cf03fc0e2759b",
"cto": "c4c53b00eea1a6c73c90f2b5a9e54960848972a517c4e6ee47198e91dc8ddf45",
"merge-gate": "8d39144cab994781cadf7ac0dbfc41a1dd8a3c59ef66d916c79e9d63324572ad",
"pm": "0f7cbae8085709ab2907483543681ff7dc002ac2f9fea14d0b840213e9cf6d56",
"reviewer": "5269da2de779f3e399838a1c8357201862a4bd25c03de71ca8ebb31978dea6b7",
"security-reviewer": "eb664413c47d405ffb593cf15d3278217f09c74ddef0a179dcf22d9bf041378a",
};
for (const [name, hash] of Object.entries(pins)) {
const text = contract(name);
assert.equal(createHash('sha256').update(text).digest('hex'), hash, name);
assert.match(text, /"Your\s+rails"/);
assert.match(text, /`my_rails`/);
assert.doesNotMatch(text, /launch record you\s+received/);
}
});
+3 -3
View File
@@ -67,7 +67,7 @@ export function validateInterviewTemplate(value) {
value.questions.forEach((q, i) => {
if (!object(q) || !sameKeys(q, ['key', 'text'])) { problems.push(`questions[${i}] keys must be exactly key, text`); return; }
if (typeof q.key !== 'string' || !KEY.test(q.key)) problems.push(`questions[${i}].key must be lower-case letters, digits and dashes`);
else if (seen.has(q.key)) problems.push(`questions[${i}].key repeats ${q.key}`);
else if (seen.has(q.key)) problems.push(`questions[${i}].key repeats an earlier question key`);
seen.add(q.key);
if (!line(q.text, 500)) problems.push(`questions[${i}].text must be one line of 1-500 characters`);
});
@@ -76,7 +76,7 @@ export function validateInterviewTemplate(value) {
problems.push('prompt must be 1-200 lines of text, each at most 2000 characters without line breaks');
else {
const text = value.prompt.join('\n'), used = new Set([...text.matchAll(/\{([^{}]*)\}/g)].map(m => m[1]));
for (const name of used) if (!PLACEHOLDERS.includes(name)) problems.push(`prompt uses unknown placeholder {${name}}`);
for (const name of used) if (!PLACEHOLDERS.includes(name)) problems.push(`prompt uses unknown placeholder; expected one of ${PLACEHOLDERS.join(", ")}`);
for (const name of REQUIRED) if (!used.has(name)) problems.push(`prompt must use {${name}}`);
if (!text.trim()) problems.push('prompt must not be empty');
}
@@ -87,7 +87,7 @@ export function validateInterviewTemplate(value) {
export function loadInterviewTemplate(url = INTERVIEW_TEMPLATE_URL) {
let value;
try { value = JSON.parse(readFileSync(url, 'utf8')); }
catch (error) { throw new OnboardingError('onboarding-template-invalid', `interview template unreadable: ${error.message}`); }
catch (error) { throw new OnboardingError('onboarding-template-invalid', `interview template unreadable: expected readable JSON in ${url}${error.code ? ` (${error.code})` : ""}`); }
const problems = validateInterviewTemplate(value);
if (problems.length) throw new OnboardingError('onboarding-template-invalid', problems.join('; '));
return value;
@@ -0,0 +1,35 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const secret = 'sk-test-NOT-A-SECRET-1234';
function scratch(t) { const root = mkdtempSync(join(tmpdir(), 'r4-')); t.after(() => rmSync(root, { recursive: true, force: true })); return root; }
function safe(label, Type, props = {}, omitted = secret) { return error => {
assert.ok(error instanceof Type, error.stack);
for (const [key, value] of Object.entries(props)) assert.equal(error[key], value);
assert.ok(error.message.includes(label), error.message);
assert.doesNotMatch(error.message + error.stack, /sk-test|NOT-A-SECRET|Unexpected token/);
assert.ok(!error.message.includes(omitted), error.message);
assert.equal(error.cause, undefined);
return true;
}; }
import { loadInterviewTemplate, OnboardingError } from '../src/onboarding.mjs';
import { initProject, writeRecord, ProjectError } from '../src/index.mjs';
test('onboarding template parser omits file bytes and keeps its code', t => {
const file = join(scratch(t), 'template.json'); writeFileSync(file, secret);
assert.throws(() => loadInterviewTemplate(file), safe(file, OnboardingError, { code: 'onboarding-template-invalid', stage: 'check' }));
});
test('project nonobject record already reports only its type (BASE control)', async t => {
const root = scratch(t), context = { business: { id: 'acme', human: 'jason', projects: { stack: { root: join(root, 'code') } } }, project: 'stack', dataRoot: join(root, 'data') };
await initProject(context);
await assert.rejects(() => writeRecord({ ...context, path: 'requirements/REQ-1.json', record: JSON.stringify(secret), author: { name: 'Tester', email: '[email protected]' } }), safe('object', ProjectError, { code: 'project-record-invalid' }));
});
import { INTERVIEW_TEMPLATE_URL } from '../src/onboarding.mjs';
for (const at of ['question', 'placeholder']) test(`template ${at} semantic refusal omits its value`, t => {
const d = JSON.parse(readFileSync(INTERVIEW_TEMPLATE_URL, 'utf8'));
if (at === 'question') { d.questions[0].key = secret.toLowerCase(); d.questions[1].key = secret.toLowerCase(); }
else d.prompt.push(`{${secret}}`);
const file = join(scratch(t), 'template.json'); writeFileSync(file, JSON.stringify(d));
assert.throws(() => loadInterviewTemplate(file), safe(at === 'question' ? 'questions[1].key' : 'placeholder', OnboardingError, { code: 'onboarding-template-invalid' }, secret.toLowerCase()));
});
+2 -2
View File
@@ -46,12 +46,12 @@ test('a replacement template is checked key by key and every problem is named',
[t => { t.drafts.requirementPrefix = '-x'; }, 'drafts.requirementPrefix must start a record id'],
[t => { delete t.drafts.prd; }, 'drafts keys must be exactly'],
[t => { t.questions = []; }, 'questions must list 1-12 questions'],
[t => { t.questions[1].key = t.questions[0].key; }, 'questions[1].key repeats outcome'],
[t => { t.questions[1].key = t.questions[0].key; }, 'questions[1].key repeats an earlier question key'],
[t => { t.questions[0].key = 'Bad Key'; }, 'questions[0].key must be lower-case'],
[t => { t.questions[0].text = ''; }, 'questions[0].text must be one line'],
[t => { t.questions[0].extra = 1; }, 'questions[0] keys must be exactly key, text'],
[t => { t.prompt = ['line\nbreak']; }, 'prompt must be 1-200 lines'],
[t => { t.prompt.push('{secret}'); }, 'prompt uses unknown placeholder {secret}'],
[t => { t.prompt.push('{secret}'); }, 'prompt uses unknown placeholder; expected one of'],
[t => { t.prompt = t.prompt.map(l => l.replaceAll('{questions}', 'the questions')); }, 'prompt must use {questions}'],
[t => { t.prompt = t.prompt.map(l => l.replaceAll('{northStarDraft}', 'a doc')); }, 'prompt must use {northStarDraft}'],
];
+41 -41
View File
@@ -99,18 +99,18 @@ export function checkCellText(v, what, opts) {
// An op id a caller chose: 8 to 72 characters, not ending in `.outcome`.
export function checkCallerOpId(v, what = "op id") {
if (typeof v !== "string" || !CALLER_OP_RE.test(v)) throw refuse(`${what} ${JSON.stringify(v)} must match ${CALLER_OP_RE.source} (8 to 72 characters)`);
if (v.endsWith(".outcome")) throw refuse(`${what} ${JSON.stringify(v)} ends in .outcome, which is reserved`);
if (typeof v !== "string" || !CALLER_OP_RE.test(v)) throw refuse(`${what} must match ${CALLER_OP_RE.source} (8 to 72 characters)`);
if (v.endsWith(".outcome")) throw refuse(`${what} ends in .outcome, which is reserved`);
return v;
}
export function checkName(v, what) {
if (typeof v !== "string" || !NAME_RE.test(v)) throw refuse(`${what} must be a seat name (lowercase letters, digits, hyphens): ${JSON.stringify(v)}`);
if (typeof v !== "string" || !NAME_RE.test(v)) throw refuse(`${what} must be a seat name (lowercase letters, digits, hyphens)`);
return v;
}
function checkId(v, what = "row id") {
if (!Number.isInteger(v) || v < 1 || v > 1_000_000) throw refuse(`${what} must be a positive integer: ${JSON.stringify(v)}`);
if (!Number.isInteger(v) || v < 1 || v > 1_000_000) throw refuse(`${what} must be a positive integer`);
return v;
}
@@ -148,14 +148,14 @@ function checkTime(v, what, { unknown = false, date = false } = {}) {
// as March 2).
const t = Date.parse(iso ? v : `${v}T00:00:00.000Z`);
if (Number.isFinite(t) && new Date(t).toISOString().slice(0, v.length) === v) return v;
throw refuse(`${what} is not a calendar ${iso ? "time" : "date"}: ${JSON.stringify(v)}`);
throw refuse(`${what} is not a calendar ${iso ? "time" : "date"}`);
}
throw refuse(`${what} must be an ISO time${date ? " or date" : ""}${unknown ? " or \"unknown\"" : ""}: ${JSON.stringify(v)}`);
throw refuse(`${what} must be an ISO time${date ? " or date" : ""}${unknown ? " or \"unknown\"" : ""}`);
}
export function checkRepoPath(v, what) {
if (typeof v !== "string" || !PATH_RE.test(v) || v.split("/").some((s) => s === "." || s === "..")) {
throw refuse(`${what} must be a repo-relative path: ${JSON.stringify(v)}`);
throw refuse(`${what} must be a repo-relative path`);
}
return v;
}
@@ -163,7 +163,7 @@ export function checkRepoPath(v, what) {
export function parseBriefSpec(spec) {
if (typeof spec !== "string") throw refuse("brief must be PATH#ANCHOR");
const i = spec.indexOf("#");
if (i < 0) throw refuse(`brief must be PATH#ANCHOR, naming the heading: ${JSON.stringify(spec)}`);
if (i < 0) throw refuse(`brief must be PATH#ANCHOR, naming the heading`);
const path = checkRepoPath(spec.slice(0, i), "brief path");
const anchor = checkCellText(spec.slice(i + 1), "brief anchor", { max: 200 });
return { path, anchor };
@@ -197,9 +197,9 @@ export function parseManifest(text) {
const lines = text.slice(0, -1).split("\n");
for (const line of lines) {
const m = /^([0-9a-f]{64}) {2}(.+)$/.exec(line);
if (!m) throw refuse(`candidate manifest line is not "<sha256> <path>": ${JSON.stringify(line)}`);
if (!m) throw refuse(`candidate manifest line is not "<sha256> <path>"`);
checkRepoPath(m[2], "candidate manifest path");
if (seen.has(m[2])) throw refuse(`candidate manifest names ${m[2]} twice`);
if (seen.has(m[2])) throw refuse(`candidate manifest names a path twice`);
seen.add(m[2]);
}
return lines.length;
@@ -222,7 +222,7 @@ export function attemptOf(row, op) {
}
function checkCommentId(v, what = "comment id") {
if (!Number.isSafeInteger(v) || v < 1) throw refuse(`${what} must be a positive integer: ${JSON.stringify(v)}`);
if (!Number.isSafeInteger(v) || v < 1) throw refuse(`${what} must be a positive integer`);
return v;
}
@@ -250,7 +250,7 @@ function checkAttempt(a) {
checkCallerOpId(a.op, "review attempt op");
checkName(a.by, "review attempt by");
checkTime(a.at, "review attempt at");
if (!ATTEMPT_STATES.includes(a.state)) throw refuse(`review attempt state ${JSON.stringify(a.state)} is not one of ${ATTEMPT_STATES.join(", ")}`);
if (!ATTEMPT_STATES.includes(a.state)) throw refuse(`review attempt state is not one of ${ATTEMPT_STATES.join(", ")}`);
if (a.comment !== null) checkCommentId(a.comment);
if ((a.state === "posted") !== (a.comment !== null)) throw refuse("a review attempt carries a comment id exactly when it is posted");
if (a.transport !== null) checkTransport(a.transport);
@@ -311,7 +311,7 @@ function checkRound(v, n) {
}
export function validateRow(row) {
keysExactly(row, ROW_KEYS, `row ${row?.id}`);
keysExactly(row, ROW_KEYS, `row`);
const w = `row ${row.id}`;
checkId(row.id);
checkCellText(row.piece, `${w} piece`, { max: 300 });
@@ -319,7 +319,7 @@ export function validateRow(row) {
checkIssues(row.issues, `${w} issues`);
checkIssues(row.closes, `${w} closes`);
for (const n of row.closes) if (!row.issues.includes(n)) throw refuse(`${w} closes #${n}, which is not in its issues`);
if (!STATES.includes(row.state)) throw refuse(`${w} state ${JSON.stringify(row.state)} is not a state`);
if (!STATES.includes(row.state)) throw refuse(`${w} state is not a state (expected one of ${STATES.join(", ")})`);
if (row.state === "blocked") {
if (!NON_TERMINAL.has(row.previousState)) throw refuse(`${w} is blocked and needs the non-terminal state it returns to`);
checkCellText(row.blockedReason, `${w} blockedReason`);
@@ -346,7 +346,7 @@ export function validateRow(row) {
}
if (row.claim !== null) {
keysExactly(row.claim, ["seat", "op"], `${w} claim`);
if (row.claim.seat !== row.owner) throw refuse(`${w} claim seat ${row.claim.seat} is not the owner ${row.owner}`);
if (row.claim.seat !== row.owner) throw refuse(`${w} claim seat is not the owner ${row.owner}`);
checkCallerOpId(row.claim.op, `${w} claim op`);
const s = row.state === "blocked" ? row.previousState : row.state;
if (!CLAIM_KEPT.has(s)) throw refuse(`${w} is claimed but ${row.state}`);
@@ -398,7 +398,7 @@ function nullable(v, fn) {
function sortedUnique(list, cmp) {
const out = [...list].sort(cmp);
for (let i = 1; i < out.length; i++) if (cmp(out[i], out[i - 1]) === 0) throw refuse(`repeated value ${JSON.stringify(out[i])}`);
for (let i = 1; i < out.length; i++) if (cmp(out[i], out[i - 1]) === 0) throw refuse(`repeated value in list`);
return out;
}
@@ -463,7 +463,7 @@ export function canonArgs(verb, a) {
};
}
case "move":
if (!STATES.includes(a.to)) throw refuse(`unknown state ${JSON.stringify(a.to)}; states: ${STATES.join(", ")}`);
if (!STATES.includes(a.to)) throw refuse(`unknown state; states: ${STATES.join(", ")}`);
return {
id: checkId(a.id),
to: a.to,
@@ -503,7 +503,7 @@ export function canonArgs(verb, a) {
if (a.verdict !== "approve" && a.verdict !== "changes") throw refuse("--verdict must be approve or changes");
return { id: checkId(a.id), verdict: a.verdict, comment: checkCommentId(a.comment), candidate: checkDigest(a.candidate, "candidate") };
default:
throw refuse(`unknown verb ${JSON.stringify(verb)}`);
throw refuse(`unknown verb; expected a queue operation`);
}
}
@@ -599,7 +599,7 @@ function findAttempt(row, op) {
const i = r.attempts.findIndex((x) => x.op === op);
if (i >= 0) return { round: r, index: i, attempt: r.attempts[i] };
}
throw refuse(`row ${row.id} has no review request ${op}`);
throw refuse(`row ${row.id} has no review request matching the attempt op`);
}
// A copy of `row` with one attempt of one round replaced.
@@ -632,13 +632,13 @@ function outcomeState(attempt, t) {
return t.outcome === "posted" && t.comment === attempt.comment ? "posted" : "conflict";
}
if (attempt.state === "abandoned") return t.outcome === "posted" ? "conflict" : "abandoned";
throw refuse(`attempt ${attempt.op} is ${attempt.state}; it already has its outcome`);
throw refuse(`attempt is ${attempt.state}; it already has its outcome`);
}
function applyReview(rows, row, entry) {
const a = entry.args;
const by = entry.by;
if (!row.review && entry.verb !== "review-request" && entry.verb !== "review-record") throw refuse(`row ${row.id} has no review request ${a.attempt}`);
if (!row.review && entry.verb !== "review-request" && entry.verb !== "review-record") throw refuse(`row ${row.id} has no review request matching the attempt op`);
switch (entry.verb) {
case "review-request": {
ownerOrPriv(row, by, "request review of");
@@ -654,7 +654,7 @@ function applyReview(rows, row, entry) {
};
}
case "review-outcome": {
if (entry.op !== `${a.attempt}.outcome`) throw refuse(`an outcome's op is its attempt's op plus .outcome (${a.attempt}.outcome)`);
if (entry.op !== `${a.attempt}.outcome`) throw refuse(`an outcome's op is its attempt's op plus .outcome`);
const found = findAttempt(row, a.attempt);
const att = found.attempt;
if (by !== att.by) throw refuse(`only ${att.by}, who made request ${att.op}, records its outcome`);
@@ -692,7 +692,7 @@ function applyReview(rows, row, entry) {
if (!row.reviewers.includes(by) || by === row.owner) throw refuse(`only a listed reviewer other than the owner may record a verdict on row ${row.id} (reviewers: ${fmt(row.reviewers)})`);
if (!row.review) throw refuse(`row ${row.id} has no review round`);
const cur = currentCommentRound(row, "review record");
if (a.candidate !== cur.candidate.digest) throw refuse(`candidate ${a.candidate} is not round ${cur.n}'s candidate ${cur.candidate.digest}`);
if (a.candidate !== cur.candidate.digest) throw refuse(`candidate is not round ${cur.n}'s candidate`);
if (cur.receipts.some((r) => r.reviewer === by)) throw refuse(`${by} already recorded a verdict for row ${row.id} round ${cur.n}`);
const rec = { reviewer: by, op: entry.op, at: entry.at, verdict: a.verdict, comment: a.comment, candidate: a.candidate };
const rounds = row.review.rounds.map((r) => (r.n === cur.n ? { ...r, receipts: [...r.receipts, rec] } : r));
@@ -703,7 +703,7 @@ function applyReview(rows, row, entry) {
};
}
default:
throw refuse(`unknown verb ${entry.verb}`);
throw refuse(`unknown verb; expected a queue operation`);
}
}
@@ -808,7 +808,7 @@ function applyMove(rows, row, entry, resolved) {
const ev = parseReviewEvidence(evidence);
if (!cur) throw refuse(`row ${row.id} has no review round to cite`);
if (ev.round !== cur.n) throw refuse(`evidence names round ${ev.round}; row ${row.id} is in round ${cur.n}`);
if (ev.candidate !== cur.candidate.digest) throw refuse(`evidence candidate ${ev.candidate} is not round ${cur.n}'s candidate ${cur.candidate.digest}`);
if (ev.candidate !== cur.candidate.digest) throw refuse(`evidence candidate is not round ${cur.n}'s candidate`);
}
round = cur.n;
next.claim = null;
@@ -860,7 +860,7 @@ function applySet(rows, row, entry, resolved) {
requirePriv(by, "re-pin a brief");
next.brief = checkBrief(resolved.brief);
if (next.brief.path !== parseBriefSpec(value).path || next.brief.anchor !== parseBriefSpec(value).anchor) throw refuse("resolved brief does not match the brief argument");
if (row.brief !== null && next.brief.blob === row.brief.blob && sameJson(next.brief, row.brief)) throw refuse(`row ${row.id} brief is already pinned to ${row.brief.blob}`);
if (row.brief !== null && next.brief.blob === row.brief.blob && sameJson(next.brief, row.brief)) throw refuse(`row ${row.id} brief is already pinned to that blob`);
break;
case "required":
if (value) {
@@ -877,7 +877,7 @@ function applySet(rows, row, entry, resolved) {
throw refuse(`set cannot change ${field}`);
}
if (sameJson(next[key], row[key]) && (field !== "issues" || sameJson(next.closes, row.closes))) {
throw refuse(`row ${row.id} ${key} is already ${fmt(row[key])}`);
throw refuse(`row ${row.id} ${key} is already set to that value`);
}
const result = { row: row.id, field: key, from: row[key], to: next[key] };
if (field === "issues") result.closes = { from: row.closes, to: next.closes, narrowed: !sameJson(row.closes, row.issues) };
@@ -947,7 +947,7 @@ export function applyEntry(state, entry, resolved) {
case "release": {
const row = getRow(rows, a.id);
if (row.state !== "in-progress") throw refuse(`row ${row.id} is ${row.state}; release applies to in-progress rows`);
if (!(row.claim && by === row.claim.seat) && !isPriv(by)) throw refuse(`only the claimant (${row.claim?.seat}) or a privileged actor may release row ${row.id}`);
if (!(row.claim && by === row.claim.seat) && !isPriv(by)) throw refuse(`only the claimant (${row.claim?.seat ?? "nobody"}) or a privileged actor may release row ${row.id}`);
rows.set(row.id, touch({ ...row, state: "briefed", claim: null }, entry));
result = { row: row.id, from: "in-progress", to: "briefed", receipt: receipt(entry, rev, `row ${row.id} in-progress→briefed`) };
break;
@@ -956,8 +956,8 @@ export function applyEntry(state, entry, resolved) {
const row = getRow(rows, a.id);
requirePriv(by, "assign a row");
if (row.state === "done") throw refuse(`row ${row.id} is done; done rows never change`);
if (row.owner === a.seat) throw refuse(`row ${row.id} is already owned by ${a.seat}`);
if (row.reviewers.includes(a.seat)) throw refuse(`can't assign row ${row.id} to ${a.seat}: ${a.seat} is one of its reviewers`);
if (row.owner === a.seat) throw refuse(`row ${row.id} is already owned by ${row.owner}`);
if (row.reviewers.includes(a.seat)) throw refuse(`can't assign row ${row.id}: ${a.seat} is one of its reviewers`);
const claim = row.claim ? { seat: a.seat, op: entry.op } : null;
rows.set(row.id, touch({ ...row, owner: a.seat, claim }, entry));
result = { row: row.id, field: "owner", from: row.owner, to: a.seat, receipt: receipt(entry, rev, `row ${row.id} owner: ${row.owner}→${a.seat}`) };
@@ -1003,7 +1003,7 @@ export function applyEntry(state, entry, resolved) {
case "genesis":
throw refuse("genesis is only log[0]");
default:
throw refuse(`unknown verb ${entry.verb}`);
throw refuse(`unknown verb; expected a queue operation`);
}
validateRows([...rows.values()]);
return { state: { rows, highWater, retired: state.retired, revision: rev }, result };
@@ -1029,11 +1029,11 @@ export function parseMigrationMap(text) {
keysExactly(map, ["rows", "retired", "highWater"], "migration map");
if (!Array.isArray(map.rows)) throw refuse("migration map rows must be a list");
map.rows.forEach((r) => {
keysExactly(r, MAP_ROW_KEYS, `migration map row ${r?.id}`);
if (r.brief !== null) keysExactly(r.brief, ["path", "anchor"], `migration map row ${r.id} brief`);
keysExactly(r, MAP_ROW_KEYS, `migration map row`);
if (r.brief !== null) keysExactly(r.brief, ["path", "anchor"], `migration map row brief`);
// The rule add, set and assign apply. Here, not in checkGenesis, so a
// log written before the rule still replays.
if (Array.isArray(r.reviewers) && r.reviewers.includes(r.owner)) throw refuse(`migration map row ${r.id} owner ${r.owner} can't be a listed reviewer`);
if (Array.isArray(r.reviewers) && r.reviewers.includes(r.owner)) throw refuse(`migration map row owner can't be a listed reviewer`);
});
if (!Array.isArray(map.retired)) throw refuse("migration map retired must be a list");
map.retired.forEach((id) => checkId(id, "retired id"));
@@ -1102,8 +1102,8 @@ export function rowsArray(state) {
function checkEntryShape(e, i) {
keysExactly(e, ENTRY_KEYS, `log entry ${i}`);
if (e.rev !== i) throw refuse(`log entry ${i} has rev ${e.rev}`);
if (!VERBS.includes(e.verb)) throw refuse(`log entry ${i} verb ${JSON.stringify(e.verb)} is unknown`);
if (e.rev !== i) throw refuse(`log entry ${i} has rev that must equal its index`);
if (!VERBS.includes(e.verb)) throw refuse(`log entry ${i} verb is unknown`);
if (e.verb === "review-outcome") {
if (typeof e.op !== "string" || !LOG_OP_RE.test(e.op) || !isObj(e.args) || e.op !== `${e.args.attempt}.outcome`) throw refuse(`log entry ${i} op must be its attempt's op plus .outcome`);
} else {
@@ -1111,7 +1111,7 @@ function checkEntryShape(e, i) {
}
checkName(e.by, `log entry ${i} by`);
checkTime(e.at, `log entry ${i} at`);
if (!Number.isInteger(e.semantics) || e.semantics < 1 || e.semantics > SEMANTICS) throw refuse(`log entry ${i} semantics ${e.semantics} is not 1 to ${SEMANTICS}`);
if (!Number.isInteger(e.semantics) || e.semantics < 1 || e.semantics > SEMANTICS) throw refuse(`log entry ${i} semantics is not 1 to ${SEMANTICS}`);
if (REVIEW_VERBS.includes(e.verb) && e.semantics < 2) throw refuse(`log entry ${i} ${e.verb} needs semantics 2`);
if (typeof e.viewSha !== "string" || !SHA256_RE.test(e.viewSha)) throw refuse(`log entry ${i} viewSha is not a SHA-256`);
}
@@ -1129,7 +1129,7 @@ export function loadDoc(bytes) {
try { doc = JSON.parse(text); } catch { throw refuse(`queue.json is not JSON`); }
keysExactly(doc, DOC_KEYS, "queue.json");
if (serialize(doc) !== text) throw refuse("queue.json does not re-serialize byte for byte (a hand edit?)");
if (doc.version !== VERSION) throw refuse(`queue.json version ${doc.version} is not ${VERSION}`);
if (doc.version !== VERSION) throw refuse(`queue.json version is not ${VERSION}`);
if (typeof doc.canonicalRoot !== "string" || !doc.canonicalRoot.startsWith("/")) throw refuse("canonicalRoot must be an absolute path");
if (!Array.isArray(doc.rows) || !Array.isArray(doc.log) || doc.log.length === 0) throw refuse("queue.json needs rows and a log starting at genesis");
doc.log.forEach(checkEntryShape);
@@ -1138,7 +1138,7 @@ export function loadDoc(bytes) {
if (ops.has(e.op)) throw refuse(`op id ${e.op} appears twice in the log`);
ops.add(e.op);
}
if (doc.revision !== doc.log.length - 1) throw refuse(`revision ${doc.revision} is not the last rev ${doc.log.length - 1}`);
if (doc.revision !== doc.log.length - 1) throw refuse(`revision is not the last rev ${doc.log.length - 1}`);
const state = replay(doc.log);
if (!sameJson(rowsArray(state), doc.rows)) throw refuse("rows do not equal the replay of the log (a hand edit?)");
if (doc.canonicalRoot !== doc.log[0].args.root) throw refuse("canonicalRoot differs from the genesis root");
@@ -1158,10 +1158,10 @@ export function replay(log) {
try {
out = applyEntry(state, e, resolvedFromResult(e.verb, e.args, e.result));
} catch (err) {
if (err instanceof QueueError) throw refuse(`log entry ${i} (${e.op}) does not replay: ${err.message}`);
if (err instanceof QueueError) throw refuse(`log entry ${i} does not replay: ${err.message}`);
throw err;
}
if (!sameJson(out.result, e.result)) throw refuse(`log entry ${i} (${e.op}) result differs from its replay`);
if (!sameJson(out.result, e.result)) throw refuse(`log entry ${i} result differs from its replay`);
state = out.state;
if (e.viewSha !== sha256(render(rowsArray(state), i))) throw refuse(`log entry ${i} viewSha is not the render of its rows`);
}
+14 -14
View File
@@ -158,7 +158,7 @@ test("matrix: release, review round, changes requested and waiting-on-jason", ()
assert.deepEqual(Object.keys(rv), ["rounds"]);
assert.deepEqual(rv.rounds.map((r) => [r.n, r.issue, r.request, r.candidate.digest]), [[1, 1508, "none", CAND.digest]]);
assert.match(d.log.at(-1).result.receipt, /in-progress→in-review round 1 on #1508$/);
refused(() => step(d, "move", mv(9, "in-progress"), "dewey"), /claimed by darkwing/);
refused(() => step(d, "move", mv(9, "in-progress"), "dewey"), /claimed/);
d = step(d, "move", mv(9, "in-progress"), "darkwing");
assert.equal(row(d, 9).claim.seat, "darkwing");
d = step(d, "move", mv(9, "in-review", { candidate: "y" }), "darkwing", { candidate: { kind: "commit", digest: BLOB2, text: null } });
@@ -182,7 +182,7 @@ test("matrix J5: in-review→done by the gate owner with evidence naming the cur
refused(() => step(d, "move", mv(9, "done", { evidence: `comment=4242,candidate=${CAND.digest}` }), "filbert"), /round=<n>/);
refused(() => step(d, "move", mv(9, "done", { evidence: `comment=1,round=1,candidate=${"d".repeat(64)}` }), "filbert"), /is not round 1's candidate/);
refused(() => step(d, "move", mv(9, "done", { evidence: `comment=4242,round=2,candidate=${CAND.digest}` }), "filbert"), /evidence names round 2; row 9 is in round 1/);
refused(() => step(d, "move", mv(9, "done", { evidence: ev }), "rocko"), /only the gate owner \(filbert\)/);
refused(() => step(d, "move", mv(9, "done", { evidence: ev }), "rocko"), /only the gate owner/);
const done = step(d, "move", mv(9, "done", { evidence: ev }), "filbert");
assert.equal(row(done, 9).state, "done");
step(d, "move", mv(9, "done", { evidence: ev }), "sage");
@@ -357,7 +357,7 @@ test("matrix R1: every state × target × actor class matches 8.7, gate owner ja
test("matrix: blocked keeps the claim and returns only to previousState", () => {
let d = row9Started();
refused(() => step(d, "move", mv(9, "blocked"), "darkwing"), /needs --reason/);
refused(() => step(d, "move", mv(9, "blocked", { reason: "x" }), "rocko"), /claimed by darkwing/);
refused(() => step(d, "move", mv(9, "blocked", { reason: "x" }), "rocko"), /claimed/);
d = step(d, "move", mv(9, "blocked", { reason: "waiting on git" }), "darkwing");
const r = row(d, 9);
assert.deepEqual([r.state, r.previousState, r.blockedReason, r.claim.seat], ["blocked", "in-progress", "waiting on git", "darkwing"]);
@@ -508,7 +508,7 @@ test("every accepted text renders to nine cells on every row (N8)", () => {
test("genesis: the map refuses an owner among its row's reviewers; replay doesn't (2026-10-04)", () => {
const self = MAP_ROWS.map((m) => (m.id === 9 ? { ...m, reviewers: ["darkwing", "filbert"] } : m));
refused(() => parseMigrationMap(mapText(self)), /migration map row 9 owner darkwing can't be a listed reviewer/);
refused(() => parseMigrationMap(mapText(self)), /migration map row owner can't be a listed reviewer/);
// A log from before the rule still loads: the check is on the map, not in replay.
const old = genesisDoc(self, "genesis-op-0001", { rows: self, retired: [7], highWater: 11 });
assert.deepEqual(row(loadDoc(Buffer.from(serialize(old))).doc, 9).reviewers, ["darkwing", "filbert"]);
@@ -522,8 +522,8 @@ test("times and dates must be calendar values, not just the shape (2026-10-04)",
validateRow({ ...r, createdAt: v });
}
for (const v of ["2026-13-01", "2026-00-10", "2026-01-32", "2026-02-30", "2027-02-29", "2026-04-31"]) {
refused(() => validateRow({ ...r, requiredSince: v }), new RegExp(`row 9 requiredSince is not a calendar date: "${v}"`));
refused(() => validateRow({ ...r, createdAt: v }), new RegExp(`row 9 createdAt is not a calendar date: "${v}"`));
refused(() => validateRow({ ...r, requiredSince: v }), /row 9 requiredSince is not a calendar date/);
refused(() => validateRow({ ...r, createdAt: v }), /row 9 createdAt is not a calendar date/);
}
// ISO times: the same days, and times past the clock. V8 reads 2026-02-30
// as March 2 and 24:00 as the next day; only the round trip catches those.
@@ -533,15 +533,15 @@ test("times and dates must be calendar values, not just the shape (2026-10-04)",
}
const T = "T12:00:00.000Z";
for (const v of [`2026-13-01${T}`, `2026-01-32${T}`, `2026-02-30${T}`, `2027-02-29${T}`, "2026-01-01T24:00:00.000Z", "2026-01-01T23:60:00.000Z", "2026-01-01T23:59:60.000Z"]) {
refused(() => validateRow({ ...r, updatedAt: v }), new RegExp(`row 9 updatedAt is not a calendar time: "${v}"`));
refused(() => validateRow({ ...r, requiredSince: v }), new RegExp(`row 9 requiredSince is not a calendar time: "${v}"`));
refused(() => validateRow({ ...r, updatedAt: v }), /row 9 updatedAt is not a calendar time/);
refused(() => validateRow({ ...r, requiredSince: v }), /row 9 requiredSince is not a calendar time/);
}
// A shape error keeps its own message.
refused(() => validateRow({ ...r, updatedAt: "2026-01-01" }), /row 9 updatedAt must be an ISO time: "2026-01-01"/);
refused(() => validateRow({ ...r, updatedAt: "2026-01-01" }), /row 9 updatedAt must be an ISO time/);
// A log entry's time goes through the same check on replay.
const d = step(genesisDoc(), "note", { id: 9, text: "x" }, "darkwing");
d.log[1].at = `2026-02-30${T}`;
refused(() => loadDoc(Buffer.from(serialize(d))), /log entry 1 at is not a calendar time: "2026-02-30T12:00:00.000Z"/);
refused(() => loadDoc(Buffer.from(serialize(d))), /log entry 1 at is not a calendar time/);
});
test("replay holds every op id to the caller's rule (N11)", () => {
@@ -550,10 +550,10 @@ test("replay holds every op id to the caller's rule (N11)", () => {
// 72 characters replay; 73 to 80 no longer do.
load(step(d, "note", { id: 9, text: "x" }, "darkwing", {}, "a".repeat(72)));
for (const n of [73, 80]) {
refused(() => load(step(d, "note", { id: 9, text: "x" }, "darkwing", {}, "a".repeat(n))), /log entry 1 op .* must match/);
refused(() => load(step(d, "note", { id: 9, text: "x" }, "darkwing", {}, "a".repeat(n))), /log entry 1 op must match/);
}
// .outcome is reserved on every verb, accept-history included.
refused(() => load(step(d, "accept-history", { reason: "r" }, "sage", { oldWitness: null }, "accept-history-1.outcome")), /log entry 1 op .* ends in \.outcome/);
refused(() => load(step(d, "accept-history", { reason: "r" }, "sage", { oldWitness: null }, "accept-history-1.outcome")), /log entry 1 op ends in \.outcome/);
refused(() => load(step(d, "note", { id: 9, text: "x" }, "darkwing", {}, "note-row9.outcome")), /\.outcome, which is reserved/);
// The genesis op is checked too.
load(genesisDoc(MAP_ROWS, "genesis-ok-0001"));
@@ -586,11 +586,11 @@ test("note: owner, listed reviewer or privileged; empty clears", () => {
test("assign moves the claim with the owner; done clears it", () => {
let d = row9Started();
refused(() => step(d, "assign", { id: 9, seat: "dewey" }, "darkwing"), /privileged/);
refused(() => step(d, "assign", { id: 9, seat: "filbert" }, "sage"), /can't assign row 9 to filbert: filbert is one of its reviewers/);
refused(() => step(d, "assign", { id: 9, seat: "filbert" }, "sage"), /can't assign row 9: filbert is one of its reviewers/);
d = step(d, "assign", { id: 9, seat: "dewey" }, "sage", {}, "assign-row-9-dewey");
assert.deepEqual(row(d, 9).claim, { seat: "dewey", op: "assign-row-9-dewey" });
assert.match(d.log.at(-1).result.receipt, /owner: darkwing→dewey$/);
refused(() => step(d, "move", mv(9, "in-review", { candidate: "x" }), "darkwing", { candidate: CAND }), /only the claimant \(dewey\)/);
refused(() => step(d, "move", mv(9, "in-review", { candidate: "x" }), "darkwing", { candidate: CAND }), /only the claimant/);
const unclaimed = step(genesisDoc(), "assign", { id: 11, seat: "rocko" }, "sage");
assert.equal(row(unclaimed, 11).claim, null);
});
+191
View File
@@ -0,0 +1,191 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync, readFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const secret = 'sk-test-NOT-A-SECRET-1234';
function scratch(t) { const root = mkdtempSync(join(tmpdir(), 'r4-')); t.after(() => rmSync(root, { recursive: true, force: true })); return root; }
function safe(label, Type, props = {}, omitted = secret) { return error => {
assert.ok(error instanceof Type, error.stack);
for (const [key, value] of Object.entries(props)) assert.equal(error[key], value);
assert.ok(error.message.includes(label), error.message);
assert.doesNotMatch(error.message + error.stack, /sk-test|NOT-A-SECRET|Unexpected token/);
assert.ok(!error.message.includes(omitted), error.message);
assert.equal(error.cause, undefined);
return true;
}; }
import { checkCallerOpId, checkName, checkRepoPath, parseManifest, validateRow, loadDoc, serialize } from '../src/queue.mjs';
import { QueueError } from '../src/errors.mjs';
for (const [field, fn] of [
['op', () => checkCallerOpId(secret + '/', 'op')],
['op', () => checkCallerOpId(secret.toLowerCase() + '.outcome', 'op')],
['owner', () => checkName(secret, 'owner')],
['path', () => checkRepoPath(secret + '/', 'path')],
['manifest', () => parseManifest(secret + '\n')],
['row', () => validateRow({ id: secret })],
['version', () => loadDoc(Buffer.from(serialize({ version: secret, canonicalRoot: '/x', revision: 0, rows: [], log: [] })))],
]) test(`queue ${field} semantic refusal omits the value`, () => assert.throws(fn, safe(field, QueueError, { code: 2 }, secret.toLowerCase())));
import { checkTransport, parseBriefSpec, genesisRows, parseMigrationMap, render, sha256, genesisReceipt } from '../src/queue.mjs';
import { MAP_ROWS, mapText } from './helpers.mjs';
const at = '2026-11-10T00:00:00.000Z';
function doc() {
const map = parseMigrationMap(mapText()), rows = genesisRows(map, new Map(map.rows.filter(r => r.brief).map(r => [r.id, 'a'.repeat(40)])), 'genesis-op-0001', at, 'sage');
const entry = { rev: 0, op: 'genesis-op-0001', verb: 'genesis', args: { root: '/x', branch: 'refactor', map: 'docs/map.md' }, by: 'sage', at, semantics: 1,
result: { mapBlob: 'b'.repeat(40), highWater: map.highWater, retired: map.retired, rows, legacyView: 'legacy\n', receipt: genesisReceipt('genesis-op-0001', rows.length) }, viewSha: sha256(render(rows, 0)) };
return { version: 1, canonicalRoot: '/x', revision: 0, rows, log: [entry] };
}
for (const [field, edit, value = secret] of [
['id', r => { r.id = secret; }], ['state', r => { r.state = secret; }],
['createdAt', r => { r.createdAt = secret; }],
['createdAt', r => { r.createdAt = '2026-02-30'; }, '2026-02-30'],
['claim seat', r => { r.claim = { seat: secret, op: 'claimed-op-0001' }; }],
]) test(`row ${field} omits semantic value ${value === secret ? 'sentinel' : 'calendar control'}`, () => {
const r = doc().rows[0]; edit(r); assert.throws(() => validateRow(r), safe(field, QueueError, { code: 2 }, value));
});
for (const [field, edit] of [
['rev', d => { d.log[0].rev = secret; }], ['verb', d => { d.log[0].verb = secret; }],
['semantics', d => { d.log[0].semantics = secret; }], ['revision', d => { d.revision = secret; }],
]) test(`queue ${field} omits semantic value from the log/document`, () => {
const d = doc(); edit(d); assert.throws(() => loadDoc(Buffer.from(serialize(d))), safe(field, QueueError, { code: 2 }));
});
test('transport comment omits its value', () => assert.throws(() => checkTransport({ outcome: 'posted', status: 201, comment: secret, detail: 'fixture' }), safe('comment', QueueError, { code: 2 })));
test('brief omits its unparseable spec', () => assert.throws(() => parseBriefSpec(secret), safe('brief', QueueError, { code: 2 })));
test('manifest duplicate path omits the value', () => assert.throws(() => parseManifest(`${'a'.repeat(64)} ${secret}\n`.repeat(2)), safe('manifest', QueueError, { code: 2 })));
import { canonArgs, applyEntry, replay } from '../src/queue.mjs';
const seatValue = secret.toLowerCase(), digestValue = 'a'.repeat(40);
function reviewRow(row, state = 'requesting') {
row.state = 'in-review';
const posted = state === 'posted';
row.review = { rounds: [{ n: 1, op: seatValue, by: 'darkwing', at, issue: 1511,
candidate: { kind: 'commit', digest: digestValue, text: null }, request: 'comment',
attempts: [{ op: seatValue, by: 'darkwing', at, state, comment: posted ? 1 : null,
transport: posted ? { outcome: 'posted', status: 201, comment: 1, detail: 'fixture' } : null, resolutions: [] }],
duplicateRisk: false, receipts: [] }] };
}
function operation(verb, args, edit = () => {}, options = {}) {
const state = replay(doc().log), row = state.rows.get(6);
edit(row);
if (!options.unchecked) validateRow(row);
const by = options.by ?? 'sage', op = options.op ?? 'operation-op-0001';
const entry = { verb, args: options.raw ? args : canonArgs(verb, { id: 6, ...args }), by, op, at, semantics: 2 };
return applyEntry(state, entry, options.resolved ?? {});
}
const ownerValue = row => { row.owner = seatValue; if (row.claim) row.claim.seat = seatValue; };
const restored = new Set(['claimed owner authority', 'start owner', 'review claimant', 'close gate owner', 'set owner reviewer', 'release claimant', 'assign existing owner', 'assign reviewer', 'note owner', 'add owner reviewer', 'unresolved request', 'missing approval', 'posted request', 'outcome actor', 'duplicate outcome', 'settled resolution', 'reviewer list', 'duplicate verdict']);
for (const [name, field, fn, omitted = seatValue] of [
['canonical duplicate reviewer', 'repeated value', () => canonArgs('set', { id: 6, field: 'reviewers', value: [seatValue, seatValue] })],
['canonical state', 'state', () => canonArgs('move', { id: 6, to: secret }), secret],
['canonical verb', 'verb', () => canonArgs(secret, {}), secret],
['claimed owner authority', 'owner', () => operation('move', { to: 'blocked', reason: 'pause' }, ownerValue, { by: 'outsider' })],
['start owner', 'owner', () => operation('move', { to: 'in-progress' }, r => { ownerValue(r); r.state = 'briefed'; r.claim = null; }, { by: 'outsider' })],
['review claimant', 'claimant', () => operation('move', { to: 'in-review', candidate: 'HEAD' }, ownerValue, { by: 'outsider' })],
['close gate owner', 'gate owner', () => operation('move', { to: 'done' }, r => { reviewRow(r, 'posted'); r.gateOwner = seatValue; }, { by: 'outsider' })],
['set owner reviewer', 'owner', () => operation('set', { field: 'reviewers', value: [seatValue] }, ownerValue)],
['set stored piece', 'piece', () => operation('set', { field: 'piece', value: secret }, r => { r.piece = secret; }), secret],
['set pinned brief', 'brief', () => { const r = doc().rows.find(r => r.id === 6), brief = { ...r.brief, blob: digestValue };
return operation('set', { field: 'brief', value: `${brief.path}#${brief.anchor}` }, r => { r.brief = brief; }, { resolved: { brief } }); }, digestValue],
['release claimant', 'claimant', () => operation('release', {}, ownerValue, { by: 'outsider' })],
['assign existing owner', 'owned', () => operation('assign', { seat: seatValue }, ownerValue)],
['assign reviewer', 'reviewers', () => operation('assign', { seat: seatValue }, r => { r.reviewers = [seatValue]; })],
['note owner', 'owner', () => operation('note', { text: 'note' }, ownerValue, { by: 'outsider' })],
['add owner reviewer', 'owner', () => operation('add', { piece: 'piece', gate: 'gate', brief: 'docs/brief.md#Brief', owner: seatValue, reviewers: [seatValue] }, () => {},
{ resolved: { brief: { path: 'docs/brief.md', anchor: 'Brief', blob: digestValue } } })],
['unresolved request', 'unresolved review request', () => operation('review-request', {}, r => reviewRow(r))],
['missing approval', 'approval', () => operation('move', { to: 'done' }, r => { reviewRow(r, 'posted'); r.gateOwner = 'sage'; r.reviewers = [seatValue]; })],
['missing attempt without round', 'review request', () => operation('review-resolve', { attempt: seatValue, comment: 1 })],
['missing attempt in round', 'review request', () => operation('review-resolve', { attempt: seatValue + '-missing', comment: 1 }, r => reviewRow(r))],
['posted request', 'posted request', () => operation('review-request', {}, r => reviewRow(r, 'posted'))],
['outcome op', 'outcome', () => operation('review-outcome', { attempt: seatValue, outcome: 'posted', status: 201, comment: 1, detail: 'fixture' }, r => reviewRow(r))],
['outcome actor', 'outcome', () => operation('review-outcome', { attempt: seatValue, outcome: 'posted', status: 201, comment: 1, detail: 'fixture' }, r => { reviewRow(r); r.review.rounds[0].attempts[0].by = seatValue; },
{ by: 'outsider', op: seatValue + '.outcome' })],
['duplicate outcome', 'outcome', () => operation('review-outcome', { attempt: seatValue, outcome: 'posted', status: 201, comment: 1, detail: 'fixture' }, r => reviewRow(r, 'posted'),
{ by: 'darkwing', op: seatValue + '.outcome' })],
['settled resolution', 'request', () => operation('review-resolve', { attempt: seatValue, comment: 1 }, r => reviewRow(r, 'posted'))],
['reviewer list', 'reviewer', () => operation('review-record', { verdict: 'approve', comment: 1, candidate: digestValue }, r => { reviewRow(r, 'posted'); r.reviewers = [seatValue]; }, { by: 'outsider' })],
['candidate mismatch', 'candidate', () => operation('review-record', { verdict: 'approve', comment: 1, candidate: 'b'.repeat(40) }, r => reviewRow(r, 'posted'), { by: 'filbert' }), digestValue],
['duplicate verdict', 'verdict', () => operation('review-record', { verdict: 'approve', comment: 1, candidate: digestValue }, r => { reviewRow(r, 'posted'); r.reviewers = [seatValue];
r.review.rounds[0].receipts = [{ reviewer: seatValue, op: 'receipt-op-0001', at, verdict: 'approve', comment: 1, candidate: digestValue }]; }, { by: seatValue })],
['legacy candidate evidence', 'candidate', () => operation('move', { to: 'done', evidence: `comment=1,round=1,candidate=${'b'.repeat(40)}` }, r => {
reviewRow(r); r.gateOwner = 'sage'; const round = r.review.rounds[0]; round.request = 'none'; delete round.attempts; delete round.duplicateRisk; delete round.receipts;
}), digestValue],
['direct unknown operation', 'verb', () => operation(secret, {}, () => {}, { raw: true }), secret],
// outcomeState also defends direct callers with an inconsistent transport record.
['inconsistent outcome record', 'outcome', () => operation('review-outcome', { attempt: seatValue, outcome: 'posted', status: 201, comment: 1, detail: 'fixture' }, r => reviewRow(r, 'failed'),
{ by: 'darkwing', op: seatValue + '.outcome', unchecked: true })],
]) test(`queue replay refusal ${name} ${restored.has(name) ? 'names validated identifiers' : 'omits stored values'}`, () => assert.throws(fn, restored.has(name) ? named(field, omitted, name === 'posted request' ? 'comment 1' : null) : safe(field, QueueError, { code: 2 }, omitted)));
test('migration map shape omits an unchecked row id', () => assert.throws(() => parseMigrationMap(mapText([{ id: secret }])), safe('migration map row', QueueError, { code: 2 })));
test('migration map brief shape omits an unchecked row id', () => {
const row = structuredClone(MAP_ROWS[1]); row.id = secret; row.brief = {};
assert.throws(() => parseMigrationMap(mapText([row])), safe('brief', QueueError, { code: 2 }));
});
test('migration map duplicate owner omits unvalidated values', () => {
const row = structuredClone(MAP_ROWS[1]); row.owner = secret; row.reviewers = [secret];
assert.throws(() => parseMigrationMap(mapText([row])), safe('owner', QueueError, { code: 2 }));
});
test('BASE control: genesis expected claim already omits the stored op', () => {
const d = doc(); d.log[0].op = seatValue; d.log[0].result.receipt = genesisReceipt(seatValue, d.rows.length);
assert.throws(() => loadDoc(Buffer.from(serialize(d))), safe('claim', QueueError, { code: 2 }, seatValue));
});
function named(label, value, additional = null) { return error => {
assert.ok(error instanceof QueueError); assert.equal(error.code, 2);
assert.ok(error.message.includes(label), error.message);
assert.ok(error.message.includes(value), error.message);
if (additional !== null) assert.ok(error.message.includes(additional), error.message);
return true;
}; }
// Each restored replay refusal is behind row validation or canonical arguments
// on the store's write path. Replace the relevant identifier at that boundary.
for (const [site, edit] of [
['claimed owner authority', r => { r.owner = secret; }],
['start owner', r => { r.owner = secret; }],
['review claimant', r => { r.claim.seat = secret; }],
['close gate owner', r => { r.gateOwner = secret; }],
['set owner reviewer', r => { r.owner = secret; }],
['release claimant', r => { r.claim.seat = secret; }],
['assign existing owner', r => { r.owner = secret; }],
['assign reviewer', r => { r.reviewers = [secret]; }],
['note owner', r => { r.owner = secret; }],
['unresolved request', r => { reviewRow(r); r.review.rounds[0].attempts[0].op = secret + '/'; }],
['missing approval', r => { reviewRow(r, 'posted'); r.reviewers = [secret]; }],
['posted request', r => { reviewRow(r, 'posted'); r.review.rounds[0].attempts[0].op = secret + '/'; }],
['posted request comment', r => { reviewRow(r, 'posted'); r.review.rounds[0].attempts[0].comment = secret; }],
['outcome actor', r => { reviewRow(r); r.review.rounds[0].attempts[0].by = secret; }],
['duplicate outcome', r => { reviewRow(r, 'posted'); r.review.rounds[0].attempts[0].op = secret + '/'; }],
['settled resolution', r => { reviewRow(r, 'posted'); r.review.rounds[0].attempts[0].op = secret + '/'; }],
['reviewer list', r => { r.reviewers = [secret]; }],
['duplicate verdict', r => { reviewRow(r, 'posted'); r.review.rounds[0].receipts = [{ reviewer: secret, op: 'receipt-op-0001', at, verdict: 'approve', comment: 1, candidate: digestValue }]; }],
]) test(`queue ${site} rejects malformed identifier before replay refusal`, () => {
const row = doc().rows.find(r => r.id === 6); edit(row);
assert.throws(() => validateRow(row), safe('', QueueError, { code: 2 }));
});
test('add owner reviewer rejects invalid owner before the conflict refusal', () => {
assert.throws(() => canonArgs('add', { piece: 'piece', gate: 'gate', brief: 'docs/brief.md#Brief', owner: secret, reviewers: [secret] }), safe('owner', QueueError, { code: 2 }));
});
test('claim mismatch names only the validated owner', () => {
const row = doc().rows.find(r => r.id === 6); row.claim.seat = secret;
assert.throws(() => validateRow(row), error => {
assert.ok(safe('claim seat', QueueError, { code: 2 })(error));
assert.ok(error.message.includes('darkwing'), error.message); return true;
});
});
test('duplicate logged op names the validated op; malformed op stops first', () => {
for (const op of ['genesis-op-0001', secret + '/']) {
const d = doc(); d.log[0].op = op; d.log.push({ ...structuredClone(d.log[0]), rev: 1 }); d.revision = 1;
assert.throws(() => loadDoc(Buffer.from(serialize(d))), op === 'genesis-op-0001' ? named('op id', op) : safe('op', QueueError, { code: 2 }));
}
});
test('unresolved request refusal names every open request', () => {
assert.throws(() => operation('review-request', {}, row => {
reviewRow(row);
row.review.rounds[0].attempts.push({ ...structuredClone(row.review.rounds[0].attempts[0]), op: 'second-request-01' });
}), named('unresolved review request', seatValue, 'second-request-01'));
});
test('approval refusal names every missing approver', () => {
assert.throws(() => operation('move', { to: 'done' }, row => {
reviewRow(row, 'posted'); row.gateOwner = 'sage'; row.reviewers = ['filbert', 'rocko'];
}), named('approval', 'filbert', 'rocko'));
});
+14 -14
View File
@@ -115,7 +115,7 @@ test("a request posts once as the requester; a retry sends nothing", async (t) =
const again = ok(s.run(move9(), "darkwing"), /\(already recorded at rev 3\)$/m);
assert.match(again.err, /request review-9-00001 is posted \(row 9 round 1 on #1508\), comment 1000; nothing was sent again/);
assert.equal(s.calls().length, 2);
no(s.run(request("request-9-0002"), "darkwing"), 2, /round 1 already has a posted request \(review-9-00001, comment 1000\)/);
no(s.run(request("request-9-0002"), "darkwing"), 2, /round 1 already has a posted request/);
no(s.run(["review", "outcome", "9"], "darkwing"), 4, /review takes request, resolve, abandon, record or verify-commit/);
throwsCode(() => s.mut({ verb: "review-outcome", op: "review-9-00001.outcome", args: { id: 9, attempt: "review-9-00001", outcome: "failed", status: 403, comment: null, detail: "x" }, by: "darkwing" }), 2, /records its own outcome/);
});
@@ -238,10 +238,10 @@ test("an unresolved request blocks a new request, a new round, waiting-on-jason
no(s.run(move9(), "darkwing"), 3, /^Look on #1508 for a comment carrying <!-- mosaic-queue-op: review-9-00001 -->\.$/m);
s.rules([]);
const head = s.head();
no(s.run(request("request-9-0002"), "darkwing"), 2, /unresolved review request: review-9-00001 \(round 1, uncertain\)/);
no(s.run(request("request-9-0002"), "darkwing"), 2, /unresolved review request/);
ok(s.run(["review", "record", "9", "--verdict", "approve", "--comment", "77", "--candidate", head, "--op", "record-9-filbert1"], "filbert"));
no(s.run(["move", "9", "done", "--op", "done-9-000001"], "filbert"), 2, /unresolved review request: review-9-00001 \(round 1, uncertain\); resolve or abandon it before closing it/);
no(s.run(["move", "9", "waiting-on-jason", "--op", "wait-9-000001"], "darkwing"), 2, /unresolved review request: review-9-00001 \(round 1, uncertain\); resolve or abandon it before moving it to waiting-on-jason/);
no(s.run(["move", "9", "done", "--op", "done-9-000001"], "filbert"), 2, /unresolved review request: .*; resolve or abandon it before closing it/);
no(s.run(["move", "9", "waiting-on-jason", "--op", "wait-9-000001"], "darkwing"), 2, /unresolved review request: .*; resolve or abandon it before moving it to waiting-on-jason/);
assert.equal(s.posts().length, 1);
// A POST that lands after the row reached waiting-on-jason: abandoned,
@@ -256,7 +256,7 @@ test("an unresolved request blocks a new request, a new round, waiting-on-jason
};
assert.equal(s3.mut({ verb: "move", op: "review-9-00001", args: { id: 9, to: "in-review", candidate: "HEAD" }, by: "darkwing" }, { hook }).code, 3);
assert.deepEqual([s3.row(9).state, s3.round(9, 1).attempts[0].state], ["waiting-on-jason", "conflict"]);
no(s3.run(["move", "9", "done", "--op", "done-9-000002"], "jason"), 2, /unresolved review request: review-9-00001 \(round 1, conflict\); resolve or abandon it before closing it/);
no(s3.run(["move", "9", "done", "--op", "done-9-000002"], "jason"), 2, /unresolved review request: .*; resolve or abandon it before closing it/);
// Back to in-progress is allowed; the next round is not.
const s2 = await ready(t);
@@ -264,7 +264,7 @@ test("an unresolved request blocks a new request, a new round, waiting-on-jason
no(s2.run(move9(), "darkwing"), 3);
s2.rules([]);
ok(s2.run(["move", "9", "in-progress", "--op", "changes-9-0001"], "darkwing"));
no(s2.run(["move", "9", "in-review", "--candidate", "HEAD", "--op", "review-9-00002"], "darkwing"), 2, /unresolved review request: review-9-00001 \(round 1, uncertain\); resolve or abandon it before a new round/);
no(s2.run(["move", "9", "in-review", "--candidate", "HEAD", "--op", "review-9-00002"], "darkwing"), 2, /unresolved review request: .*; resolve or abandon it before a new round/);
assert.equal(s2.posts().length, 1);
});
@@ -326,7 +326,7 @@ test("late outcomes: after an abandon, and after a resolve with the same or anot
let a = s.round(9, 1).attempts[0];
assert.deepEqual([a.state, a.comment, a.transport.comment, a.resolutions.map((x) => x.verb)], ["conflict", null, 1000, ["abandon"]]);
assert.equal(s.round(9, 1).duplicateRisk, true);
no(s.run(request("request-9-0002"), "darkwing"), 2, /unresolved review request: review-9-00001 \(round 1, conflict\)/);
no(s.run(request("request-9-0002"), "darkwing"), 2, /unresolved review request/);
ok(s.run(["review", "resolve", "9", "review-9-00001", "--comment", "1000", "--op", "resolve-9-00001"], "darkwing"), /conflict→posted comment 1000$/m);
a = s.round(9, 1).attempts[0];
assert.deepEqual([a.state, a.comment, a.resolutions.map((x) => x.verb)], ["posted", 1000, ["abandon", "resolve"]]);
@@ -465,7 +465,7 @@ test("request, changes, a new candidate, approval: every round pinned; no review
ok(s.run(["review", "record", "9", "--verdict", "changes", "--comment", "88", "--candidate", c1, "--op", "record-9-filb01"], "filbert"), /row 9 round 1 changes by filbert \(comment 88\)$/m);
no(s.run(["review", "record", "9", "--verdict", "approve", "--comment", "89", "--candidate", c1, "--op", "record-9-filb02"], "filbert"), 2, /filbert already recorded a verdict for row 9 round 1/);
ok(cli(s.repo, ["next", "filbert"]), /^nothing$/m);
no(s.run(["move", "9", "done", "--op", "done-9-000001"], "filbert"), 2, /round 1 has no approval recorded by filbert/);
no(s.run(["move", "9", "done", "--op", "done-9-000001"], "filbert"), 2, /round 1 has no approval recorded by (?:filbert|rocko)(?:, rocko)?/);
ok(s.run(["move", "9", "in-progress", "--op", "changes-9-0001"], "darkwing"));
no(s.run(["review", "record", "9", "--verdict", "approve", "--comment", "89", "--candidate", c1, "--op", "record-9-filb02"], "filbert"), 2, /is in-progress; review record applies to in-review rows/);
writeFileSync(join(s.repo.root, "packages/fix.mjs"), "export {};\n");
@@ -474,7 +474,7 @@ test("request, changes, a new candidate, approval: every round pinned; no review
const c2 = s.head();
ok(s.run(move9("review-9-00002"), "darkwing"), /round 2 on #1508; request review-9-00002 requesting$/m);
ok(cli(s.repo, ["next", "filbert"]), /^review row 9/m);
no(s.run(["review", "record", "9", "--verdict", "approve", "--comment", "89", "--candidate", c1, "--op", "record-9-filb02"], "filbert"), 2, new RegExp(`candidate ${c1} is not round 2's candidate ${c2}`));
no(s.run(["review", "record", "9", "--verdict", "approve", "--comment", "89", "--candidate", c1, "--op", "record-9-filb02"], "filbert"), 2, /candidate is not round 2's candidate/);
ok(s.run(["review", "record", "9", "--verdict", "approve", "--comment", "89", "--candidate", c2, "--op", "record-9-filb02"], "filbert"));
no(s.run(["move", "9", "done", "--evidence", `comment=89,round=2,candidate=${c2}`, "--op", "done-9-000001"], "filbert"), 2, /closes on its recorded verdicts; drop --evidence/);
no(s.run(["move", "9", "done", "--op", "done-9-000001"], "rocko"), 2, /only the gate owner \(filbert\) or a privileged actor/);
@@ -565,7 +565,7 @@ test("semantics: v1 entries replay as before; review entries need v2", async (t)
const outcome = d.log.at(-1);
assert.throws(() => queue.loadDoc(Buffer.from(queue.serialize({ ...d, log: [...d.log.slice(0, -1), { ...outcome, semantics: 1 }] }))), /review-outcome needs semantics 2/);
assert.throws(() => queue.loadDoc(Buffer.from(queue.serialize({ ...d, log: [...d.log.slice(0, -1), { ...outcome, op: "review-9-00002.outcome" }] }))), /op must be its attempt's op plus \.outcome/);
assert.throws(() => queue.loadDoc(Buffer.from(queue.serialize({ ...d, log: d.log.map((e, i) => (i === 1 ? { ...e, semantics: 3 } : e)) }))), /semantics 3 is not 1 to 2/);
assert.throws(() => queue.loadDoc(Buffer.from(queue.serialize({ ...d, log: d.log.map((e, i) => (i === 1 ? { ...e, semantics: 3 } : e)) }))), /semantics is not 1 to 2/);
// An attempt takes one outcome, even one that agrees with it.
const full = queue.replay(d.log);
const again = { ...outcome, rev: full.revision + 1 };
@@ -665,17 +665,17 @@ test("a Jason-gated row reaches waiting-on-jason only on every reviewer's approv
ok(s.run(move9(), "darkwing"), /posted comment 1000$/m);
assert.deepEqual(s.round(9, 1).receipts, []);
no(s.run(["move", "9", "done", "--op", "done-9-000001"], "darkwing"), 2, /gate is Jason's; it goes through waiting-on-jason/);
no(s.run(["move", "9", "waiting-on-jason", "--op", "wait-9-000001"], "darkwing"), 2, /row 9 round 1 has no approval recorded by filbert, rocko$/m);
no(s.run(["move", "9", "waiting-on-jason", "--op", "wait-9-000001"], "sage"), 2, /no approval recorded by filbert, rocko$/m);
no(s.run(["move", "9", "waiting-on-jason", "--op", "wait-9-000001"], "darkwing"), 2, /row 9 round 1 has no approval recorded by (?:filbert|rocko)(?:, rocko)?$/m);
no(s.run(["move", "9", "waiting-on-jason", "--op", "wait-9-000001"], "sage"), 2, /no approval recorded by (?:filbert|rocko)(?:, rocko)?$/m);
ok(s.run(["review", "record", "9", "--verdict", "approve", "--comment", "88", "--candidate", head, "--op", "record-9-filb01"], "filbert"));
ok(s.run(["review", "record", "9", "--verdict", "changes", "--comment", "89", "--candidate", head, "--op", "record-9-rock01"], "rocko"));
no(s.run(["move", "9", "waiting-on-jason", "--op", "wait-9-000001"], "darkwing"), 2, /no approval recorded by rocko$/m);
no(s.run(["move", "9", "waiting-on-jason", "--op", "wait-9-000001"], "darkwing"), 2, /no approval recorded by (?:filbert|rocko)(?:, rocko)?$/m);
// Changes go back to in-progress, as before.
ok(s.run(["move", "9", "in-progress", "--op", "changes-9-0001"], "darkwing"));
ok(s.run(move9("review-9-00002"), "darkwing"), /posted comment 1001$/m);
ok(s.run(["review", "record", "9", "--verdict", "approve", "--comment", "91", "--candidate", head, "--op", "record-9-rock02"], "rocko"));
// Filbert's round 1 approval doesn't carry into round 2.
no(s.run(["move", "9", "waiting-on-jason", "--op", "wait-9-000001"], "darkwing"), 2, /row 9 round 2 has no approval recorded by filbert$/m);
no(s.run(["move", "9", "waiting-on-jason", "--op", "wait-9-000001"], "darkwing"), 2, /row 9 round 2 has no approval recorded by (?:filbert|rocko)(?:, rocko)?$/m);
ok(s.run(["review", "record", "9", "--verdict", "approve", "--comment", "90", "--candidate", head, "--op", "record-9-filb02"], "filbert"));
ok(s.run(["move", "9", "waiting-on-jason", "--op", "wait-9-000001"], "darkwing"), /in-review→waiting-on-jason$/m);
ok(s.run(["move", "9", "done", "--op", "done-9-000001"], "jason"), /waiting-on-jason→done$/m);
Loaded 100 of 153 files, more files were not shown because too many files have changed in this diff. Show more