Compare commits

...
32 Commits
Author SHA1 Message Date
jason.woltje afe131444c test(cli): retain default service template coverage (#1873) 2026-11-11 02:34:50 -06:00
jason.woltje 0b52249cc3 test(cli): measure legacy and query inner timers at the supervisor (#1873) 2026-11-11 02:11:48 -06:00
jason.woltje b7c9eee603 fix(organization): definition writes go through the bus with the held-secret check (#1873)
Review P1: the CLI wrote definitions with a no-op secret check. setDefinition
now refuses definition-secret-check-required when a write that adds or changes
an entry has no assertClean. New bus verbs definition.import|invent|edit|remove
run the writes for the business's human at the cli with the credential
store's assertClean; the tester, agents and readers refuse. The CLI's
definition writes call the bus and map its codes to messages that never
quote the definition.
2026-11-11 01:15:12 -06:00
jason.woltje 1d07df0d61 feat(organization): org definitions, template hash and diff reads, definition verbs (#1873)
Roles round 2, slice T1 (roles-and-rails design sections 4, 5 and 13).

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

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

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

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

The fixture now spreads requestHostOps from packages/cli/tests, the shared
map every other fixture uses, so it tracks the production protocol.
2026-11-11 00:30:10 -06:00
jason.woltje 99ce579e3c fix(plugins): keep imports on the verified package pin (#1873) 2026-11-11 00:11:13 -06:00
jason.woltje b44b14c7df feat(plugins): install pinned instance packages and declared tools (#1873) 2026-11-11 00:11:13 -06:00
jason.woltje 85858938fc docs: design credential bindings for agent copies 2026-11-10 23:52:29 -06:00
jason.woltje 6415e81ee7 docs(run-sheet): check config directory ancestors before accounts init (#1873)
mosaic accounts init refuses accounts-unsafe since #1919 when a folder above the
config directory is writable by another user. Step 3 now gives the read-only check.
2026-11-10 23:43:55 -06:00
jason.woltje 15225c5e1c test(webui): verify applied light overrides and the U5 shot recipe (#1873) 2026-11-10 23:40:06 -06:00
jason.woltje cb9580f6fb fix(webui): explain conflicts that reopen a read-only task (#1873) 2026-11-10 23:40:06 -06:00
jason.woltje c0e08f81ea fix(webui): reopen Archive conflicts on the current task (#1873) 2026-11-10 23:40:06 -06:00
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
186 changed files with 8776 additions and 861 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
+86 -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,83 @@ 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. It also refuses `accounts-unsafe` when any folder above the config
directory is not root's or `mosaic`'s, or is group or other writable without the sticky bit (#1919).
Check first, read-only: every line printed by
`p=/srv/mosaic/.config/mosaic-dev; while [ "$p" != / ]; do stat -c '%U %a %n' "$p"; p=$(dirname "$p"); done; stat -c '%U %a %n' /`
names `root` or `mosaic`, and its mode has no group or other write bit unless it starts with 1 (sticky).
The config directory itself is `mosaic`'s with no group or other write bit. A group-writable `.config`
refuses on purpose; fix the mode, never the check.
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 +528,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
@@ -0,0 +1,450 @@
# Credential binding for agent copies
Date: 2026-11-11 UTC. Lane `org-cred-bind-design`, issue #1873.
Status: proposed, for Jason's approval before implementation. Doc only.
Baseline: `6415e81ee75a6783b889b67b985f61825f9a074e` (`origin/refactor`).
All current-code citations below refer to that commit. References to other
designs describe proposed policy, not a claim that it is implemented.
## 1. Outcome and boundaries
An owner can approve a credential binding for one root or project agent copy
inside one organization. A launch uses only that copy's own secret slot,
with verified service permissions bounded by its frozen role and the
organization's narrower approval. No agent can bind credentials for itself
or another agent. Missing, stale or uncertain authority refuses launch and
credential use. Copies in other organizations remain independent.
This proposes a writer, schema and enforcement changes for later slices. It
changes no code, right, role, contract, secret or running instance. Host and
model credentials remain on their existing grant and proxy paths. There is
no migration of named-agent credentials, no root-wide secret binding, no
shared token pool, and no new plugin service in the first build.
Owner direction requires each non-assistant agent's credentials to stay
inside its organization even when its definition comes from root
(`docs/plans/2026-11-09_unified-workspace-owner-direction.md:124-131`). A3
explicitly deferred binding and its two-org thread-copy test
(`/mnt/storage/scratch/mosaic-lead/reports/org-a3-build.md:52,216-217`).
## 2. Current behavior, traced before the proposal
| Step | Current behavior and evidence |
| --- | --- |
| Declare requirements | `checkCredentials` accepts each known service once and validates its scope shape. Gitea uses category levels; Vikunja uses route-group verbs; host has no scopes (`packages/organization/src/role.mjs:100-143`). `admin` and `all` are outside the grantable Gitea categories (`packages/organization/src/vocabulary.mjs:187-191`). |
| Declare a named instance | `roles.<instance>.credentials` must reference exactly the role's services, except host grants and the chief's Gitea exception when no library exists. Vikunja also requires a unique bot identity (`packages/organization/src/organization-config.mjs:50-59,124-167`). |
| Locate the slot | `parseCredentialRef` accepts exactly one of `store`, `file` or `env`. `{store:true}` derives the file from config directory, business, agent and service; it does not accept another agent's store path. Named agents can still use external file and environment references (`packages/organization/src/credentials.mjs:23-59`). This lane proposes only the store form for copies. |
| Resolve launch inputs | `resolveInstance` takes the entry from the validated organization, intersects tools/network/actions with limits, then returns its non-host credential references and tracker metadata (`packages/organization/src/resolve.mjs:34-69,81-95`). These limits do not compute a credential scope intersection. |
| Load secrets | Runtime indexes references by `business/role` and sets the store root to the agents secret directory (`packages/bus/src/runtime.mjs:333-345,378-379`). `Credentials` loads those references into its private map (`packages/bus/src/credentials.mjs:130-165`). It derives its store filename from business, agent and service (`packages/bus/src/credentials.mjs:351-355`). File reads reject symlinks, wrong ownership/mode and repository/data-root placement (`packages/bus/src/credentials.mjs:357-415`). |
| Use secrets | `Credentials.useFor` derives business and role from the session, rather than a caller-supplied target. Missing tokens refuse `credential-unavailable`; Vikunja expiry refuses `credential-expired` (`packages/bus/src/credentials.mjs:597-624`). The task adapter uses that session-bound path (`packages/tasks/src/adapter.mjs:84-88`). |
| Issue and revoke | The broker requires a human, with an explicit tester-verb exception; it checks that the agent exists and prevents delete while ordinary or thread claims remain (`packages/bus/src/broker.mjs:6212-6225`). The credential writer reads only `document.roles[agent].credentials`; issue writes a store reference, revoke drops memory/file before writing or recording the receipt (`packages/bus/src/credential-verbs.mjs:16-51`). |
| Record writes | The organization writer takes the shared registry lock, then the organization's history `write.lock`, validates the revision and document, records a prepared change and publishes the file (`packages/organization/src/organization.mjs:143-159,175-220,278-340`; `packages/organization/src/org-registry.mjs:73-101`). Its `pathsOnly` option records changed field paths and presence without values (`organization.mjs:276-278,324-327`). |
| Materialize copies | Root and project entries explicitly refuse `credentials`, `tracker`, `identity`, `publish`, `holder` and `hosts` (`packages/organization/src/agent-levels.mjs:18-41,53-59,105-115`). Copy names preserve root names or append project names. Collisions refuse both claimants; org instances shadow copies. Every required non-host service makes a copy `unbound` (`packages/organization/src/agent-copies.mjs:36-53,73-104`). An unbound copy never joins the role map and launch refuses `credential-unbound`, naming missing services (`agent-copies.mjs:223-245,279-287`; `packages/cli/src/launcher.mjs:361-375`). |
| Guard launch races | Launch checks against a fresh organization revision, then final `request.admission` compares it again after async preparation (`packages/cli/src/launcher.mjs:357-364,802-813`; `packages/bus/src/runtime.mjs:636-661`). This revision covers organization bytes, not role file bytes. A3 records that limitation explicitly (`/mnt/storage/scratch/mosaic-lead/reports/org-a3-build.md:215`). |
Important limit: validating a slot reference does not prove the remote
token's permissions. For non-host/non-model services, `Credentials.#format`
checks token syntax and length, not remote scopes
(`packages/bus/src/credentials.mjs:448-451`). Existing Gitea minting has a
fixed seat scope list, not a per-copy intersection
(`packages/bus/src/gitea-grant.mjs:27-30,262-269`). Vikunja startup has
specific negative permission probes, not a complete per-composition scope
proof (`packages/tasks/src/startup.mjs:30-55`). The new build must close
these gaps for copies; a JSON field that asserts scopes is insufficient.
## 3. Choice and storage
Considered three approaches:
1. Add credentials to root/project definitions. Rejected: a root definition
would carry a per-org concern and invite cross-org reuse.
2. Write synthetic copies into `roles`. Rejected: this conflicts with org
shadowing and turns a boot copy into a persistent named instance.
3. Add an org-local binding overlay and reuse the existing per-agent store.
Recommended. It keeps copy definitions secret-free, preserves shadowing,
and gives bindings the same revision fence as other org settings.
The authoritative metadata lives in the existing organization file,
`<configDir>/businesses/<org>.json`, under a proposed top-level
`copyBindings` key. It is not written into `agents.json` or `roles/`.
An absent key means no bindings. The writer stores one record per concrete
copy instance; the example below is illustrative schema, not current JSON:
```json
"copyBindings": {
"writer.site": {
"copyId": "<random 128-bit lowercase hex>",
"orgIncarnation": "<registry incarnation>",
"source": { "level": "project", "name": "writer", "project": "site" },
"definition": { "ref": "org:release-writer", "rev": 3, "sha256": "<digest>" },
"generation": 1,
"state": "bound",
"services": {
"gitea": {
"slot": "self",
"scopes": ["read:repository"],
"resources": { "projects": ["site"] },
"rotateBy": "2026-12-01",
"grantId": "<opaque service grant id>",
"grantGeneration": 1
}
},
"decision": "<authority decision id>"
}
}
```
`slot: "self"` means exactly `(org, concrete instance, service)`. It is a
selector for today's derived slot, not an arbitrary string slot name. The
record contains no path, environment name, token, token fragment or token
hash. Grant ids identify authorization records, never encode token bytes.
Secret values stay exclusively in the existing private credential store
and trusted service-call memory, never in metadata, evidence, bundles,
decision text or errors. The symbolic paths in this design describe source
architecture; runtime responses never emit resolved host paths.
`source` includes the level and original name/project, so dotted-name
collisions cannot silently attach an old binding. `copyId` is created on
first approval and retired on fire, shadowing, source removal or replacement.
`generation` increases for every binding change or revocation and never
resets while that copyId exists. An unbound tombstone remains after unbind.
A new copyId never adopts an old slot file. Name reuse requires verified
old-slot cleanup and newly verified credentials before activation.
For a root copy, source.project is null and its definition is the frozen
root definition identity. For a project copy it names the org definition
or template identity. A definition hash includes composition and contract;
the applicable instruction/personality revision is pinned too. Import
provenance (`from.sha256`) is not the customized definition's current hash.
Vikunja metadata additionally contains the existing bot identity shape
(`tracker: {bot, botId}`) at the copy record level. It must pass the same
org-wide uniqueness check as named instances, including other copies.
No service binding implicitly grants identity, publication, host enrollment
or project access. Existing identity/publish requirements remain separate.
### Writer and lock order
Extend `organizationWriter` with dedicated trusted methods
`applyCopyBindingDecision` and `revokeCopyBinding`; names are proposed API.
Only the authenticated broker/Console owner path invokes these methods.
Generic document writes and root/project writers must reject changes to
`copyBindings`, even if a submitted document validates. An actor string,
decision id or hash supplied by an agent is never authorization.
Reuse the shared registry lock `<configDir>/org-runtime.lock`, then the
organization lock `<configDir>/businesses/.history/<org>/write.lock`.
Do not introduce a second competing metadata file or lock. Reuse the
writer's revision conflict, prepared/committed receipt and paths-only
evidence machinery. Under the locks, establish fresh active org incarnation,
owner identity, expected document revision and decision digest. Perform
the final checks in the publication critical section, not only in HTTP.
Do not hold filesystem locks across remote service calls or waits for runs
to stop. Before such work, persist a non-active pending operation with a
unique id and expected generations. Reacquire the locks, re-read and compare
all identities before publishing. A competing write invalidates preparation.
Use the same operation id for idempotent recovery; a prepared or uncertain
operation cannot produce a usable binding or resurrect the prior token.
## 4. Authority, decisions and deployed ownership
Use the existing grantable `org.hire` right as the authority-change entry
right. It already exists, defaults to owner yes/member no, and maps
`hire.resolve.console` to `org.hire`
(`packages/organization/src/org-rights.mjs:6-26,67-69`). Do not add an
`org.credentials` right that would imply delegable token-grant authority.
The right is necessary but not sufficient: any decision containing a
credential line requires the current owner of the target org, even if a
member holds `org.hire` or `decide`. Map credential resolution to an
explicit owner check as well as the right check, rechecked at commit.
The owner-only credential read stays owner-only; `board.read` gets at most
the existing copy's missing-service status, never a binding record.
Bind and unbind are **authority-change decisions**, represented by the
hire model's `op: "change"`, with a binding-specific payload. The
decision names source, copyId (or new-copy proposal), org incarnation,
definition revision/hash, before/after service scopes and resources, and
expected binding generation. The broker computes credential lines and
the whole-decision digest. Approval consumes the decision once. Dropping,
narrowing or replacing a line invalidates the old digest and requires
confirmation of the new digest; widening always raises a new decision.
This reuses hire-as-a-decision instead of creating a second approval
model. It follows the roles-and-rails design's credential ruling,
owner restriction, digest, seven-day expiry, one-step human-originated
decision, and post-approval binding steps
(`docs/plans/2026-11-10_roles-and-rails-design.md:35,232-310`). The chief
may propose a binding only through that decision path, never by naming a
slot, supplying a token or calling the writer. Other agents cannot raise
this authority-change subtype. No agent resolves it, including the chief,
an arbiter, its subject, or a different copy of the same definition.
This is an authority change under system rails R1, R5, R7 and R8 and the
role's credential rail. Default autonomy is **in the loop**. As required by
the existing design, credential bindings do not move to near or out of the
loop at all without a new owner ruling
(`docs/plans/2026-11-10_roles-and-rails-design.md:90-119,416-425,445-463`).
Unbind uses the same human decision record, including a one-step
owner-originated decision. Emergency **revoke** remains an owner-only
immediate verb: it only removes use, must not wait for a chief proposal,
and records the authority reduction. Revoke never issues a token or changes
an unbound binding to bound.
A root-level definition change still needs the instance owner. Binding one
root copy inside org O needs O's owner and grants nothing in another org.
Being an instance operator or root-business owner alone does not make a
person O's owner. Accounts design separates these principals explicitly
(`docs/plans/2026-11-10_accounts-design.md:314-349`). No account-only session
falls back to a human/root transport. All Console mutations retain Origin,
session, fresh ownership and confirmation fences.
The host remains the trust boundary. Host root and the instance uid can
already change the instance and its secrets; this proposal does not claim
protection from either, or from a sandbox escape to that uid. The deployed
instance uid must remain separate from human/development seats
(`docs/plans/2026-11-10_accounts-design.md:887-936`).
## 5. Permission calculation and proof
For every service V, compute:
`effective(V) = role-declared(V) intersect org-approved-binding(V)`.
The binding is the org's per-copy narrowing. Omitted services are unbound,
not permission to use another source. Unknown services/scopes refuse; they
are never ignored. The requested scopes must already be a subset of the
role. Do not silently trim a broader request and report that it succeeded.
Resources also intersect the org's current admitted project set and the
copy's union. A project copy may access its project only. A root copy may
access only the approved projects that still admit it. An empty intersection
allows no service operations and leaves a required service unbound.
Scope comparison is semantic: Gitea read is narrower than write in the same
category, categories are independent, and neither admin nor all is allowed.
Vikunja compares each route-group/verb pair. Missing categories/groups mean
no permission; there is no wildcard. All adapters must apply their service's
registered semantics, never compare scope strings lexicographically.
### At proposal, approval and bind
1. Resolve the concrete copy from the target org's boot snapshot and current
project union. Refuse missing, shadowed, colliding, excluded or fired
copies. Never create `roles[copyName]` to make the operation succeed.
2. Validate the role through the core composition validator and current
enabled registry. Read T1's definition revision/hash, or the frozen root
role identity. Verify org active state and incarnation. Compute all
credential lines from these inputs, not from chief-supplied summaries.
3. Check every requested service/scope/resource against that definition and
org. Verify the resolver is the fresh org owner. The owner cannot override
a system rail or widen a binding past the approved role; changing the role
requires its own approved change first.
4. Obtain a dedicated service credential for this exact org/copy/service.
Trusted service adapters must establish both token scopes and remote
identity resource grants. Prove the allowed projects/repos at the service,
not just a local `resources` list. A root-wide or cross-org bot account is
unacceptable even if its token is stored in a per-org directory.
5. Accept a token only after trusted live introspection verifies its scopes,
service account, permitted resources and expiry against this operation,
or after a reviewed managed mint path produces and verifies that exact
bounded grant. An owner-entered assertion, copied grant id, token format,
or a few successful/negative probes are not proof of complete permissions.
If the service/version cannot prove the bound, refuse
`credential-scope-unverified`. Ship that refusal rather than an unsafe
compatibility mode. This is a required service-adapter build item.
6. Stage the secret privately in the existing store with the binding inactive.
Recheck decision digest, definition hash, org incarnation, document
revision and slot generation after remote work. Only then commit the
metadata/receipt. A grant record binds the opaque grant id, copyId,
incarnation and generation to the issued slot. It stores no secret or
token-derived digest. Ambiguous crash recovery keeps use closed and
requires a new verified issue if exact provenance cannot be established.
Gitea's existing fixed `SEAT_SCOPES` must not be reused for a read-only role.
Vikunja needs a reviewed verifier for the complete route-group scope set
and bot/project access. Until each adapter exists, that service's copy
binding refuses. Do not claim the existing named-agent path provides this
proof. Extending remote minting beyond its current approved envelope is a
separate protected change, identified in slice CB2 below.
### At launch, restart and every use
The first build uses the existing restart model for **new references**.
Approval may record a binding, but it is not usable until the org bus
restarts and verifies it. Surface `restart-required`, not a successful
launch. Today reference/bot changes already require restart
(`packages/bus/src/runtime.mjs:547-583`). Do not add live reference reload
in the first slice. Revocations take effect immediately and never wait
for this restart.
On boot and every launch, re-resolve the effective role and binding,
validate source/copyId/incarnation/generation, and obtain fresh trusted
service scope/resource proof for the actual slot contents. If verification
is unavailable, launch refuses. All required services must be bound and
available. Bindings may be approved incrementally, but partial success
cannot launch. A stale file alone never counts as a binding.
Preserve A3's organization revision fence. Add a separate launch authority
digest over frozen definition identity, copyId, org incarnation, binding
generation and verified grant generation. Do not redefine the writer's
document revision. Carry both into final admission and `bindLaunch`, and
bind the admitted generations to the session. A pending revocation blocks
admission, including the interval between final admission and spawn/bind.
If a child was spawned before revocation won, deny its capability and stop
it; it must perform no credential call. Do not release a launch fence on an
uncertain outcome until that child is confirmed stopped.
At every credential-bearing operation, check session org/instance, active
incarnation, effective scopes/resources, binding/grant generation and slot
liveness before any external request. Derive the slot from the session,
never from request arguments or thread text. Raw tokens and credential file
paths do not enter sandboxes. The trusted service adapter checks each
method/resource as well as token permissions. Constrain outbound destinations
to the approved service origin and reject redirects carrying authorization
to another origin. Operation completion rechecks the generation before
publishing its result. Unknown or stale state refuses closed.
## 6. Lifecycle and running copies
All transitions below close credential admission first, invalidate affected
session generations, then stop **all** affected ordinary and thread runs.
Serialize close versus new admission so a concurrent launch cannot escape
the stop inventory. Abort cancellable service calls and wait for all held
uses to settle before slot deletion or replacement. A completed external
write cannot be undone; report that limit, never call local revoke a remote
rollback. Existing non-host `Credentials.use` does not provide the host-key
abort behavior, so generalizing the use fence is required, not assumed
(`packages/bus/src/credentials.mjs:556-569,618-653`).
| Event | Durable outcome and effect on a running copy | Next launch/use |
| --- | --- | --- |
| Unbind service | Owner-approved change bumps generation, records unbound tombstone, closes use, stops every run of that copy, revokes/removes its slot. Never retain a reusable token behind an inactive binding. | `credential-unbound`: `credentials.gitea needs an owner-approved binding.` |
| Emergency revoke | Owner verb immediately closes use, bumps generation, stops all copy runs and performs local revoke. A managed remote grant is also revoked through its approved adapter. Keep tombstone; reissue needs a fresh decision and verification. | `credential-revoked`: `credentials.gitea was revoked; the owner must approve a new binding.` |
| Slot removed or altered outside the writer | A slot liveness/identity check at use and launch refuses even if memory still holds the token. Mark binding unavailable, invalidate session use and stop its runs. Never fall back to cached memory, environment or another slot. Replacement is a newly verified issue. | `credential-unavailable`: `credentials.gitea is unavailable; the owner must verify the slot.` |
| Expiry or upstream revocation | Local expiry checks and service authentication failure close the binding, invalidate use and stop runs. Rotation is a new verified slot generation and owner-approved change; no in-place replacement behind active uses. | `credential-expired`: `credentials.vikunja has expired; the owner must approve a replacement.` Upstream revoke uses `credential-revoked`. |
| Role/definition changed | Any role identity/hash/revision mismatch closes use and stops affected copies. Do not inherit old credentials even for equal service names. A narrower role requires new bounded service proof; a wider role requires approval first and fresh binding. T1 blocks edits of an in-use definition until the change-decision path exists. | `credential-role-changed`: `credentials.gitea no longer matches the approved definition; the owner must review the binding.` |
| Copy fired, source removed, excluded or shadowed | Close use, retire copyId, stop every run, revoke slots and delist the copy. Preserve run/trail evidence. A newly shadowing named instance must not load the old copy's file at the identical slot name. Require cleanup before it can use that name. | `copy-unavailable`: `copy is no longer active in this organization.` |
| Copy-name collision | Refuse attaching either binding. If introduced while running, close affected use and stop both claimants. No first-wins fallback. | `copy-refused`: `copy identity is ambiguous; the owner must resolve the definition collision.` |
| Organization archived | Close the org's admission, invalidate its running sessions and stop every org run before archive completes. Revoke all copy service slots and mark bindings unbound. Restore preserves definitions but requires fresh binding decisions. Never affect another org's runs or slots. | `org-archived`: `organization is archived.` After restore, `credential-unbound`. |
| Organization deleted | Close all admission/use and stop every org run, revoke managed remote grants and local slots before the existing quarantine flow. Preserve evidence/tombstones in deletion evidence. A new org with the same id has a new incarnation and no inherited bindings. | `org-unavailable`: `organization is not active.` Recreated org starts unbound. |
| Revoke/stop/cleanup uncertain | Persist a non-usable pending-revocation state and keep the affected copy or org fenced. Do not publish success or restore memory on write failure. Retry with the same operation id. Remote revocation uncertainty remains visible to the owner. | `credential-revoke-failed`: `credentials.gitea revocation is incomplete; the owner must retry cleanup.` |
These archive/delete effects are proposed extensions. Current archive stops
the bus and marks the registry archived; deletion quarantines org runtime,
document/history and org secret trees, while its receipt says remote-side
revocation is out of scope (`packages/organization/src/org-service.mjs:239-267,332,343-347`).
The new build must explicitly test and add copy-run and copy-grant cleanup;
it cannot infer it from a stopped bus.
Refusal codes in this section are proposed public contracts where new.
Use `human-required` with `Only the organization owner can approve credential
binding.` for an agent/member mutation, `credential-not-yours` with
`credentials.gitea must use this copy's own organization slot.` for a wrong
slot, `credential-scope-exceeded` with `credentials.gitea.scopes exceeds the
approved role.` for excess scopes, and `credential-scope-unverified` with
`credentials.gitea permissions could not be verified.` for missing proof.
`credential-binding-changed` says `credentials.gitea changed; read and approve
again.` for stale generation; `organization-changed` retains the existing
document-race code. Validation errors name field positions such as
`copyBindings entry 1 services field`, not untrusted submitted keys.
All new boundaries allow only registered service/field labels and fixed
remediation text. Never interpolate a submitted value, resolved path, raw
service error, URL, bot name, secret, token hash or token fragment into an
error, receipt or trail. New metadata serializers use an allowlist and
paths-only receipts. Scan human text with the existing secret scanner and
fixed field-size limits; arbitrary unknown keys refuse with a fixed field
label. Metadata holds only validated ids, known scope names and bounded
dates/integers. The scope is every new copy-binding surface, consistent
with the brief's #1916/#1920 requirement, without changing unrelated legacy
diagnostics. Neither reviewer nor implementer needs live secrets to test it.
## 7. Two organizations and identity reuse
Example acceptance fixture: org A and org B both materialize root `writer`
from the same creative template. A's binding selects `(A, writer, gitea)`;
B's selects `(B, writer, gitea)`. Each uses a dedicated remote bot/token whose
resources exclude the other org. B cannot select A's slot or reuse A's grant
record. A path restriction alone would not prevent a token with both orgs'
remote grants, so service proof must check those grants too.
Enforce isolation at all of these independent boundaries:
1. Console resolves the target org from the authorized route and fresh
ownership, not a body-supplied org or an instance-wide owner flag.
2. The writer derives its org and slot from that context and binds the
metadata to the registry incarnation. A copied record from A has the
wrong incarnation in B and refuses before opening any slot.
3. Copy source tuple, copyId and definition digest must match B's materialized
copy. A root definition carries no binding to propagate.
4. Credential loader derives the slot, enforces private regular-file rules,
rejects alias/symlink/hardlink substitution for copy slots and verifies
remote grant ownership. Reject nlink other than one for these slots.
5. Every use derives org/instance from the authenticated session and checks
the active binding generation. A thread, room or caller-supplied instance
cannot choose the key. Session and grant records cannot cross buses.
6. Archive/delete touches only that incarnation's resources. A failed cleanup
never makes credentials valid for a later org or copy with the same name.
Registry incarnation is a dependency of accounts design
(`docs/plans/2026-11-10_accounts-design.md:501-514`). The binding build must
use that stable value; if the target baseline lacks it, land an explicitly
approved incarnation slice first. Do not substitute timestamps, a pathname
or an org id alone. The dedicated store layout remains unchanged.
## 8. T1 dependency and implementation slices
T1 is in flight. Its brief is
`/mnt/storage/scratch/mosaic-lead/uidev/org-release/T1-BRIEF.md`. This design
depends on its org-local definitions, `org:` resolver, revision/hash read,
composition validation and in-use definition guard, as specified in
roles-and-rails sections 4 and 5
(`docs/plans/2026-11-10_roles-and-rails-design.md:157-170,186-228`). Do not
patch T1's files in this lane. Project copies use those org definitions;
root copies keep a frozen root identity. Supporting root-local customized
definitions waits for that resolver to exist, rather than inventing another
definition format here. A4 supplies the authority-change decision engine;
this build extends it, rather than shipping a temporary direct bind verb.
Every slice below is a later proposal requiring Jason's approval before
work. Each ends with focused tests plus affected organization, business,
bus, CLI and Console suites. No slice edits `roles/` or `contracts/`.
| Slice and dependencies | Build and acceptance evidence | Protected changes needing approval |
| --- | --- | --- |
| CB0: identity foundation, after T1/accounts | Reuse incarnation or add its approved missing support. Define copyId, source/hash pin and binding generation semantics. Pure resolver tests cover identical root copies across two orgs, project dotted-name collisions, org shadowing, removed/recreated org and source-name reuse. No secret read and no launch enabled. | Org schema; registry schema only if incarnation is absent. No new rights or role files. |
| CB1: metadata and decisions, after CB0/A4 | Add `copyBindings`, guarded organization writer, owner-only credential subtype under `org.hire`, digest/expiry/single-consumption and paths-only receipts. Tests: owner binds/unbinds; member with org.hire/decide, chief, arbiter, subject agent, tester and reader all fail to resolve/write; agent may not call raw writer; only chief agent can propose; stale revision/digest/generation, expired approval, replay, wrong incarnation and generic write injection have no activation. Pending/partial binding never launches. | Organization and decision payload schema; `VERB_RIGHTS`/owner dispatch changes even though no new grantable right. Approval explicitly covers the reuse of org.hire with the owner restriction. |
| CB2: verified service slots, after CB1 | Reuse self-only store and dedicated bot identity; add complete Gitea/Vikunja verification or exact bounded mint adapters. Tests use fake services for narrower/equal/broader scopes, read/write hierarchy, extra route verbs, unrelated repos/projects, reused foreign bot/grant, opaque/unverifiable token, wrong date, symlinks/hardlinks, stale file and token replacement. No request, path or token enters metadata/evidence; lost replies and crash points remain inactive. A live scratch-service acceptance must prove each service/version's permission verifier before that service enables copy launch. | Credential handling/security boundary; grant metadata schema and any remote mint/revoke envelope changes. No automatic reuse of existing fixed Gitea seat scopes. |
| CB3: admission and use, after CB2 | Materialize only verified bound copies; new references visibly need restart. Check document revision plus separate authority digest at prepare, final admission and bindLaunch. Session-bound adapter checks every operation and slot liveness. Race tests mutate role hash, org bindings, token contents or admitted projects during async prepare and between admission/spawn/bind/use. Assert no admitted credential call, including an empty project manifest. Concurrent revoke denies new work; no cached-token fallback after unlink. | Launcher/broker credential enforcement boundary; session/launch authority fields and any store schema needed to persist fences. No live reference-reload policy. |
| CB4: lifecycle and recovery, after CB3 | Test unbind, emergency revoke, slot removal, expiry, role change, fire, collision, shadowing, archive/restore, delete/recreate. Each closes admissions, invalidates ordinary and every thread run, aborts or drains uses, and denies later calls. Inject failures before/after metadata publication, secret staging, remote revoke, stop acknowledgement and receipt write; restart never resurrects authority. Include named-instance shadowing over a former copy's slot. Other org continues. | Credential/revocation and org lifecycle boundary; durable pending-operation schema. Existing deletion quarantine must not silently claim remote revoke. |
| CB5: two-org thread acceptance and operator flow, after CB4 | Finish A3's deferred test with a shipped Gitea-requiring creative thread role in both orgs. Approve independent verified bindings; boot real buses and use fake harness/service, create one thread/session per org, launch both, write/read records, reopen each. Assert distinct session/run/trail/workspace and slot identities, no A record visible in B, no B request uses A's token, and B survives A revoke/archive/delete. Reject raw A binding/grant replay in B. Operator flow shows computed scope lines, confirmation, missing service, restart and remediation states, with sentinel redaction tests on every response/event/log. No screenshot gate for this doc lane. | Console mutation/decision flow; no further right or role changes. Any discovered schema delta returns to approval before build. |
Existing regressions to retain include organization `credentials.test.mjs`,
`role.test.mjs`, `agent-copies.test.mjs`, `instance-credential-transitions.test.mjs`,
bus `credential-store.test.mjs`, `credential-verbs.test.mjs`,
`agent-copies.test.mjs`, and CLI `thread-launch.test.mjs` and
`launcher-final-race-probe.test.mjs`. Add meaningful new focused tests for
the guarantees above; merely checking the proposed JSON shape is inadequate.
Acceptance samples for this document are the seven brief requirements:
storage/writer/locks in section 3; right/rail/decision in section 4;
role ceiling and verification in section 5; lifecycle/code/text in section 6;
two-org enforcement in section 7; tests/slices/protected changes in section 8;
and at most three owner questions below. No visual samples apply.
## 9. Questions for Jason, with recommendations
1. Approve `org.hire` as the entry right while keeping every credential
binding/unbinding approval owner-only? Recommend yes: it matches the
existing hire decision model and does not grant members token authority.
2. Approve the overlay schema and CB0-CB5 security changes, including complete
service scope/resource proof as a prerequisite and restart for new
references? Recommend yes. A service without a verifier stays refused;
implement Gitea first if its verifier is ready before Vikunja's.
3. On archive/restore, discard copy credentials and require fresh bindings?
Recommend yes. It adds setup on restore, but avoids keeping dormant
usable grants and gives archive one clear revocation meaning.
These are recommendations, not approvals. The Lead takes this design to
Jason. No implementation or rollout is authorized by this document.
+124 -8
View File
@@ -9,7 +9,7 @@ import { initializeControl, processingStatus } from '../../processing-control/sr
import { NAME as HARNESS_NAME } from '../../plugins/src/kinds.mjs';
import { loadRequirements } from '../../tasks/src/requirements.mjs';
import { MERGE, LandingRefusal, readLanding } from './landing.mjs';
import { ID_PATTERN, THREAD_DEFINITIONS, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers, grantScopeOf, grantInScope } from '../../organization/src/vocabulary.mjs';
import { pluginName, ID_PATTERN, THREAD_DEFINITIONS, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers, grantScopeOf, grantInScope } from '../../organization/src/vocabulary.mjs';
// Kept closed while S1 lands; its vocabulary is the integration authority.
export const ACTIONS = Object.freeze([
@@ -193,6 +193,15 @@ const userLevel = (verb) => (VIEWER_VERBS.includes(verb) ? 'viewer' : 'collabora
export const BOARD_VERBS = Object.freeze(['inbox', 'agents', 'thread.claims', 'tasks', 'trail', 'decisions', 'credentials', 'replies', 'messages', 'state', 'usage']);
// The person's task-store writes, distinct from the agent's singular task.* actions.
export const PERSON_TASK_VERBS = Object.freeze(['tasks.create', 'tasks.edit', 'tasks.archive', 'tasks.unarchive', 'tasks.delete']);
// #1873 T1: the human's definition writes, each with its [allowed, required] argument keys. Kept here,
// not imported, as the sandbox client loads this file; runtime.mjs maps each verb to the verb module
// (organization/src/definition-verbs.mjs names the same four, and a test holds them equal).
export const DEFINITION_BUS_KEYS = Object.freeze({
'definition.import': [['revision', 'template', 'as'], ['revision', 'template']],
'definition.invent': [['revision', 'name', 'definition'], ['revision', 'name', 'definition']],
'definition.edit': [['revision', 'rev', 'name', 'definition'], ['revision', 'rev', 'name', 'definition']],
'definition.remove': [['revision', 'rev', 'name'], ['revision', 'rev', 'name']],
});
// Row 175: the organization verbs a tester may call. Its writes run every check the human's do
// and log actor `tester`; row 176 adds routes reads; limits.set, organization.set and organization.revision refuse it.
export const TESTER_ORGANIZATION_VERBS = Object.freeze(['limits', 'plugins', 'instances', 'plugins.set', 'instances.set', 'routes.set', 'routes']);
@@ -533,7 +542,7 @@ function checkBusiness(name, b) {
if (!object(a) || !Array.isArray(a.withinRole) || !Array.isArray(a.crossRole))
fail('invalid-authority');
const all = [...a.withinRole, ...a.crossRole];
if (new Set(all).size !== all.length || all.some((v) => !ACTIONS.includes(v) || GATED.has(v)))
if (new Set(all).size !== all.length || all.some((v) => (!ACTIONS.includes(v) && !pluginName(v)) || GATED.has(v)))
fail('invalid-authority');
}
if (!Object.hasOwn(b.roles, b.arbiters.technical) || !Object.hasOwn(b.roles, b.arbiters.delivery))
@@ -593,6 +602,7 @@ export class Broker {
#builtin;
#builtinBinding;
#organization;
#pluginService;
#credentialVerbs;
#proxyInit;
#proxyPage;
@@ -655,7 +665,7 @@ export class Broker {
#processing;
#taskTracker;
// credentialStatus is the runtime's metadata-only Credentials.status(); the broker never gets use().
constructor({ store, businesses, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, library = null, usage = null, limits = null, harnessOn = null, credentialVerbs = null, proxyInit = null, proxyPage = null, hostGrant = null, hostKeys = null, processing = null, testerOn = null, grantScope = null, roleFile = null, giteaGrant = null, giteaKeys = null, access = null, accessStamp = null, compactions = null, requestLifecycle = null, requirementsHost = null, workspaceRead = null, taskTracker = null }) {
constructor({ store, businesses, pluginService = null, secretCheck = () => {}, credentialStatus = () => [], organization = null, project = null, library = null, usage = null, limits = null, harnessOn = null, credentialVerbs = null, proxyInit = null, proxyPage = null, hostGrant = null, hostKeys = null, processing = null, testerOn = null, grantScope = null, roleFile = null, giteaGrant = null, giteaKeys = null, access = null, accessStamp = null, compactions = null, requestLifecycle = null, requirementsHost = null, workspaceRead = null, taskTracker = null }) {
if (!object(businesses) || !Object.keys(businesses).length) fail('invalid-business');
// Row 194 b15 (sage-2501): the function-argument checks come before anything reads the store.
if (harnessOn !== null && typeof harnessOn !== 'function') throw new TypeError('harnessOn must be a function');
@@ -678,6 +688,7 @@ export class Broker {
this.#secretCheck = secretCheck;
this.#credentialStatus = credentialStatus;
this.#organization = organization;
this.#pluginService = pluginService;
// Row 199: the runtime's credential lifecycle (credential-verbs.mjs); it holds the store, not the broker.
this.#credentialVerbs = credentialVerbs;
this.#proxyInit = proxyInit;
@@ -1220,6 +1231,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;
@@ -1534,11 +1550,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(
@@ -1852,7 +1897,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
@@ -2109,6 +2162,13 @@ export class Broker {
}
bindLaunch(record) {
const { key, session, old, body } = this.#launchProof(record);
const launchRole=this.#business(record.business).roles[record.role];
const pluginCapability=[...(launchRole.tools ?? []),...(launchRole.authority?.withinRole ?? []),...(launchRole.authority?.crossRole ?? []),...Object.keys(launchRole.credentials ?? {})].find(pluginName);
if(pluginCapability) {
if(!this.#pluginService)throw new BusError('plugin-disabled',pluginName(pluginCapability));
try { this.#pluginService.check(record.business,record.role); }
catch(e) { throw new BusError(e.code === 'plugin-disabled' ? e.code : 'invalid-request', e.code === 'plugin-disabled' ? e.plugin : undefined); }
}
if (!old) this.#processingWork();
if (!old && !this.#harnessOn(record.business, record.harness)) fail('invalid-harness');
if (!old && record.thread !== undefined && this.#archived(record.business, this.#threadRow(session, record.thread).project)) fail('project-archived');
@@ -4626,7 +4686,14 @@ export class Broker {
fail('role-end-refused');
}
#classification(s, action, target = null) {
if (!ACTIONS.includes(action)) fail('unknown-action');
if (!ACTIONS.includes(action)) {
const name=pluginName(action);
const held=this.#business(s.business).roles[s.role]?.authority;
if(!name || ![...(held?.withinRole ?? []),...(held?.crossRole ?? [])].includes(action))fail('unknown-action');
if(!this.#pluginService)throw new BusError('plugin-disabled',name);
let checked;try {checked=this.#pluginService.check(s.business,s.role);}catch{throw new BusError('plugin-disabled',name);}
if(!checked.view.registry.actions.includes(action))throw new BusError('plugin-disabled',name);
}
if (
this.#hardWait(s, action, target) ||
(action === 'role.launch' && this.#business(s.business).launch?.by !== s.role)
@@ -5873,6 +5940,15 @@ export class Broker {
}).catch(error => { throw this.#refused(s, new BusError(/^proxy-[a-z-]+$/.test(error.code ?? '') ? error.code : 'proxy-init-failed'), request); });
}
this.#secretCheck(args);
if (request.verb === 'plugin.tool') {
if(s.human || s.reader || s.tester || s.user || s.console)fail('agent-required');
keys(args,['tool','arguments'],['tool','arguments']);
this.#store.transaction(()=>this.#agent(s));
if(!this.#pluginService)throw new BusError('plugin-disabled',pluginName(args.tool) ?? undefined);
return this.#pluginService.call(s.business,s.role,args.tool,args.arguments).then(result=>{
this.#secretCheck(result);return result;
}).catch(e=>{throw this.#refused(s,new BusError(['plugin-disabled','invalid-request','not-authorized'].includes(e.code)?e.code:'adapter-failed',e.code==='plugin-disabled'?e.plugin:undefined),{verb:'plugin.tool'});});
}
if (request.verb === 'processing.halt' || request.verb === 'processing.resume')
return this.#requestProcessing(s, request.verb, structuredClone(args));
if (request.verb === 'processing.status') {
@@ -6015,7 +6091,7 @@ export class Broker {
}
if (request.verb === 'grants.scope' || request.verb === 'grants.scope.set')
return this.#grantScopeVerb(s, request.verb, structuredClone(args));
if (["limits", "limits.set", "organization.set", "organization.revision", "plugins", "plugins.set", "instances", "instances.set", "routes", "routes.set", "veto.set", "library.limits.set"].includes(request.verb)) {
if (["limits", "limits.set", "organization.set", "organization.revision", "plugins", "plugins.set", "instances", "instances.set", "routes", "routes.set", "veto.set", "library.limits.set"].includes(request.verb) || Object.hasOwn(DEFINITION_BUS_KEYS, request.verb)) {
// Row 123 (#1617, REQ-COS-1, REQ-LIMIT-1): organization.set is limits.set for a role whose
// file lists organization.change. It takes the same keys, runs the same write path and
// logs the same receipt; only the actor differs: the role definition and instance.
@@ -6070,6 +6146,13 @@ export class Broker {
else if (request.verb === "library.limits.set") {
keys(args, ["revision", "replyLimits"], ["revision", "replyLimits"]);
this.#human(s);
}
// #1873 T1: the definition writes need org.hire. Until row 193, as #mayHuman, the business's
// one human (the owner) passes; the tester is refused above and an agent here.
else if (Object.hasOwn(DEFINITION_BUS_KEYS, request.verb)) {
keys(args, ...DEFINITION_BUS_KEYS[request.verb]);
this.#human(s);
if (s.human !== this.#business(s.business).human) fail("human-required");
} else {
keys(args, ORGANIZATION_KEYS, ["revision"]);
// Row 194 (D2.4, B7): the chief can't change a kept instance's model, as that replaces it at
@@ -6110,6 +6193,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(() => {
@@ -6340,6 +6429,8 @@ export class Broker {
if ((verb === "instance.hire" || verb === "instance.fire") && e.code === "definition-not-hireable") fail(e.code);
// Row 194 (B7, webby-0094 N3): the limits writer's keep check, against the file, as well.
if (e.code === "instance-kept") fail(e.code);
// #1873 T1: the definition writer's refusals pass by code; a composition refusal stays invalid-request.
if (Object.hasOwn(DEFINITION_BUS_KEYS, verb) && /^definition-[a-z-]+$/.test(e.code ?? "")) fail(e.code);
// Row 194 (brief item 8): a fire's or a delete's revoke failed, and nothing was written.
if ((verb === "instance.fire" || verb === "instances.set") && e.code === "credential-revoke-failed") fail(e.code);
// Row 65: the route checks every write of an instance's route vars runs.
@@ -8033,7 +8124,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.
@@ -8121,7 +8222,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);
@@ -8135,9 +8242,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(
+2 -2
View File
@@ -67,7 +67,7 @@ class Failure extends Error {
// lineages, each the current, retiring and earlier ids of one agent. A daily key quota follows
// the lineage that holds its id, so it outlives rotation and re-issue. Both run only while a
// daily key quota is set and a check is switched on. Without keyLineages they refuse.
export function createLimits({ store, read, log, makeLoader = createLoader, keyForSubject = () => null,
export function createLimits({ store, read, pluginDataRoot = null, log, makeLoader = createLoader, keyForSubject = () => null,
keyLineages = () => { throw new Failure('proxy-key-refused'); } }) {
if (typeof read !== 'function' || typeof log !== 'function') throw new TypeError('createLimits needs read and log');
const loaders = new Map();
@@ -103,7 +103,7 @@ export function createLimits({ store, read, log, makeLoader = createLoader, keyF
return validLineages(value) ? value : null;
}
const loaderFor = business => {
if (!loaders.has(business)) loaders.set(business, makeLoader({ log }));
if (!loaders.has(business)) loaders.set(business, makeLoader({ log, dataRoot:pluginDataRoot }));
return loaders.get(business);
};
+2 -2
View File
@@ -4,7 +4,7 @@ import { createLoader } from '../../plugins/src/loader.mjs';
const validId = s => typeof s === 'string' && /^[a-z0-9][a-z0-9._-]{0,63}$/.test(s);
const validModel = s => typeof s === 'string' && /^[A-Za-z0-9][A-Za-z0-9._:/-]{0,199}$/.test(s);
const disabled = () => ({ status: 'disabled', observedAt: null, models: [], notices: [] });
export function createModelCatalogue({ client, settings, now = Date.now, makeLoader = createLoader, proxyLock = null }) {
export function createModelCatalogue({ client, settings, pluginDataRoot = null, now = Date.now, makeLoader = createLoader, proxyLock = null }) {
const cache = new Map(), flights = new Map(); let generation = 0, writing = 0;
function invalidate() { generation++; cache.clear(); }
function begin() { writing++; invalidate(); }
@@ -19,7 +19,7 @@ export function createModelCatalogue({ client, settings, now = Date.now, makeLoa
try {
if (off(id)) return disabled();
const names = [...settings(id)['model-provider']];
const loader = makeLoader({ log: () => {} });
const loader = makeLoader({ log: () => {}, dataRoot:pluginDataRoot });
const result = await loader.load({ 'model-provider': names });
if (result.refused.length || loader.snapshot('model-provider').length !== names.length) throw Error();
const c = client(); if (!c) throw Error();
@@ -19,6 +19,7 @@ export function busOrganization(business, resolved) {
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].tools = [...(r.limits.tools ?? [])];
out.roles[role].web = (r.limits.tools ?? []).includes('web');
// Row 210 (#1707, design D5): resolveInstance leaves host.<id> keys out of the session's
// credentials. The bus child uses them, so they come back here from the business entry.
+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);
+2 -2
View File
@@ -8,8 +8,8 @@ const KINDS = ['input', 'output', 'cacheWrite', 'cacheRead'];
const ID = /^[a-f0-9]{12}$/;
const plain = n => n <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(n) : String(n);
// makeLoader is the plug-in loader's factory; a test wraps it to see what a module is handed.
export function createProxyUsage({ store, read, client, records, log, now = Date.now, makeLoader = createLoader, enableDrain = false }) {
const loader = makeLoader({ log });
export function createProxyUsage({ store, read, pluginDataRoot = null, client, records, log, now = Date.now, makeLoader = createLoader, enableDrain = false }) {
const loader = makeLoader({ log, dataRoot:pluginDataRoot });
let due = 0, previous = '', pending = null, closed = false;
const states = new Map();
// #778: per enabled business, the batches taken off the proxy queue and then not written, since
+2
View File
@@ -8,6 +8,8 @@ const group = (codes, rail, nextStep) => {
entries.push([code, guidance]);
}
};
group("plugin-disabled", "R3/R6/R10: installed, pinned and enabled plugin capabilities", "Ask the instance owner to verify the installed plugin and the organization owner to enable it. A replacement of an already loaded package needs a host restart; then relaunch the agent.");
group("plugin-restart-required", "R3/R10: one imported package pin per host process", "Ask the instance owner to restart the host after replacing an already loaded package, then retry through the supported path.");
group(
"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",
+64 -26
View File
@@ -1,3 +1,5 @@
import { pluginService } from '../../plugins/src/tools.mjs';
import { readPluginLock } from '../../plugins/src/pinned.mjs';
import { createRequirementsHost } from '../../tasks/src/builtin.mjs';
import { createRequirementCatalog } from './requirement-catalog.mjs';
import { readPrdMaterial } from '../../project/src/prd.mjs';
@@ -33,9 +35,11 @@ import { createProxyLock } from './proxy-lock.mjs';
import { ModelKeys, modelOrganization, stopModelRuns, MODEL_STOP_TIMEOUT_MS } from './model-keys.mjs';
import { instanceSettings } from '../../organization/src/instance-settings.mjs';
import { organizationWriter, OrganizationError, setReplyLimits } from '../../organization/src/organization.mjs';
import { definitionVerbs, DEFINITION_BUS_VERBS } from '../../organization/src/definition-verbs.mjs';
import { ROLE_RIGHTS } from '../../organization/src/org-rights.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';
@@ -71,10 +75,10 @@ export const ACCESS_SWEEP_MS = 30_000;
// web.fetch still works. A plug-in that refuses to load is logged and leaves search off; it never
// stops the broker. Under harness-builtin the harness serves web itself, so the broker gets no
// service and refuses the web verbs.
export async function webServices(businesses, { log = (e) => process.stderr.write(`plugins: ${JSON.stringify(e)}\n`), deps } = {}) {
export async function webServices(businesses, { log = (e) => process.stderr.write(`plugins: ${JSON.stringify(e)}\n`), deps, dataRoot = null } = {}) {
const services = new Map();
for (const [name, b] of Object.entries(businesses)) {
const loader = createLoader({ log });
const loader = createLoader({ log, dataRoot });
await loader.load(b.plugins ?? {});
const [source] = loader.modules('search-source');
// "harness-builtin" is served by the harness itself, not the broker: no broker web verbs.
@@ -90,7 +94,7 @@ export async function webServices(businesses, { log = (e) => process.stderr.writ
// checks a write runs, which are synchronous. A module that doesn't load, or answers anything but a
// list of names, answers null, which refuses the write. The tree is pinned, so a module changes only
// with a re-pin, and a re-pin restarts the bus.
export async function harnessLevels({ log = (e) => process.stderr.write(`plugins: ${JSON.stringify(e)}\n`) } = {}) {
export async function harnessLevels({ log = (e) => process.stderr.write(`plugins: ${JSON.stringify(e)}\n`), dataRoot = null } = {}) {
const levels = new Map();
let names = [];
try {
@@ -98,8 +102,12 @@ export async function harnessLevels({ log = (e) => process.stderr.write(`plugins
} catch (e) {
log({ event: 'harness-levels-unread', message: e.message });
}
if (dataRoot !== null) {
try { names = [...new Set([...names,...Object.keys(readPluginLock(dataRoot).plugins).filter(name=>hasModule('harness',name,dataRoot))])]; }
catch { log({event:'harness-levels-unread',message:'installed harness discovery refused'}); }
}
for (const name of names) {
const loader = createLoader({ log });
const loader = createLoader({ log, dataRoot });
if ((await loader.load({ harness: [name] })).refused.length) continue;
try {
const list = loader.modules('harness')[0].levels();
@@ -284,7 +292,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, dataRoot:instanceRoot ?? dataRoot, 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');
@@ -399,20 +414,20 @@ export async function startBroker({
// vikunja:19/305: the plug-in switch through the same human-only path as the limits: the verbs
// plugins and plugins.set, logged in changes.jsonl with the actor, a missing module refused.
const plugins=organization?pluginSettings({dir:organization.dir,rolesDir:organization.rolesDir,exists:hasModule}):null;
const levels=organization?await harnessLevels():()=>null;
const plugins=organization?pluginSettings({dir:organization.dir,rolesDir:organization.rolesDir,dataRoot:instanceRoot ?? dataRoot,exists:(kind,name)=>hasModule(kind,name,instanceRoot ?? dataRoot)}):null;
const levels=organization?await harnessLevels({dataRoot:instanceRoot ?? dataRoot}):()=>null;
// Row 333 R4 (#1831): the writer's key lineage lookup, ids only, from this bus's own credential
// records. A daily key quota moved within one agent's lineage keeps its since and its count.
const keyLineage=(business,keyId)=>modelKeyLineage(credentials.status(),business,keyId);
const settings=organization?launchSettings({...organization,sessions:()=>readSessions(dataRoot),levels,keyLineage}):null;
modelCatalogues = modelCatalogue ?? (settings ? createModelCatalogue({ client: () => modelProxyClient, settings: id => settings.current(id).plugins ?? {}, proxyLock }) : null);
modelCatalogues = modelCatalogue ?? (settings ? createModelCatalogue({ pluginDataRoot:instanceRoot ?? dataRoot, client: () => modelProxyClient, settings: id => settings.current(id).plugins ?? {}, proxyLock }) : null);
// Resolve legacy table entries with the enabled modules' declarations. A
// missing or ambiguous mapping never becomes an available route.
async function catalogueFor(id, options = {}) {
const snapshot = settings.writer.read(id);
const current = settings.current(id);
const names = current.plugins?.['model-provider'] ?? [];
const loader = makeProviderLoader({log:()=>{}});
const loader = makeProviderLoader({dataRoot:instanceRoot ?? dataRoot,log:()=>{}});
const loaded = await loader.load({'model-provider': names});
const catalogue = await modelCatalogues.read(id, options);
if (loaded.refused.length || settings.writer.read(id).revision !== snapshot.revision) return {...catalogue, status:'unavailable', legacy:{}};
@@ -472,16 +487,16 @@ export async function startBroker({
return { revoked: services, outsideStore: how === 'fire' ? outside : [] };
}
// Row 165 (#1662): the human adds and changes role instances through instances and instances.set.
const proxyOrganization = modelKeys ? modelOrganization({ modelKeys, writer: organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir }) }) : null;
const proxyOrganization = modelKeys ? modelOrganization({ modelKeys, writer: organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir, dataRoot:instanceRoot ?? dataRoot }) }) : null;
const instances=organization?instanceSettings({dir:organization.dir,rolesDir:organization.rolesDir,system:organization.system,levels,revoke,
...(proxyOrganization ? { writer: proxyOrganization.writer, revoke: proxyOrganization.revoke(revoke) } : {})}):null;
const usageLog = entry => process.stderr.write(`usage: ${JSON.stringify(entry)}\n`);
const keyForRun = (business, run) => modelRuns.get(business + ':' + run)?.keyId ?? null;
// Row 333 R3 (#1831): the usage view marks key quotas that match no hired agent; limits answers.
let orphanedKeys = () => null;
const usage = await createUsage({ store, dataRoot, businesses, log: usageLog, keyForRun,
const usage = await createUsage({ store, dataRoot, pluginDataRoot:instanceRoot ?? dataRoot, businesses, log: usageLog, keyForRun,
proxyView: business => proxyUsage?.view(business) ?? null, keyOrphans: business => orphanedKeys(business) });
proxyUsage = createProxyUsage({ store, log: usageLog, client: async () => {
proxyUsage = createProxyUsage({ store, pluginDataRoot:instanceRoot ?? dataRoot, log: usageLog, client: async () => {
if (!modelKeys) return null;
await modelKeys.verifyIdentity();
return modelProxyClient;
@@ -491,7 +506,7 @@ export async function startBroker({
// Row 125 (#1619, REQ-LIMIT-3, REQ-LIMIT-4): the limit checks read the organization file at
// each ask, so a limit, a reset or a switched-on check takes effect without a restart.
const limitLog = entry => process.stderr.write(`limits: ${JSON.stringify(entry)}\n`);
const limits = createLimits({ store, read: id => settings ? settings.current(id) : businesses[id], log: limitLog,
const limits = createLimits({ store, pluginDataRoot:instanceRoot ?? dataRoot, read: id => settings ? settings.current(id) : businesses[id], log: limitLog,
keyForSubject: (business, subject) => modelUsageKey(credentials.status(), modelRuns, business, subject),
keyLineages: business => modelKeyLineages(credentials.status(), business) });
orphanedKeys = business => limits.orphanedKeys(business);
@@ -564,7 +579,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));
@@ -584,7 +599,7 @@ export async function startBroker({
// receipt-backed addition under the organization lock: the file must still be this business plus that entry,
// with the credentials, bots, libraries, roles and prior mappings the bus booted with. Anything else leaves
// the product pending. Both readiness records must exist before even the broker's reload begins.
const writer = organization ? organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir }) : null;
const writer = organization ? organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir, dataRoot:instanceRoot ?? dataRoot }) : null;
const known = new Map(Object.entries(businesses).map(([id, b]) => [id, canonicalJson(b.projects ?? {})]));
function activateManagedProject({ business: id, project, receipt, organization_revision }) {
if (!writer || !booted.has(id) || !Object.hasOwn(businesses, id)) throw new BusError('unknown-business');
@@ -595,7 +610,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 +637,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 +650,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 };
}
@@ -669,6 +700,7 @@ export async function startBroker({
trackerOf.set(id, named ?? ((trackers ? tracked : tasks !== null) ? 'vikunja' : 'store'));
}
broker = new Broker({
pluginService: pluginService({dataRoot:instanceRoot ?? dataRoot,read:id=>organization ? loadOrg(id) : businesses[id]}),
taskTracker: (id) => trackerOf.get(id),
workspaceRead: state => requestWorkspace({ dataRoot, state, events: store.all("SELECT kind,actor_role,subject,body FROM events WHERE business=? AND kind IN ('task.created','task.assigned','session.launched') ORDER BY seq LIMIT 10001", state.business) }),
requirementsHost,
@@ -737,7 +769,7 @@ export async function startBroker({
// configured reads as unreadable, so requireCitation is true), and thread.messages' `last`.
guard: (context, body, options) => libraryModule.guardReply({ ...context, dataRoot }, body, options),
replyLimits: id => libraryModule.resolveReplyLimits(organization
? organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir }).readRaw(id) : { readable: false }),
? organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir, dataRoot:instanceRoot ?? dataRoot }).readRaw(id) : { readable: false }),
last: (context, paths) => libraryModule.lastCommits({ ...context, dataRoot }, paths),
GuardError: libraryModule.GuardError,
},
@@ -753,7 +785,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));
@@ -768,7 +800,7 @@ export async function startBroker({
known.set(id, canonicalJson(next.projects));
return getProjectRemote(writer, id, args.project);
}
if (verb === 'system-types.installed') return installedSystemTypes();
if (verb === 'system-types.installed') return installedSystemTypes({dataRoot:instanceRoot ?? dataRoot});
if (verb === 'system-types.current') return settings.current(id).plugins?.['system-type'] ?? [];
if(verb==='limits')return withTokens(id,settings.read(id));
if(verb==='plugins')return {...plugins.read(id), available:{harness:levels.names ?? []}};
@@ -809,13 +841,19 @@ export async function startBroker({
});
result=settings.setRoutes(id,args,actor);
}
else if(verb==='plugins.set'){result=plugins.set(id,args,actor);modelCatalogues?.invalidate();}
else if(verb==='plugins.set') {
if(actor==='tester' && Object.entries(args.plugins ?? {}).some(([kind,value])=>(Array.isArray(value)?value:[value]).some(name=>name!==null && !hasModule(kind,name)))) throw new BusError('human-required');
result=plugins.set(id,args,actor);modelCatalogues?.invalidate();
}
// Row 194 (#1692, V6): the human's keep and noHire lists, through the instance writer.
else if(verb==='veto.set')result=instances.veto(id,args,actor);
// Row 298: the human's grant scope, through the instance writer.
else if(verb==='grants.scope.set')result=instances.grantScope(id,args,actor);
// C2-06 M11: the human's replyLimits, through the organization writer's fence.
else if(verb==='library.limits.set')result=setReplyLimits(organizationWriter({dir:organization.dir,rolesDir:organization.rolesDir}),id,args,actor);
// #1873 T1: the human's definition writes. The broker passes only the business's human (the
// owner, so ROLE_RIGHTS.owner); the store's assertClean is the held-secret check each write needs.
else if(Object.hasOwn(DEFINITION_BUS_VERBS,verb))result=definitionVerbs({writer:organizationWriter({dir:organization.dir,rolesDir:organization.rolesDir}),rolesDir:organization.rolesDir,assertClean:value=>credentials.assertClean(value)})[DEFINITION_BUS_VERBS[verb]](id,args,{actor,rights:[...ROLE_RIGHTS.owner],agent:false});
// Row 194 (D2.1, D2.2): the chief's hire and fire, with the reason in the change context.
else if(verb==='instance.hire')result=instances.hire(id,args,{actor,context});
else if(verb==='instance.fire')result=instances.fire(id,{name:args.name},{actor,context});
@@ -845,7 +883,7 @@ export async function startBroker({
return settings;
} : null,
credentialVerbs: organization
? credentialVerbs({ credentials, writer: organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir }) })
? credentialVerbs({ credentials, writer: organizationWriter({ dir: organization.dir, rolesDir: organization.rolesDir, dataRoot:instanceRoot ?? dataRoot }) })
: null,
// Row 65: a new launch binds only on a harness its business switches on, read from the file
// as it stands now; an unreadable file switches none on.
@@ -918,7 +956,7 @@ export async function startBroker({
let factory = null;
if (name === 'vikunja' && tasks) factory = tasks;
else if (name !== 'vikunja' || [...Object.values(businesses)].some((b) => b?.plugins?.['task-tracker'] === 'vikunja')) {
const loader = createLoader({ log: pluginLog });
const loader = createLoader({ dataRoot:instanceRoot ?? dataRoot, log: pluginLog });
const { refused } = await loader.load({ 'task-tracker': name });
const [module] = loader.modules('task-tracker');
if (!refused.length && module) factory = module.adapter({ trackers: trackers ?? {} });
@@ -940,7 +978,7 @@ export async function startBroker({
return one.handle(cap, verb, args);
};
}
const web = await webServices(businesses);
const web = await webServices(businesses,{dataRoot:instanceRoot ?? dataRoot});
// Row 210 (#1707, design D7 step 6): clear what a killed bus child left, before anyone can call.
if (runRoot) sweepRunDir(runRoot);
// Row 234 (r6 X3): end the enrollments a killed bus child left, before anyone can call.
@@ -1193,13 +1231,13 @@ export async function startBroker({
if (vars[prefix + 'model'] !== modelKey || vars[prefix + 'harness'] !== harness) return refused('model-provider-unavailable');
const entry = current.launch?.models?.[modelKey];
if (!entry || canonicalJson(entry) !== canonicalJson(expected ?? null) || entry.provider !== provider || entry.model !== model) return refused('model-provider-unavailable');
const harnessLoader = createLoader({log:()=>{}});
const harnessLoader = createLoader({dataRoot:instanceRoot ?? dataRoot,log:()=>{}});
if ((await harnessLoader.load({harness:[harness]})).refused.length) return refused('model-provider-unavailable');
const countsAccount = harnessLoader.modules('harness')[0].countsAccount();
if (typeof countsAccount !== 'boolean' || account !== (countsAccount ? current.launch?.account?.name ?? null : null)) return refused('model-provider-unavailable');
const names = current.plugins?.['model-provider'];
if (!Array.isArray(names) || !names.length || (entry.module && !names.includes(entry.module))) return refused('model-provider-unavailable');
const loader = makeProviderLoader({log:()=>{}}), loaded = await loader.load({'model-provider':entry.module ? [entry.module] : names});
const loader = makeProviderLoader({dataRoot:instanceRoot ?? dataRoot,log:()=>{}}), loaded = await loader.load({'model-provider':entry.module ? [entry.module] : names});
if (loaded.refused.length) return refused('model-provider-unavailable');
const selected = selectModelProvider(loader.snapshot('model-provider'), entry);
if (!selected) return refused('model-provider-unavailable');
+2 -2
View File
@@ -53,10 +53,10 @@ export function turnRecords(store, business) {
};
});
}
export async function createUsage({ store, businesses, log, keyForRun = () => null, proxyView = () => null, keyOrphans = () => null }) {
export async function createUsage({ store, businesses, pluginDataRoot = null, log, keyForRun = () => null, proxyView = () => null, keyOrphans = () => null }) {
const loaders = new Map();
for (const [id, b] of Object.entries(businesses)) {
const loader = createLoader({ log });
const loader = createLoader({ log, dataRoot:pluginDataRoot });
await loader.load(b.plugins ?? {});
loaders.set(id, loader);
}
+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);
@@ -0,0 +1,74 @@
// #1873 T1: the definition writes on the bus. Only the business's human at the cli reaches them,
// and the writer runs the credential store's held-secret check on what it would write.
import test from 'node:test';
import assert from 'node:assert/strict';
import { appendFileSync, chmodSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { DEFINITION_BUS_KEYS } from '../src/broker.mjs';
import { startBroker } from '../src/runtime.mjs';
import { DEFINITION_BUS_VERBS } from '../../organization/src/definition-verbs.mjs';
import { businessDoc, rolesCopy, systemFor, writeJson } from '../../organization/tests/helpers.mjs';
const TOKEN = 'fixture-held-token-t1-5c0e9a';
const SCOUT = { role: { tools: ['read'], network: 'none', authority: { withinRole: [], crossRole: [] }, credentials: [] }, instructions: 'Read and report.\n' };
function files(dir) {
return readdirSync(dir, { withFileTypes: true }).flatMap((e) => e.isDirectory() ? files(join(dir, e.name)) : e.isFile() ? [join(dir, e.name)] : []);
}
test('T1 the broker checks the same four definition verbs the runtime maps', () => {
assert.deepEqual(Object.keys(DEFINITION_BUS_KEYS).sort(), Object.keys(DEFINITION_BUS_VERBS).sort());
});
test('T1 definition writes: the human at the cli writes, every other caller refuses, and a held token never lands', async (t) => {
const root = mkdtempSync(join(tmpdir(), 'mosaic-def-bus-'));
const rolesDir = rolesCopy(root);
const doc = businessDoc(root, 'demo');
const file = writeJson(join(root, 'businesses/demo.json'), { ...doc, plugins: { ...doc.plugins, harness: ['pi'] } });
const data = join(root, 'data');
mkdirSync(data, { mode: 0o700 });
const token = join(root, 'held.token');
writeFileSync(token, TOKEN, { mode: 0o600 });
chmodSync(token, 0o600);
const bus = await startBroker({ dataRoot: data, businesses: { demo: { id: 'demo', human: 'jason', arbiters: { technical: 'chief', delivery: 'chief' }, vars: { 'bus.tester': true }, roles: {
chief: { definition: 'chief-of-staff', authority: { withinRole: ['decision.resolve.technical', 'decision.resolve.delivery'], crossRole: [] } },
coder: { authority: { withinRole: [], crossRole: [] }, credentials: { gitea: { file: token, rotateBy: '2099-01-01' } } },
} } }, organization: { dir: root, rolesDir, system: systemFor(root) } });
t.after(async () => { await bus.close(); rmSync(root, { recursive: true, force: true }); });
const b = bus.broker;
const human = b.bindHuman({ business: 'demo', human: 'jason', via: 'cli', outsideAgent: true });
const revision = () => b.request(human, { verb: 'limits' }).revision;
const invent = (name) => ({ verb: 'definition.invent', args: { revision: revision(), name, definition: SCOUT } });
// An agent, a reader and the tester never reach the writer.
const before = readFileSync(file);
const agent = b.bindLaunch({ business: 'demo', role: 'coder', run: 'coder-run', harness: 'pi', address: 'coder-run' });
for (const cap of [agent, b.bindReader({ business: 'demo' })]) assert.throws(() => b.request(cap, invent('scout')), /human-required/);
assert.throws(() => b.request(b.bindTester({ business: 'demo' }), invent('scout')), /tester-refused/);
// Unknown and missing arguments refuse; an actor can't be forged through them.
assert.throws(() => b.request(human, { verb: 'definition.invent', args: { ...invent('scout').args, actor: 'mallory' } }), /invalid-request/);
assert.throws(() => b.request(human, { verb: 'definition.remove', args: { revision: revision(), name: 'scout' } }), /invalid-request/);
assert.deepEqual(readFileSync(file), before);
// The human's invent commits, as the human, with a receipt that carries no role text.
const receipt = b.request(human, invent('scout'));
assert.deepEqual({ ...receipt, revision: 0, transaction: 0 }, { verb: 'invent', organization: 'demo', definition: 'scout', rev: 1, revision: 0, transaction: 0 });
assert.equal(JSON.parse(readFileSync(file, 'utf8')).definitions.scout.instructions, SCOUT.instructions);
const last = JSON.parse(readFileSync(join(root, 'businesses/.history/demo/changes.jsonl'), 'utf8').trim().split('\n').at(-1));
assert.equal(last.actor, 'jason');
assert.equal(last.state, 'committed');
// A writer refusal passes by its code.
assert.throws(() => b.request(human, invent('scout')), /definition-exists/);
// The bus refuses a request that carries the held token, and the writer refuses a template whose
// contract carries it (the import request itself holds no token). Neither writes.
const written = readFileSync(file);
assert.throws(() => b.request(human, { verb: 'definition.invent', args: { revision: revision(), name: 'leak', definition: { ...SCOUT, instructions: `Use ${TOKEN}.\n` } } }),
(e) => e.code === 'credential-leak' && !e.message.includes(TOKEN));
appendFileSync(join(rolesDir, 'coder.md'), `\nUse ${TOKEN} for the forge.\n`);
assert.throws(() => b.request(human, { verb: 'definition.import', args: { revision: revision(), template: 'coder' } }),
(e) => e.code === 'invalid-request' && !e.message.includes(TOKEN) && !String(e.stack).includes(TOKEN));
assert.deepEqual(readFileSync(file), written);
for (const path of [...files(data), ...files(join(root, 'businesses'))]) assert.ok(!readFileSync(path).includes(TOKEN), path);
});
+1 -1
View File
@@ -41,7 +41,7 @@ test('Broker keeps the sandbox client import closure: only the listed imports, n
// Row 236 (#1742, #436b) reads giteaName and parseGiteaUrl for the Gitea grant path.
// FED R3 r5 (row 274, #414) reads covers, a user's grant check, from it.
// Row 219 reads THREAD_DEFINITIONS from the same vocabulary for creative thread admission.
"import { ID_PATTERN, THREAD_DEFINITIONS, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers, grantScopeOf, grantInScope } from '../../organization/src/vocabulary.mjs';",
"import { pluginName, ID_PATTERN, THREAD_DEFINITIONS, END_PROTECTED, forkProblem, forkUrl, credentialKind, validArgv, commandAllowed, HOST_ID, CREATIVE_DEFINITIONS, unhireableAction, giteaName, parseGiteaUrl, covers, grantScopeOf, grantInScope } from '../../organization/src/vocabulary.mjs';",
]);
assert.doesNotMatch(source, /\bimport\s*\(/);
assert.doesNotMatch(readFileSync(new URL('../src/proxy-page-vocabulary.mjs', import.meta.url), 'utf8'), /\bimport\b/);
+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/config-dir-trust.mjs (rename design r2, stage B).
export * from "../../organization/src/config-dir-trust.mjs";
@@ -0,0 +1,2 @@
// Old path of packages/organization/src/definition-verbs.mjs (rename design r2, stage B).
export * from "../../organization/src/definition-verbs.mjs";
+2
View File
@@ -0,0 +1,2 @@
// Old path of packages/organization/src/definitions.mjs (rename design r2, stage B).
export * from "../../organization/src/definitions.mjs";
+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.
+4 -2
View File
@@ -154,11 +154,13 @@ function pidAlive(pid) {
}
}
// Names the lock by label: its path sits beside the access file in the config
// directory, and a refusal never quotes a host path (PR #1916).
function lockRefusal(lock) {
const pid = lockPid(lock);
if (pid !== null && pidAlive(pid)) return BUSY;
if (pid === null) return `refused: lock ${lock} is unreadable or not a pid; if no write is running, remove it by hand and try again`;
return `refused: lock ${lock} is left by pid ${pid}, which is not running; if no write is running, remove it by hand and try again`;
if (pid === null) return `refused: access file lock is unreadable or not a pid; if no write is running, remove it by hand and try again`;
return `refused: access file lock is left by pid ${pid}, which is not running; if no write is running, remove it by hand and try again`;
}
function takeLock(lock, fs) {
+5 -3
View File
@@ -37,8 +37,10 @@ function parse(args) {
function owns(user, owner, providers) {
return user.subjects.some(s => s.provider === owner.provider && s.sub === owner.sub && providers.find(p => p.id === s.provider)?.issuer === owner.iss);
}
function readOwner(root) {
const owner = readJsonFile(join(root, 'owner.json'), 'organization owner', st => {
// The registry checked `id` against the id pattern, so the label may name it; the
// root is a host path and never reaches a refusal (PR #1916).
function readOwner(root, id) {
const owner = readJsonFile(join(root, 'owner.json'), `organization owner of ${id}`, st => {
if (st.uid !== process.geteuid() || (st.mode & 0o777) !== 0o600) throw new CliError('refused: unsafe organization owner', 3);
});
rejectUnknownKeys(owner, ['provider', 'iss', 'sub'], 'organization owner');
@@ -92,7 +94,7 @@ export async function accountsCommand(args, io, deps) {
// writing owner.json, so any missing owner must block removal too.
for (const entry of Object.values((await readRegistryLocked(registry)).orgs)) {
let owner;
try { owner = readOwner(entry.root); }
try { owner = readOwner(entry.root, entry.id); }
catch (e) { if (/not found/.test(e.message)) throw new CliError(`refused: org-create-in-progress: ${entry.id} has no owner metadata`, 3); throw e; }
if (owns(user, owner, providers)) throw new CliError(`refused: user-owns-org: ${entry.id}`, 3);
}
+13 -2
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";
@@ -17,6 +18,7 @@ import { interviewCommand, INTERVIEW_USAGE, northStarCommand, NORTH_STAR_USAGE }
import { resolveThread, threadCommand, threadMembers, threadsCommand, THREAD_TALK_USAGE, THREAD_USAGE, THREADS_USAGE } from "./thread.mjs";
import { sessionCommand, SESSION_USAGE } from "./session.mjs";
import { limitsCommand, LIMITS_USAGE } from "./limits.mjs";
import { pluginInstallCommand } from "./plugin-install.mjs";
import { pluginsCommand, PLUGINS_USAGE } from "./plugins.mjs";
import { proxyCommand } from "./model-proxy.mjs";
import { credentialsCommand, CREDENTIALS_USAGE } from "./credentials.mjs";
@@ -35,6 +37,7 @@ import { bootConfig, loadSystem, orgPaths, socketPath } from "./config.mjs";
import { bootLine, readBootStatus, recordBootOk, recordBootRefused } from "./boot-status.mjs";
import { businessFilePath, configDir } from "../../organization/src/index.mjs";
import { busExit, humanTransport, refuseInsideAgent, testerTransport } from "./transport.mjs";
import { definitionsCommand } from "./org-definitions.mjs";
import { formatAgents, formatDecision, formatInbox, formatTasks, formatTrail, shortId } from "./format.mjs";
import { hostStatus, readHostState, startHost, stopHost } from "./host.mjs";
import { createLauncher } from "./launcher.mjs";
@@ -219,7 +222,7 @@ function callFor(parsed, io, deps) {
// Only the converted project/thread surface uses a channel. Management stays on callFor.
function channelCallFor(parsed, io, deps) {
const call = callFor(parsed, io, deps);
const channel = createChannelCaller({ name: "cli", call,
const channel = createChannelCaller({ name: "cli", call, dataRoot:(deps.system ?? loadSystem({env:io.env})).dataRoot,
log: entry => io.stderr.write(`mosaic: channel module refused (${entry.code})\n`),
});
return Object.assign(async (verb, args = {}) => {
@@ -614,8 +617,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") });
@@ -658,7 +663,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');
@@ -733,6 +738,8 @@ export async function main(argv, io = { env: process.env, stdin: process.stdin,
// builds; `org validate FILE` stays orgCommand's draft check.
if (verb === 'org' && rest[0] === 'config') return orgConfig(rest.slice(1), io);
if (verb === 'org' && ["instances", "instance", "veto"].includes(rest[0])) return orgInstancesCommand(rest, io, (p) => callFor(p, io, deps));
// #1873 T1: definition writes go through the organization's bus, as the human.
if (verb === 'org' && rest[0] === 'definitions') return definitionsCommand(rest.slice(1), io, { ...deps, callFor: (p) => callFor(p, io, deps) });
if (verb === 'org') return orgCommand(rest, io, deps);
// A2 (#1873): the owner's root and project agent levels. `mosaic agents` without `root` stays the bus read.
if (verb === "agents" && rest[0] === "root") return agentsRootCommand(rest.slice(1), io);
@@ -756,6 +763,10 @@ export async function main(argv, io = { env: process.env, stdin: process.stdin,
if (verb === 'accounts') return accountsCommand(rest, io, { system: deps.system ?? loadSystem({ env: io.env }), configDir: deps.accountsConfigDir });
if (verb === "limits") return limitsCommand(rest,io,p=>callFor(p,io,deps));
if (verb === "usage") return usageCommand(rest, io, p => callFor(p, io, deps));
if (verb === "plugins" && ["install","list","remove"].includes(rest[0])) {
refuseInsideAgent(io.env);
return pluginInstallCommand(rest,io,{system:deps.system ?? loadSystem({env:io.env}),dir:deps.accountsConfigDir});
}
if (verb === "plugins") return pluginsCommand(rest, io, (p) => callFor(p, io, deps));
// Row 199 (#1697): issue and rotate read the token on stdin.
if (verb === "proxy") return proxyCommand(rest, io, { system: () => deps.system ?? loadSystem({ env: io.env }), callFor: p => callFor(p, io, deps), prompt: deps.promptProxyKey });
+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 = () => {}, dataRoot = null }) {
const dir = configDir(env), roles = rolesDir(env);
loadBusiness(businessId, { dir, rolesDir: roles, dataRoot });
const { document } = organizationWriter({ dir, rolesDir: roles, dataRoot }).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, dataRoot }) };
}
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, dataRoot:system.dataRoot });
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); }
}
+6 -6
View File
@@ -46,7 +46,7 @@ function noCredentials(env) {
function ownedPrivate(path, directory) {
const st = lstatSync(path);
if (st.isSymbolicLink() || (directory ? !st.isDirectory() : !st.isFile()) || st.uid !== process.getuid() || (st.mode & 0o077))
throw refuse('fixture-state-unsafe', `${path} is not a private ${directory ? 'directory' : 'file'} of this user`);
throw refuse('fixture-state-unsafe', `${directory ? 'dev-fixture directory' : 'fixture file'} is not a private ${directory ? 'directory' : 'file'} of this user`);
}
// The recorded fixture, or null when there is none. Anything malformed refuses.
@@ -56,19 +56,19 @@ export function readFixture(dataRoot) {
catch (e) { if (e.code === 'ENOENT') return null; throw e; }
let fd;
try { fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW); }
catch (e) { if (e.code === 'ENOENT') return null; throw refuse('fixture-state-unsafe', `${file} can't be opened (${e.code})`); }
catch (e) { if (e.code === 'ENOENT') return null; throw refuse('fixture-state-unsafe', `fixture.json can't be opened (${e.code})`); }
let doc;
try {
const st = fstatSync(fd);
if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o777) !== 0o600) throw refuse('fixture-state-unsafe', `${file} is not a private file of this user`);
if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o777) !== 0o600) throw refuse('fixture-state-unsafe', 'fixture.json is not a private file of this user');
doc = JSON.parse(readFileSync(fd, 'utf8'));
} catch (e) { if (e instanceof CliError) throw e; throw refuse('fixture-state-invalid', `${file} is not JSON`); }
} catch (e) { if (e instanceof CliError) throw e; throw refuse('fixture-state-invalid', 'fixture.json is not JSON'); }
finally { closeSync(fd); }
const m = LOOPBACK.exec(doc?.endpoint ?? '');
if (!m) throw refuse('fixture-endpoint-not-loopback', `${file} names an endpoint that is not http://127.0.0.1:<port>`);
if (!m) throw refuse('fixture-endpoint-not-loopback', 'fixture.json names an endpoint that is not http://127.0.0.1:<port>');
if (doc.fixtureVersion !== 1 || doc.model !== 'deterministic' || Number(m[1]) !== doc.port || !Number.isSafeInteger(doc.pid) || doc.pid < 2
|| typeof doc.instance !== 'string' || JSON.stringify(doc.entry) !== JSON.stringify(FIXTURE_ENTRY))
throw refuse('fixture-state-invalid', `${file} is not a fixture record`);
throw refuse('fixture-state-invalid', 'fixture.json is not a fixture record');
return doc;
}
+7 -4
View File
@@ -79,12 +79,13 @@ import { spawn } from "node:child_process";
import { randomBytes } from "node:crypto";
import { closeSync, createReadStream, existsSync, lstatSync, mkdirSync, openSync, rmSync, statSync, symlinkSync, writeSync, chmodSync } from "node:fs";
import { createServer } from "node:net";
import { join } from "node:path";
import { basename, join } from "node:path";
import { GIT_GRACE_MS, GIT_TIMEOUT_MS } from "../../seat/src/checkout.mjs";
import { gitSocketPath } from "../../seat/src/session.mjs";
import { gateEnv, isMosaicStack, rootHits, runGate } from "../../seat/src/landing.mjs";
import { BUNDLE_MAX, dropLand, finishLand, FSCK, HAVE_MAX, prepareLand, publish, push, review, validBranch, workRef, WorkingError } from "../../seat/src/working.mjs";
import { CliError } from "./errors.mjs";
import { ID_PATTERN } from "../../organization/src/vocabulary.mjs";
import { withProcessing } from '../../processing-control/src/effects.mjs';
const HEADER_MAX = 4096;
@@ -385,7 +386,7 @@ export function createGitSocket({ host, business: b, dataRoot, env = process.env
if (!p) throw new Refusal("unknown-project");
mkdirSync(logs, { recursive: true, mode: 0o700 });
prepared = await prepareLand({ ...where, root: p.root, taskRef: task_ref, commit: facts.commit, dir: logs, name: id, rootHits, message: (m) => landMessage(facts, m) });
if (!(await gate.classify(p.root, env))) throw new Refusal("gate-missing", `${p.root} isn't a mosaic-stack checkout with node_modules`);
if (!(await gate.classify(p.root, env))) throw new Refusal("gate-missing", `project ${facts.project} isn't a mosaic-stack checkout with node_modules`);
symlinkSync(join(p.root, "node_modules"), join(prepared.worktree, "node_modules"));
} catch (e) {
if (prepared) await dropLand({ ...where, worktree: prepared.worktree }).catch((x) => log(`git socket: ${id}: ${x.message}`));
@@ -398,7 +399,9 @@ export function createGitSocket({ host, business: b, dataRoot, env = process.env
const r = await gate.run({ worktree: prepared.worktree, logs, env: gateEnv(env), signal: AbortSignal.any([slot.abort.signal, scope.signal]) });
scope.guard();
if (r.aborted || slot.abort.signal.aborted) throw new Refusal("host-closing", "the host closed during the gate; nothing moved");
if (!r.ok) throw new Refusal("gate-failed", `${r.step} failed, see ${r.log}`);
// The detail reaches the role, so the log is named inside the bus host directory, never by
// host path (PR #1916); its file name only when it passes the id pattern.
if (!r.ok) throw new Refusal("gate-failed", `${r.step} failed, see land/${id}${ID_PATTERN.test(basename(r.log ?? "")) ? `/${basename(r.log)}` : ""} in the bus host directory`);
await finishLand({ ...where, land: prepared, record: (m) => op({ op: "recordLand", ticket, land: { ...m, reviewed, base, patch_id } }) });
} finally {
await dropLand({ ...where, worktree: prepared.worktree }).catch((x) => log(`git socket: ${id}: ${x.message}`));
@@ -503,7 +506,7 @@ export function createGitSocket({ host, business: b, dataRoot, env = process.env
async function listen() {
if (existsSync(path)) {
if (!lstatSync(path).isSocket()) throw new CliError(`${path} exists and isn't a socket`, 3);
if (!lstatSync(path).isSocket()) throw new CliError("git socket path exists and isn't a socket", 3);
rmSync(path);
}
server = createServer((socket) => {
+4 -4
View File
@@ -54,10 +54,10 @@ function readState(file, found = () => {}) {
state = JSON.parse(readFileSync(file, "utf8"));
} catch (e) {
if (e.code === "ENOENT") return null;
throw new CliError(`bus host state is unreadable: ${file}`, 3);
throw new CliError("bus host state is unreadable", 3);
}
if (!state || !Number.isSafeInteger(state.pid) || typeof state.startTime !== "string" || typeof state.business !== "string") {
throw new CliError(`bus host state is malformed: ${file}`, 3);
throw new CliError("bus host state is malformed", 3);
}
return { ...state, live: startTimeOf(state.pid) === state.startTime };
}
@@ -94,7 +94,7 @@ export function claimHostLock(dataRoot, state, { found = () => {}, between = ()
const prior = readState(file, found);
if (!prior) continue;
if (prior.live) throw new CliError(`a bus host already runs for ${prior.business} (pid ${prior.pid})`, 3);
if (refuseStale) throw new CliError(`stale bus host lock: ${file}`, 3);
if (refuseStale) throw new CliError("stale bus host lock", 3);
between();
try {
renameSync(file, aside);
@@ -111,7 +111,7 @@ export function claimHostLock(dataRoot, state, { found = () => {}, between = ()
}
rmSync(aside, { force: true });
}
throw new CliError(`${file} kept changing; no bus host started`, 3);
throw new CliError("bus host lock kept changing; no bus host started", 3);
} finally {
rmSync(tmp, { force: true });
}
+3 -1
View File
@@ -120,7 +120,9 @@ function setArgs(parsed) {
try {
args.vars = JSON.parse(readFileSync(values["--vars"], "utf8"));
} catch (error) {
throw new CliError(`--vars ${values["--vars"]}: ${error.message}`, 4);
// Name the flag and the error kind, never the file's path or bytes (PR #1916).
const code = typeof error?.code === "string" && /^E[A-Z0-9]+$/.test(error.code) ? error.code : "unreadable";
throw new CliError(`--vars file: ${error instanceof SyntaxError ? "not valid JSON" : code}`, 4);
}
}
if (launch !== undefined) args.launch = launch;
+69 -18
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), dataRoot:system.instanceRoot ?? dataRoot, 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
@@ -353,12 +358,19 @@ export function createLauncher({
const admissionGeneration = admission().generation;
let businessRevision;
try {
const snapshot = organizationWriter({dir:configDir(env),rolesDir:rolesDir(env)}).read(host.business);
const snapshot = organizationWriter({dir:configDir(env),rolesDir:rolesDir(env),dataRoot:system.instanceRoot ?? dataRoot}).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), dataRoot:system.instanceRoot ?? dataRoot, 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`);
@@ -370,16 +382,16 @@ export function createLauncher({
throw new Refusal("legacy-run-live", `a run of ${instance} with no session is still live`);
let resolved;
try {
resolved = resolveInstance({ system: vars, business: b, instance });
resolved = resolveInstance({ system: vars, business: b, instance, enforcePlugins:true, pluginRoot:system.instanceRoot ?? dataRoot });
} catch (e) {
throw new Refusal("unsupported-harness", e.message);
throw new Refusal(e.code === "plugin-disabled" ? "plugin-disabled" : "unsupported-harness", e.code === "plugin-disabled" ? e.plugin : e.message);
}
// Resolve the business's current provider modules with a fresh loader; a refused
// load never inherits a previous business's authority.
const providerNames = b.plugins?.['model-provider'] ?? [];
const providerModules = new Map();
if (providerNames.length) {
const loader = makeLoader({ log: entry => record({ ...entry, instance }) });
const loader = makeLoader({ dataRoot:system.instanceRoot ?? dataRoot, log: entry => record({ ...entry, instance }) });
const loaded = await loader.load({ 'model-provider': providerNames });
if (loaded.refused.length) throw new Refusal('model-provider-unavailable', 'model provider module refused');
for (const { name, module } of loader.snapshot('model-provider')) providerModules.set(name, module);
@@ -400,7 +412,7 @@ export function createLauncher({
};
const primary = routeOf("primary");
const fallback = hasFallback(resolved) ? routeOf("fallback") : null;
if (!resolved.contract) throw new Refusal("no-role-contract", `role ${resolved.definition} has no contract file`);
if (!resolved.contract && typeof resolved.instructions !== "string") throw new Refusal("no-role-contract", `role ${resolved.definition} has no contract file`);
// Row 64 (#1557): every skill on the role's list is enabled and readable before a run exists.
try {
readSkills(resolved.skills, skillsDir);
@@ -415,7 +427,7 @@ export function createLauncher({
}
// One harness through a fresh loader, so a refusal never leaves an earlier load's module in use.
const harnessOf = async (m) => {
const loader = makeLoader({ log: (entry) => record({ ...entry, instance }) });
const loader = makeLoader({ dataRoot:system.instanceRoot ?? dataRoot, log: (entry) => record({ ...entry, instance }) });
const { refused } = await loader.load({ harness: [m.harness] });
if (refused.length) throw new Refusal("harness-refused", `harness ${m.harness}: ${refused.map((r) => `${r.code}: ${r.message}`).join("; ")}`);
const module = loader.modules("harness")[0];
@@ -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) {
@@ -677,7 +723,7 @@ export function createLauncher({
models: b.launch.models,
proxy: sessionProxy,
resolver,
contract: readFileSync(resolved.contract, "utf8"),
contract: resolved.contract ? readFileSync(resolved.contract, "utf8") : resolved.instructions,
arbiter: Object.values(b.arbiters).includes(instance),
tracker,
library: Object.values(b.projects ?? {}).some(project => project.kind === "library"),
@@ -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.
+10 -10
View File
@@ -162,28 +162,28 @@ export function openJournal(file, { log = () => {}, now = () => new Date() } = {
mkdirSync(dir, { recursive: true, mode: 0o700 });
} catch (e) {
// A dangling link in place of the directory fails mkdir with ENOENT.
if (e.code === "ENOENT" && lstatSync(dir, { throwIfNoEntry: false })?.isSymbolicLink()) throw new CliError(`notify journal directory must not be a symlink: ${dir}`, 3);
if ([...UNWRITABLE, "ENOTDIR"].includes(e.code)) throw new CliError(`notify journal directory cannot be created (${e.code}): ${dir}`, 3);
if (e.code === "ENOENT" && lstatSync(dir, { throwIfNoEntry: false })?.isSymbolicLink()) throw new CliError(`notify journal directory must not be a symlink`, 3);
if ([...UNWRITABLE, "ENOTDIR"].includes(e.code)) throw new CliError(`notify journal directory cannot be created (${e.code})`, 3);
if (e.code !== "EEXIST") throw e;
}
const ds = lstatSync(dir);
if (ds.isSymbolicLink()) throw new CliError(`notify journal directory must not be a symlink: ${dir}`, 3);
if (ds.isSymbolicLink()) throw new CliError(`notify journal directory must not be a symlink`, 3);
if (!ds.isDirectory() || ds.uid !== process.getuid() || (ds.mode & 0o077) !== 0) {
throw new CliError(`notify journal directory must be mode 0700 and owned by this user: ${dir}`, 3);
throw new CliError(`notify journal directory must be mode 0700 and owned by this user`, 3);
}
try {
accessSync(dir, constants.W_OK | constants.X_OK);
} catch (e) {
if (UNWRITABLE.includes(e.code)) throw new CliError(`notify journal directory is not writable (${e.code}): ${dir}`, 3);
if (UNWRITABLE.includes(e.code)) throw new CliError(`notify journal directory is not writable (${e.code})`, 3);
throw e;
}
let fd;
try {
fd = openSync(file, O_RDWR | O_APPEND | O_CREAT | O_NOFOLLOW, 0o600);
} catch (e) {
if (e.code === "ELOOP") throw new CliError(`notify journal must not be a symlink: ${file}`, 3);
if (UNWRITABLE.includes(e.code)) throw new CliError(`notify journal is not writable (${e.code}): ${file}`, 3);
if (e.code === "EISDIR") throw new CliError(`notify journal must be a regular file, mode 0600, owned by this user: ${file}`, 3);
if (e.code === "ELOOP") throw new CliError(`notify journal must not be a symlink`, 3);
if (UNWRITABLE.includes(e.code)) throw new CliError(`notify journal is not writable (${e.code})`, 3);
if (e.code === "EISDIR") throw new CliError(`notify journal must be a regular file, mode 0600, owned by this user`, 3);
throw e;
}
const sent = new Set();
@@ -204,7 +204,7 @@ export function openJournal(file, { log = () => {}, now = () => new Date() } = {
try {
const st = fstatSync(fd);
if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o777) !== 0o600) {
throw new CliError(`notify journal must be a regular file, mode 0600, owned by this user: ${file}`, 3);
throw new CliError(`notify journal must be a regular file, mode 0600, owned by this user`, 3);
}
const bytes = readFileSync(fd);
const end = bytes.lastIndexOf(0x0a) + 1;
@@ -218,7 +218,7 @@ export function openJournal(file, { log = () => {}, now = () => new Date() } = {
r = null;
}
const bad = r === null ? "json" : badField(r);
if (bad) throw new CliError(`notify journal line ${i + 1} is malformed (${bad}): ${file}`, 3);
if (bad) throw new CliError(`notify journal line ${i + 1} is malformed (${bad})`, 3);
count(r);
});
if (end < bytes.length) {
+6 -5
View File
@@ -6,27 +6,28 @@ import { CliError } from "./errors.mjs";
// The notifier's private config: `<dataRoot>/notify/<business>/notify.json`,
// 0600, `{"notifyVersion": 1, "binding": "<discord binding>" | null}`.
// Missing refuses, so a host never starts without someone deciding whether
// it DMs; null runs the host without a notifier.
// it DMs; null runs the host without a notifier. Refusals name the business
// and the path under the data root, never the host path (PR #1916).
export function readNotifyConfig(dataRoot, business) {
const file = join(dataRoot, "notify", business, "notify.json");
let st;
try {
st = lstatSync(file);
} catch {
throw new CliError(`no notifier config at ${file}; write {"notifyVersion": 1, "binding": "<discord binding>"} there, mode 0600, or "binding": null to run without DMs`, 3);
throw new CliError(`no notifier config for ${business}: write {"notifyVersion": 1, "binding": "<discord binding>"} to notify/${business}/notify.json under the data root, mode 0600, or "binding": null to run without DMs`, 3);
}
if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o777) !== 0o600) {
throw new CliError(`notifier config must be a regular file, mode 0600, owned by this user: ${file}`, 3);
throw new CliError(`notifier config for ${business} must be a regular file, mode 0600, owned by this user`, 3);
}
let doc;
try {
doc = JSON.parse(readFileSync(file, "utf8"));
} catch {
throw new CliError(`notifier config is not JSON: ${file}`, 3);
throw new CliError(`notifier config for ${business} is not JSON`, 3);
}
const keys = doc && typeof doc === "object" && !Array.isArray(doc) ? Object.keys(doc).sort().join(",") : "";
if (keys !== "binding,notifyVersion" || doc.notifyVersion !== 1 || (doc.binding !== null && !(typeof doc.binding === "string" && BINDING_NAME.test(doc.binding)))) {
throw new CliError(`notifier config must be exactly {"notifyVersion": 1, "binding": <binding name> | null}: ${file}`, 3);
throw new CliError(`notifier config for ${business} must be exactly {"notifyVersion": 1, "binding": <binding name> | null}`, 3);
}
return doc.binding;
}
+3 -1
View File
@@ -138,7 +138,9 @@ export async function busStart(ctx, id, { detach = false, devLocalOwner = false,
await new Promise(r => setTimeout(r, 50));
}
if (!exited) { child.kill('SIGTERM'); await done; }
throw new CliError(`bus failed to start; see ${join(org.paths.hostDir, 'host.log')}`, 3);
// Name the log by its place in the organization's runtime directory, never by host path (PR #1916);
// resolveOrg admitted `id` only after the registry's id check.
throw new CliError(`bus failed to start; see bus-host/host.log in the runtime directory of organization ${id}`, 3);
}
export async function busRestart(ctx, id) {
const org = await target(ctx, id);
+5 -7
View File
@@ -4,7 +4,7 @@ import { processState } from '../../seat/src/process-state.mjs';
import { spawn } from 'node:child_process';
import { request as httpRequest } from 'node:http';
import { constants, closeSync, fstatSync, lstatSync, mkdirSync, openSync, readFileSync, rmSync, renameSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { basename, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { listOrgs, resolveOrg } from '../../organization/src/org-registry.mjs';
import { cmdlineOf, startTimeOf } from './host.mjs';
@@ -95,7 +95,8 @@ async function launch(ctx, args, lan, detach) {
}
await sleep(50);
}
fail(`console failed to start; see ${log}`);
// The log is named under the data root, never by host path (PR #1916).
fail(`console failed to start; see console/${basename(log)} under the data root`);
} catch (e) {
if (child.exitCode === null && child.signalCode === null) { child.kill('SIGTERM'); await done; }
throw e;
@@ -193,11 +194,8 @@ export async function consoleStop(ctx, { warn = s => process.stderr.write(s) } =
const argv = cmdlineOf(s.pid);
if (!argv || !argv.length) fail('console process identity is uncertain');
const i = argv.indexOf(WEBUI);
if (i < 0 || argv[i + 1] !== 'serve') {
const serve = argv.indexOf('serve');
const entry = serve > 0 ? ` ${argv[serve - 1]}` : '';
fail(`console was started from another path${entry}; stop it there`);
}
// The other entry script is a host path, so the refusal never names it (PR #1916).
if (i < 0 || argv[i + 1] !== 'serve') fail('console was started from another checkout; stop it there');
process.kill(s.pid, 'SIGTERM');
const deadline = Date.now() + 20000;
while (Date.now() < deadline) {
+96
View File
@@ -0,0 +1,96 @@
// #1873 T1: `mosaic org definitions`, the human verbs on an organization's own role definitions and
// the template hash and diff reads. Reads run here, from the file. A write runs only from a human
// shell (refuseInsideAgent) and goes through the organization's bus as its human: the bus holds the
// credential store, whose held-secret check every definition write needs (R1), and no other process
// holds the tokens it looks for. Each write prints its receipt.
import { readFile } from 'node:fs/promises';
import { configDir } from '../../organization/src/organization-config.mjs';
import { organizationWriter } from '../../organization/src/organization.mjs';
import { definitionVerbs } from '../../organization/src/definition-verbs.mjs';
import { checkDefinition } from '../../organization/src/definitions.mjs';
import { rolesDir } from './config.mjs';
import { CliError } from './errors.mjs';
import { busExit, refuseInsideAgent } from './transport.mjs';
export const DEFINITIONS_USAGE = 'mosaic org definitions templates | definitions ID list | definitions ID show NAME | definitions ID diff NAME | definitions ID import TEMPLATE [--as NAME] | definitions ID invent NAME --from FILE | definitions ID edit NAME --from FILE --if-rev N | definitions ID remove NAME --if-rev N';
const WRITES = ['import', 'invent', 'edit', 'remove'];
const VALUES = ['--as', '--from', '--if-rev'];
function parse(args) {
const pos = [];
const values = {};
for (let i = 0; i < args.length; i++) {
const a = args[i];
if (VALUES.includes(a)) {
if (values[a] !== undefined || !args[i + 1] || args[i + 1].startsWith('--')) throw new CliError(`${a} needs one value`, 4);
values[a] = args[++i];
} else if (a.startsWith('--')) throw new CliError(`unknown option ${a}`, 4);
else pos.push(a);
}
return { pos, values };
}
async function definitionFile(file) {
try {
return JSON.parse(await readFile(file, 'utf8'));
} catch (e) {
// Never echo the file's text: it may hold what the composition check refuses.
throw new CliError(`cannot read definition ${file}: ${e instanceof SyntaxError ? 'not valid JSON' : e.code ?? 'unreadable'}`, 2);
}
}
// The bus answers a refusal with its code only. These say what the code means for this request;
// none quotes the definition's text.
function refusal(code, verb, name, rev, read) {
const why = {
'definition-in-use': () => `definition ${name} is named by ${read().usedBy.join(', ')}; a change to a hired agent's role is a change decision`,
'definition-rev-conflict': () => `definition ${name} is not at rev ${rev}`,
'definition-exists': () => `definitions already declares ${name}`,
'definition-missing': () => `definitions declares no ${name}`,
'revision-conflict': () => 'the organization file changed while this ran; run it again',
'credential-leak': () => `the ${verb} request holds a credential the organization's bus holds; nothing was written`,
'invalid-request': () => `the bus refused the ${verb}: the definition fails the composition check or holds a held credential`,
}[code];
let detail = null;
try { detail = why?.() ?? null; } catch { detail = null; }
const error = new CliError(detail ? `${code}: ${detail}` : code, busExit(code));
error.code = code;
return error;
}
// deps.callFor(parsed) returns the human's call(verb, args) for the organization parsed names (cli.mjs
// passes its callFor). Without one, a write refuses: it never runs without the bus's secret check.
export async function definitionsCommand(args, io, deps = {}) {
const { pos, values } = parse(args);
const allowed = (...keys) => { for (const k of Object.keys(values)) if (!keys.includes(k)) throw new CliError(`unexpected ${k}`, 4); };
const need = (key) => { if (!values[key]) throw new CliError(`${key} is required`, 4); return values[key]; };
const ifRev = () => { const r = need('--if-rev'); if (!/^[1-9][0-9]*$/.test(r)) throw new CliError('invalid --if-rev', 4); return Number(r); };
const [id, verb, name] = pos;
if (WRITES.includes(verb)) refuseInsideAgent(io.env);
const writer = deps.writer ?? organizationWriter({ dir: configDir(io.env), rolesDir: rolesDir(io.env) });
const verbs = definitionVerbs({ writer, rolesDir: rolesDir(io.env) });
let result;
if (id === 'templates' && pos.length === 1) { allowed(); result = verbs.templates(); }
else if (verb === 'list' && pos.length === 2) { allowed(); result = verbs.list(id); }
else if (['show', 'diff'].includes(verb) && pos.length === 3) { allowed(); result = verbs[verb](id, name); }
else if (WRITES.includes(verb) && pos.length === 3) {
let request, rev;
if (verb === 'import') { allowed('--as'); request = { template: name, as: values['--as'] ?? name }; }
else if (verb === 'invent') { allowed('--from'); request = { name, definition: await definitionFile(need('--from')) }; }
else if (verb === 'edit') { allowed('--from', '--if-rev'); rev = ifRev(); request = { rev, name, definition: await definitionFile(need('--from')) }; }
else { allowed('--if-rev'); rev = ifRev(); request = { rev, name }; }
// The file's own checks first, so a bad key is named here; the bus repeats them with its secret check.
if (request.definition) checkDefinition(name, { ...request.definition, rev: rev ?? 1 });
if (typeof deps.callFor !== 'function') throw new CliError('a definition write goes through the organization bus; run it as `mosaic org definitions`', 3);
const call = deps.callFor({ positional: [id], flags: new Set(), values: { '--business': id } });
const { revision } = writer.read(id);
try {
result = await call(`definition.${verb}`, { revision, ...request });
} catch (e) {
if (typeof e?.code !== 'string' || e.code === '') throw e;
throw refusal(e.code, verb, request.as ?? name, rev, () => verbs.show(id, request.as ?? name));
}
} else throw new CliError(DEFINITIONS_USAGE, 4);
io.stdout.write(JSON.stringify(result, null, 2) + '\n');
}
+14 -3
View File
@@ -9,11 +9,16 @@ import { CliError } from './errors.mjs';
import { busStopForDelete } from './org-bus.mjs';
import { consoleBusRestart } from './org-console.mjs';
import { refuseInsideAgent } from './transport.mjs';
export const ORG_USAGE = 'mosaic org system-types installed | system-types enabled ID | lock show|clear ID | reload ID | archive ID --if-revision N [--json] | restore ID --if-revision N [--json] | delete ID --confirm ID [--if-revision N] [--json] | template render ID [--fixture local] --output FILE | validate FILE | create ID --from FILE | show ID [--json] | list [--json] | update ID --from FILE --if-revision N | queue init ID (--local-only --accept-local-loss-risk | --remote URL) | queue show ID [--json] | project repo init ID PROJECT (--local-only --accept-local-loss-risk | --remote URL) | project repo show ID PROJECT [--json] | config validate ID | config resolve ID INSTANCE [--project ID]';
import { definitionsCommand, DEFINITIONS_USAGE } from './org-definitions.mjs';
export const ORG_USAGE = 'mosaic org system-types installed | system-types enabled ID | lock show|clear ID | reload ID | archive ID --if-revision N [--json] | restore ID --if-revision N [--json] | delete ID --confirm ID [--if-revision N] [--json] | template render ID [--fixture local] --output FILE | validate FILE | create ID --from FILE | show ID [--json] | list [--json] | update ID --from FILE --if-revision N | queue init ID (--local-only --accept-local-loss-risk | --remote URL) | queue show ID [--json] | project repo init ID PROJECT (--local-only --accept-local-loss-risk | --remote URL) | project repo show ID PROJECT [--json] | ' + DEFINITIONS_USAGE.slice('mosaic org '.length) + ' | config validate ID | config resolve ID INSTANCE [--project ID]';
function parse(args) { const pos = [], values = {}, flags = new Set(); for (let i = 0; i < args.length; i++) { const a = args[i]; if (['--json', '--local-only', '--accept-local-loss-risk'].includes(a)) { if (flags.has(a)) throw new CliError(`duplicate ${a}`, 4); flags.add(a); } else if (['--fixture', '--output', '--from', '--if-revision', '--remote', '--confirm'].includes(a)) { if (values[a] !== undefined || !args[i+1] || args[i+1].startsWith('--')) throw new CliError(`${a} needs one value`, 4); values[a] = args[++i]; } else if (a.startsWith('--')) throw new CliError(`unknown option ${a}`, 4); else pos.push(a); } return { pos, values, flags }; }
async function document(file) { try { return JSON.parse(await readFile(file, 'utf8')); } catch (e) { throw new CliError(`cannot read document ${file}: ${e instanceof SyntaxError ? 'not valid JSON' : e.message}`, 2); } }
// A refusal names the document by label and the error by its code, never by host path or bytes (PR #1916).
const errorCode = e => typeof e?.code === 'string' && /^E[A-Z0-9]+$/.test(e.code) ? e.code : 'unreadable';
async function document(file) { try { return JSON.parse(await readFile(file, 'utf8')); } catch (e) { throw new CliError(`cannot read organization document: ${e instanceof SyntaxError ? 'not valid JSON' : errorCode(e)}`, 2); } }
export async function orgCommand(args, io, deps = {}) {
try {
// #1873 T1: the definitions verbs take their own options.
if (args[0] === 'definitions') return await definitionsCommand(args.slice(1), io, deps);
const p = parse(args); const words = p.pos; let result; let accepted = [];
const allowed = (flags, values) => { for (const f of p.flags) if (!flags.includes(f)) throw new CliError(`unexpected ${f}`, 4); for (const f of Object.keys(p.values)) if (!values.includes(f)) throw new CliError(`unexpected ${f}`, 4); };
const need = key => { if (!p.values[key]) throw new CliError(`${key} is required`, 4); return p.values[key]; };
@@ -41,5 +46,11 @@ export async function orgCommand(args, io, deps = {}) {
else throw new CliError(ORG_USAGE, 4);
} else throw new CliError(ORG_USAGE, 4);
io.stdout.write(JSON.stringify(result, null, 2) + '\n');
} catch (e) { if (e instanceof CliError) throw e; throw new CliError(`${e.code ? e.code + ': ' : ''}${e.message}`, 2); }
} catch (e) {
if (e instanceof CliError) throw e;
// A raw Node filesystem error ("CODE: description, syscall 'path'") is named by its operation
// and code, never by the host path in its message (PR #1916).
const message = typeof e?.syscall === 'string' && typeof e.code === 'string' && e.message.startsWith(`${e.code}:`) ? `cannot ${e.syscall} (${errorCode(e)})` : e.message;
throw new CliError(`${e.code ? e.code + ': ' : ''}${message}`, 2);
}
}
+38
View File
@@ -0,0 +1,38 @@
// Instance-level shell verbs. Intentionally separate from pluginsCommand's bus transport.
import { readdirSync } from 'node:fs';
import { join } from 'node:path';
import { CliError } from './errors.mjs';
import { refuseInsideAgent } from './transport.mjs';
import { configDir } from '../../organization/src/organization-config.mjs';
import { assertAccountsOwner } from '../../organization/src/accounts.mjs';
import { withRegistry } from '../../organization/src/org-registry.mjs';
import { readJsonFile } from '../../organization/src/util.mjs';
import { installPlugin, removePlugin } from '../../plugins/src/install.mjs';
import { readPluginLock } from '../../plugins/src/pinned.mjs';
export async function pluginInstallCommand(args,io,{system,dir=configDir(io.env)}) {
refuseInsideAgent(io.env);
try {
assertAccountsOwner(dir);
const [verb,value,...rest]=args;
if(!['install','list','remove'].includes(verb))throw new CliError('mosaic plugins install SOURCE --sha256 HASH [--commit FULL_SHA] | list | remove NAME',4);
const opts={};
if(verb==='install') {
for(let i=0;i<rest.length;i+=2){if(!['--sha256','--commit'].includes(rest[i]) || !rest[i+1] || Object.hasOwn(opts,rest[i]))throw new CliError('invalid plugin install options',4);opts[rest[i]]=rest[i+1];}
if(!value || !opts['--sha256'])throw new CliError('install requires SOURCE and --sha256 HASH',4);
}else if(rest.length || (verb==='list'?value!==undefined:!value))throw new CliError('invalid plugin management arguments',4);
let result;
if(verb==='list') result=readPluginLock(system.dataRoot);
else if(verb==='install')result=installPlugin({dataRoot:system.dataRoot,source:value,commit:opts['--commit'],expectedHash:opts['--sha256'],print:line=>io.stdout.write(line+'\n')});
else result=await withRegistry({configDir:dir,instance:system.instance ?? dir.split('/').pop().replace(/^mosaic-/, '')},()=>{
// All organization files, including archived organizations, must be readable. The registry
// lock prevents a simultaneous create from disappearing from this check.
let files=[];try{files=readdirSync(join(dir,'businesses'));}catch(e){if(e.code!=='ENOENT')throw e;}
return removePlugin({dataRoot:system.dataRoot,name:value,enabled:name=>files.filter(n=>n.endsWith('.json')).some(n=>{
const doc=readJsonFile(join(dir,'businesses',n),'organization file');
return Object.values(doc.plugins ?? {}).flat().includes(name);
})});
},{write:false});
io.stdout.write(JSON.stringify(result,null,2)+'\n');
}catch(e){if(e instanceof CliError)throw e;throw new CliError(`plugin management refused: ${/^[a-z-]+$/.test(e.code ?? '')?e.code:'plugin-refused'}`,3);}
}
+1 -1
View File
@@ -7,7 +7,7 @@ import { readFileSync } from "node:fs";
import { CliError } from "./errors.mjs";
import { orgSelector } from "./org-selector.mjs";
export const PLUGINS_USAGE = "mosaic plugins [--business ID] [--json] [--tester] | set --file PATCH [--business ID] [--tester]";
export const PLUGINS_USAGE = "mosaic plugins [--business ID] [--json] [--tester] | set --file PATCH [--business ID] [--tester] | install SOURCE --sha256 HASH [--commit FULL_SHA] | list | remove NAME";
function parse(args) {
const out = { positional: [], flags: new Set(), values: {} };
+2 -2
View File
@@ -63,7 +63,7 @@ const inside = (root, path) => { const r = relative(root, path); return r !== ''
export function threadRecords(file, thread) {
const log = fileEvidence(file);
if (!log.ok && log.problem === 'missing') return { file, missing: true, launches: [], ends: [], malformed: 0 };
if (!log.ok) throw new CliError(`run-evidence-refused: launch log ${file}: ${log.problem}`, 3);
if (!log.ok) throw new CliError(`run-evidence-refused: launch log: ${log.problem}`, 3);
const launches = [], ends = []; let malformed = 0;
log.bytes.toString('utf8').split('\n').forEach((line, i) => {
if (!line) return;
@@ -186,7 +186,7 @@ export async function runCommand(rest, io, deps = {}) {
lines.push(verify ? `verified: ${verified ? 'yes' : 'no'}` : 'not verified (pass --verify)');
io.stdout.write(`${lines.join('\n')}\n`);
}
if (runs.length === 0) throw new CliError(`no launch for thread ${thread.label} in ${log.file}`, 2);
if (runs.length === 0) throw new CliError(`no launch for thread ${thread.label} in the launch log`, 2);
if (verify && !verified) throw new CliError('run evidence failed verification', 1);
return report;
}
+5 -4
View File
@@ -148,7 +148,7 @@ test("a lock left by a dead pid refuses, names the pid, and is not reclaimed", a
const dead = spawnSync(process.execPath, ["-e", ""]);
writeFileSync(`${s.file}.lock`, `${dead.pid}\n`, { mode: 0o600 });
const before = readFileSync(s.file, "utf8");
await refusal(run([...GRANT, "--revision", "1"], s.deps), new RegExp(`${s.file}\\.lock is left by pid ${dead.pid}, which is not running`));
await refusal(run([...GRANT, "--revision", "1"], s.deps), new RegExp(`^refused: access file lock is left by pid ${dead.pid}, which is not running`));
assert.equal(readFileSync(s.file, "utf8"), before);
assert.equal(readFileSync(`${s.file}.lock`, "utf8"), `${dead.pid}\n`);
assert.equal(s.receipts(), "");
@@ -165,13 +165,14 @@ test("a failed rename leaves the old bytes, removes the temp file, and writes no
assert.equal(s.receipts(), "");
});
test("a group-writable access file refuses with exit 3, names the file, and changes nothing", async (t) => {
test("a group-writable access file refuses with exit 3, names the file by label, never by path, and changes nothing", async (t) => {
const s = setup(t);
chmodSync(s.file, 0o664);
const before = readFileSync(s.file, "utf8");
const err = await refusal(run([...GRANT, "--revision", "1"], s.deps), /must not be writable by group or other/);
assert.equal(err.exitCode, 3);
assert.match(err.message, new RegExp(s.file.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
assert.equal(err.message, "refused: access file must not be writable by group or other (mode 664)");
assert.ok(!err.message.includes(s.file), err.message);
assert.equal(readFileSync(s.file, "utf8"), before);
assert.equal(s.receipts(), "");
assert.equal(existsSync(`${s.file}.lock`), false);
@@ -353,7 +354,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/],
+46 -1
View File
@@ -1,6 +1,6 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, chmodSync, writeFileSync, readFileSync, mkdirSync, existsSync, rmSync } from 'node:fs';
import { mkdtempSync, chmodSync, writeFileSync, readFileSync, mkdirSync, existsSync, rmSync, symlinkSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { accountsCommand } from '../src/accounts.mjs';
@@ -69,3 +69,48 @@ 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/);
});
// PR #1916 (org-redact5): an unreadable owner file is named by the registry's checked
// id, never by the organization root's host path.
test('remove: an invalid or linked owner file is named by the org id, never by host path', async t => {
const SENTINEL = 'sk-test-NOT-A-SECRET-1234';
const s = setup(t); await s.run(['init']); await s.run(['user', 'add', '--name', 'Pat', '--subject', 'pocket:pat', '--revision', '1']); const id = s.read().users[1].id;
const root = join(s.configDir, SENTINEL, 'broken'); mkdirSync(root, { recursive: true, mode: 0o700 });
await withRegistry(s.deps, async registry => { registry.orgs.broken = { id: 'broken', root, state: 'active', revision: 1, created: 'now' }; writeFileSync(join(root, 'owner.json'), '{not json', { mode: 0o600 }); });
const clean = (pattern) => e => { assert.equal(e.exitCode, 3); assert.match(e.message, pattern); assert.ok(!e.message.includes(SENTINEL) && !e.message.includes(root), e.message); return true; };
await assert.rejects(s.run(['user', 'remove', id, '--revision', '2']), clean(/^refused: .*organization owner of broken is not valid JSON$/));
rmSync(join(root, 'owner.json')); writeFileSync(join(root, 'real.json'), '{}', { mode: 0o600 }); symlinkSync(join(root, 'real.json'), join(root, 'owner.json'));
await assert.rejects(s.run(['user', 'remove', id, '--revision', '2']), clean(/^refused: .*organization owner of broken must be a regular, non-symbolic-link file$/));
assert.equal(s.read().users.length, 2, 'nothing was removed');
});
+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);
});
+3 -1
View File
@@ -72,7 +72,9 @@ test("an unknown key and a JSON syntax error each leave the bus down with the re
assert.equal(s.business, "acme");
assert.equal(s.file, file);
assert.match(s.reason, reason);
assert.ok(s.reason.includes(file) || /surprise/.test(s.reason));
// The reason names the file by label, never by host path (PR #1916); the path stays in `file`.
assert.ok(!s.reason.includes(file), s.reason);
assert.ok(/surprise/.test(s.reason) || /business file acme\.json is not valid JSON/.test(s.reason), s.reason);
// `bus status` shows it, in text and in JSON.
const out = run(f, "bus", "status", "acme", "--json");
assert.equal(out.status, 0, out.stderr);
@@ -0,0 +1,35 @@
// Test-copy observations only: bracket the supervisor's inner timer before
// clean_session scans the host's /proc. Keep all shipped control flow intact.
import { readProbeSeconds } from './case-p10-timing-fixture.mjs';
export { readProbeSeconds };
const BEGIN = '# row253-supervisor-begin\n';
const END = '# row253-supervisor-end\n';
const TIMER = '"$sleep_bin" "$seconds" & timer=$!\n';
const FINISH = 'finish() {\n local rc="$?"\n';
function unique(source, anchor) {
const at=source.indexOf(anchor);
if(at<0 || source.indexOf(anchor,at+anchor.length)>=0) throw new Error('inner observer anchor must occur exactly once');
return at;
}
function innerObservers(clock) {
// The literal path survives env -i in the fake sudo. It is a private scratch
// file, never a product input. Quote even paths containing shell metacharacters.
const file="'"+clock.replaceAll("'", "'\\''")+"'";
return {
begin:`# inner-observer-begin\n{ read -r timing2_up timing2_spare < /proc/uptime &&\n printf 'begin %s\\n' "$timing2_up" >> ${file}; } 2>/dev/null || :\n# inner-observer-begin-end\n`,
end:` # inner-observer-end\n { read -r timing2_up timing2_spare < /proc/uptime &&\n printf 'end %s\\n' "$timing2_up" >> ${file}; } 2>/dev/null || :\n # inner-observer-end-end\n`,
};
}
export function instrumentInnerTimer(source,clock) {
const from=unique(source,BEGIN),to=unique(source,END);
if(to<=from)throw new Error('inner observer scope is reversed');
let supervisor=source.slice(from,to);
unique(supervisor,TIMER);unique(supervisor,FINISH);
const blocks=innerObservers(clock);
supervisor=supervisor.replace(TIMER,blocks.begin+TIMER).replace(FINISH,FINISH+blocks.end);
return source.slice(0,from)+supervisor+source.slice(to);
}
export function removeInnerObservers(source,clock) {
for(const block of Object.values(innerObservers(clock))){unique(source,block);source=source.replace(block,'');}
return source;
}
@@ -0,0 +1,33 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync, writeFileSync } from 'node:fs';
import { spawnSync } from 'node:child_process';
import { join } from 'node:path';
import { tmp } from './helpers.mjs';
import { instrumentInnerTimer, removeInnerObservers, readProbeSeconds } from './case-inner-timing-fixture.mjs';
const scripts=['case1-accept.sh','case2-unit.sh'];
const read=name=>readFileSync(new URL(`../../../scripts/${name}`,import.meta.url),'utf8');
for(const name of scripts)test(`inner timing ${name}: private copy preserves shipped bytes and rejects anchor drift`,t=>{
const clock=join(tmp(t,'timer-'),'clock');
const source=read(name),copy=instrumentInnerTimer(source,clock);
assert.equal(removeInnerObservers(copy,clock),read(name));
for(const marker of ['SO_PEERCRED','MOSAIC_TESTER_SOCKET_V1','MOSAIC_LEGACY_STATUS'])
assert.equal(copy.split(marker).length,source.split(marker).length,marker);
for(const anchor of ['# row253-supervisor-begin\n','# row253-supervisor-end\n','"$sleep_bin" "$seconds" & timer=$!\n','finish() {\n local rc="$?"\n']){
assert.throws(()=>instrumentInnerTimer(source.replace(anchor,''),clock),/anchor/);
assert.throws(()=>instrumentInnerTimer(source.replace(anchor,anchor+anchor),clock),/anchor/);
}
});
for(const name of scripts)test(`inner timing ${name}: real supervisor retains streams status and EXIT cleanup`,t=>{
const clock=join(tmp(t,'timer-'),"clock '$;literal");writeFileSync(clock,'');
const source=instrumentInnerTimer(read(name),clock);
const body=source.slice(source.indexOf('# row253-supervisor-begin'),source.indexOf('# row253-supervisor-end'));
const result=spawnSync('/bin/bash',['-c',body+'\n/bin/bash -c "$TESTER_SUPERVISOR" -- 2 /usr/bin/setsid /usr/bin/sleep /bin/bash -c \'printf exact-out; printf exact-err >&2; /usr/bin/sleep 0.1; exit 7\''],{encoding:'utf8'});
assert.equal(result.status,87,result.stderr);
assert.equal(result.stdout,'exact-out');assert.equal(result.stderr,'exact-err');
assert.match(readFileSync(clock,'utf8'),/^begin [0-9]+\.[0-9]{2}\nend [0-9]+\.[0-9]{2}\n$/);
// The same strict parser used by the timing bands rejects a guard's partial,
// duplicate or reversed pair, rather than substituting wall time.
for(const invalid of ['begin 10.00\n','end 11.00\n','begin 10.00\nend 11.00\nbegin 12.00\nend 13.00\n','begin 11.00\nend 10.00\n'])
assert.throws(()=>readProbeSeconds(invalid));
});
+36 -1
View File
@@ -5,6 +5,13 @@ const LEGACY = '# row276-legacy-begin';
// Stable even when a mutant removes the outer timeout from this command.
const LAUNCH = ' LC_ALL=C ';
const WAIT = ' wait "$child" 2>/dev/null || rc=$?\n';
// org-base-reds: the root pair above also spans supervisor start-up and clean_session, a scan of
// every host /proc entry whose cost grows with the host's process count. The timer pair brackets
// only the inner timer: from just before the supervisor starts its sleep to the start of finish().
const SUPERVISOR = '# row253-supervisor-begin\n';
const SUPERVISOR_END = '# row253-supervisor-end\n';
const TIMER = '"$sleep_bin" "$seconds" & timer=$!\n';
const FINISH = 'finish() {\n local rc="$?"\n';
export const P10_OBSERVERS = Object.freeze({
begin: ` # row299-observer-begin-start
@@ -20,6 +27,26 @@ export const P10_OBSERVERS = Object.freeze({
printf 'end %s\\n' "$row299_up" >> "$ROW299_TIMING_FILE"; } 2>/dev/null || :
fi
# row299-observer-end-end
`,
// Supervisor-side pair. Only the P10 peer's arguments carry the peer-credential option name.
// The pattern is split so this block never adds that literal to the supervisor text: the fake
// sudo stalls any command whose arguments contain it, and the supervisor text is an argument of
// every capture. finish() has no arguments of its own, so its stamp follows exactly one begin
// and is written at most once.
timerBegin: `# row299-timer-begin-start
if [ -n "\${ROW299_TIMER_FILE-}" ] && [[ "$*" == *SO_PEER*CRED* ]]; then
{ read -r row299_up row299_spare < /proc/uptime &&
printf 'begin %s\\n' "$row299_up" >> "$ROW299_TIMER_FILE"; } 2>/dev/null || :
fi
# row299-timer-begin-end
`,
timerEnd: ` # row299-timer-end-start
if [ -n "\${ROW299_TIMER_FILE-}" ]; then
{ read -r row299_up row299_spare < /proc/uptime &&
[[ "$(< "$ROW299_TIMER_FILE")" == begin* && "$(< "$ROW299_TIMER_FILE")" != *end* ]] &&
printf 'end %s\\n' "$row299_up" >> "$ROW299_TIMER_FILE"; } 2>/dev/null || :
fi
# row299-timer-end-end
`,
});
@@ -40,7 +67,15 @@ export function instrumentP10Probe(source) {
uniqueIndex(capture, WAIT);
capture = capture.replace(LAUNCH, P10_OBSERVERS.begin + LAUNCH);
capture = capture.replace(WAIT, WAIT + P10_OBSERVERS.end);
return source.slice(0, start) + capture + source.slice(end);
source = source.slice(0, start) + capture + source.slice(end);
const from = uniqueIndex(source, SUPERVISOR), to = uniqueIndex(source, SUPERVISOR_END);
if (to <= from) throw new Error('P10 observer supervisor scope is reversed');
let supervisor = source.slice(from, to);
uniqueIndex(supervisor, TIMER);
uniqueIndex(supervisor, FINISH);
supervisor = supervisor.replace(TIMER, P10_OBSERVERS.timerBegin + TIMER);
supervisor = supervisor.replace(FINISH, FINISH + P10_OBSERVERS.timerEnd);
return source.slice(0, from) + supervisor + source.slice(to);
}
export function removeP10Observers(source) {
+59 -10
View File
@@ -13,6 +13,7 @@ import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { tmp } from "./helpers.mjs";
import { instrumentP10Probe, removeP10Observers, readProbeSeconds, P10_OBSERVERS } from "./case-p10-timing-fixture.mjs";
import { instrumentInnerTimer } from "./case-inner-timing-fixture.mjs";
import { CASE_SUDO_SESSION, recordedLivePids, killRecordedPids, fixtureGuard, cleanupFixture, pollFixtureGone, blockingQuerySource, LEGACY_CASES } from "./case-sudo-fixture.mjs";
const REPO = fileURLToPath(new URL("../../../", import.meta.url));
@@ -378,6 +379,9 @@ test('row299 P10 timing copy preserves shipped bytes and isolates observer varia
assert.deepEqual([...block.matchAll(/\b([A-Za-z_][A-Za-z0-9_]*)=/g)],[],'no other assignments in observer blocks');
assert.doesNotMatch(block,/\$ROOT|\$\{ROOT/,'fixture root cannot supply the clock');
}
// The fake sudo keys its faults on these argument markers; observers must not add or remove any.
for(const marker of['SO_PEERCRED','MOSAIC_TESTER_SOCKET_V1','MOSAIC_LEGACY_STATUS'])
assert.equal(copy.split(marker).length,source.split(marker).length,`observers keep the ${marker} count`);
const changedTimer=source.replace('tester_capture 2 4 128','tester_capture 1 4 128');
assert.equal(removeP10Observers(instrumentP10Probe(changedTimer)),changedTimer,'copy keeps current timer mutants');
});
@@ -402,6 +406,34 @@ capture`;
assert.match(read(clock),/^begin [0-9]+\.[0-9]{2}\nend [0-9]+\.[0-9]{2}\n$/);
});
test('row299 P10 timer observers keep supervisor status and record one P10 pair',t=>{
const dir=tmp(t,'p10-timer-'),clock=join(dir,'clock'),trapFile=join(dir,'trap');
put(clock,'',0o600);
// Same shape as TESTER_SUPERVISOR: positional arguments are the setsid command, finish() has none.
const program=`set +m
finish() {
local rc="$?"
${P10_OBSERVERS.timerEnd} printf trap > "$ROW299_TRAP_FILE"
exit "$rc"
}
trap finish EXIT
${P10_OBSERVERS.timerBegin}printf stdout-without-newline
printf stderr-without-newline >&2
exit 81`;
const supervise=(...args)=>spawnSync('/bin/bash',['-c',program,'--',...args],{encoding:'utf8',env:{...process.env,ROW299_TIMER_FILE:clock,ROW299_TRAP_FILE:trapFile}});
let r=supervise('node','-e','query');
assert.equal(r.status,81);assert.equal(read(clock),'','a supervisor without the P10 peer records nothing');
r=supervise('python','-c','getsockopt(SO_PEERCRED)');
assert.equal(r.status,81);
assert.equal(r.stdout,'stdout-without-newline');
assert.equal(r.stderr,'stderr-without-newline');
assert.equal(read(trapFile),'trap');
assert.match(read(clock),/^begin [0-9]+\.[0-9]{2}\nend [0-9]+\.[0-9]{2}\n$/);
const pair=read(clock);
r=supervise('node','-e','later');
assert.equal(r.status,81);assert.equal(read(clock),pair,'a later supervisor cannot add a second end');
});
test('row299 P10 observer records are complete and ordered',()=>{
assert.equal(readProbeSeconds('begin 100.01\nend 102.03\n'),2.02);
const invalid={
@@ -433,23 +465,33 @@ for(const fault of ['inner','outer','overflow'])test(`row253 C4 P10 ${fault} rea
if(fault==='inner')put(join(h.hc,'peer-hang'),'');
if(fault==='outer'){put(join(h.hc,'stall-probe'),'');put(join(h.hc,'stall-output'),'200 997 997\n');}
if(fault==='overflow'){put(join(h.hc,'peer-output'),'200 997 997\n'+'X'.repeat(256));put(join(h.hc,'peer-status'),'0');}
let observedScript=SCRIPT,timingFile;
let observedScript=SCRIPT,timingFile,timerFile;
if(fault==='inner'||fault==='outer'){
observedScript=join(h.dir,'case1-p10-observed.sh');timingFile=join(h.hc,'probe-uptime');
put(timingFile,'',0o600);put(observedScript,instrumentP10Probe(read(SCRIPT)),0o700);
// The outer fault stalls before the supervisor starts, so only the inner fault has a timer pair.
if(fault==='inner'){timerFile=join(h.hc,'timer-uptime');put(timerFile,'',0o600);}
put(join(h.dir,'selector.sh'),read(join(REPO,'scripts','selector.sh')),0o644);
assert.equal(removeP10Observers(read(observedScript)),read(SCRIPT),'observer reversal equals a fresh shipped script read');
}
const stop=fixtureGuard(t,files,7000,timingFile);
const start=performance.now(),r=run(h,timingFile?{ROW299_TIMING_FILE:timingFile}:{},undefined,observedScript),seconds=(performance.now()-start)/1000;capture();
const observe={...(timingFile?{ROW299_TIMING_FILE:timingFile}:{}),...(timerFile?{ROW299_TIMER_FILE:timerFile}:{})};
const start=performance.now(),r=run(h,observe,undefined,observedScript),seconds=(performance.now()-start)/1000;capture();
assert.equal(r.status,0,r.out+r.err);
const reason=fault==='inner'?'inner-timeout':fault==='outer'?'outer-timeout':'output-limit';
assert.match(r.out,new RegExp('P10 tester-socket-squat unnamed boundary=failed reason=probe-'+reason+' '));
assert.doesNotMatch(r.out,/boundary=observed/);
let probeSeconds=seconds;
let probeSeconds=seconds,timerSeconds;
if(timingFile)assert.doesNotThrow(()=>{probeSeconds=readProbeSeconds(read(timingFile));},'P10 observer pair is required');
if(fault==='inner'||fault==='outer')t.diagnostic(JSON.stringify({fault,mode,baselineSeconds,faultSeconds:seconds,probeSeconds}));
if(fault==='inner')assert.ok(probeSeconds>=1.25&&probeSeconds<2.75,`${mode}: direct probe ${probeSeconds}s; baseline ${baselineSeconds}s is diagnostic only`);
if(timerFile)assert.doesNotThrow(()=>{timerSeconds=readProbeSeconds(read(timerFile));},'P10 timer observer pair is required');
if(fault==='inner'||fault==='outer')t.diagnostic(JSON.stringify({fault,mode,baselineSeconds,faultSeconds:seconds,probeSeconds,timerSeconds}));
// org-base-reds: the band applies to the inner timer itself. The root pair adds supervisor start-up and
// clean_session's whole-host /proc scan, which grows with the host's process count, so it must contain
// the timer pair but has no band of its own; probe-inner-timeout above already places it before the outer bound.
if(fault==='inner'){
assert.ok(timerSeconds>=1.25&&timerSeconds<2.75,`${mode}: inner timer ${timerSeconds}s (root probe ${probeSeconds}s); baseline ${baselineSeconds}s is diagnostic only`);
assert.ok(probeSeconds>=timerSeconds,`${mode}: root probe ${probeSeconds}s must contain the inner timer ${timerSeconds}s`);
}
if(fault==='outer'){
assert.ok(probeSeconds>=3.25&&probeSeconds<4.75,`${mode}: direct probe ${probeSeconds}s; baseline ${baselineSeconds}s is diagnostic only`);
if(mode==='hop')assert.ok(recordedLivePids(files[0]).length>0,'stalled supervisor is R3 residual');
@@ -927,8 +969,8 @@ test("row 195 accept: the host check runs with --check and never --enforce", (t)
// Row276 exercises the real legacy launcher with fixture-only commands, never sudo.
function legacyBody() {
const source=read(SCRIPT),start=source.indexOf('# row253-supervisor-begin'),end=source.indexOf('# row276-legacy-end');
function legacyBody(source=read(SCRIPT)) {
const start=source.indexOf('# row253-supervisor-begin'),end=source.indexOf('# row276-legacy-end');
assert.ok(start>=0&&end>start,'legacy supervisor block exists');
return source.slice(start,end);
}
@@ -950,11 +992,18 @@ for(const {tag} of LEGACY_CASES)test(`row276 ${tag} legacy inner timeout cleans
const h=host(t),records=join(h.hc,'legacy-pids'),files=[join(h.hc,'session-pids'),records];
put(join(h.hc,'session-mode'),mode);
const file=join(h.dir,'blocked.mjs');put(file,blockingQuerySource(records,'signals')+'\nloadSystem();');
const capture=cleanupFixture(t,files),stop=fixtureGuard(t,files,4500);
const start=performance.now(),r=legacyCall(h,tag,[NODE,file]);capture();
const clock=join(h.dir,'inner-clock');put(clock,'',0o600);
const body=legacyBody(instrumentInnerTimer(read(SCRIPT),clock));
const capture=cleanupFixture(t,files),stop=fixtureGuard(t,files,4500,clock);
// The outer watchdog includes startup and cleanup; only the supervisor pair
// measures the inner budget. Seven seconds also lets the 4.5s guard expose a missing timer.
const start=performance.now(),r=legacyCall(h,tag,[NODE,file],{outer:7,body});capture();
assert.equal(r.status,81,r.stdout+r.stderr);
assert.equal(r.stderr,`case1-accept: ${tag} inner-timeout\n`);
assert.ok((performance.now()-start)/1000<2,mode);
const seconds=readProbeSeconds(read(clock)),wall=(performance.now()-start)/1000;
assert.ok(seconds>=0.25&&seconds<1.25,`${tag}/${mode}: inner timer ${seconds}s (wall ${wall}s)`);
assert.ok(wall>=seconds,`${mode}: the call contains its timer interval`);
t.diagnostic(`${tag}/${mode}: inner ${seconds}s; wall ${wall}s`);
assert.deepEqual(await pollFixtureGone(files),[],mode);await stop();
}
});
+16
View File
@@ -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);
});
+280 -9
View File
@@ -14,6 +14,7 @@ import { fileURLToPath } from "node:url";
import { businessDoc, systemConfig, writeJson } from "../../business/tests/helpers.mjs";
import { binds, prepareRun, sandboxArgv, sandboxProblem } from "../../seat/src/sandbox.mjs";
import { tmp } from "./helpers.mjs";
import { instrumentInnerTimer, readProbeSeconds } from "./case-inner-timing-fixture.mjs";
import { CASE_SUDO_SESSION, recordedLivePids, killRecordedPids, fixtureGuard, cleanupFixture, pollFixtureGone, blockingQuerySource } from "./case-sudo-fixture.mjs";
const REPO = fileURLToPath(new URL("../../../", import.meta.url));
@@ -66,7 +67,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`);
@@ -107,13 +108,13 @@ const libraryFlags = (h) => ["--port", "443", "--business", "acme", "--public-ho
"--allow-user", "melanie", "--proof-header", "X-Proxy-Proof", "--proof-file", h.proof];
const factoryFlags = () => ["--port", "443", "--business", "acme", "--tester-socket"];
function unit(h, { profile = "library", node = USR_NODE, copy = COPY_A, extra = [], flags, env = {}, outer = ["--business", "acme"], argv } = {}) {
function unit(h, { profile = "library", node = USR_NODE, copy = COPY_A, extra = [], flags, env = {}, outer = ["--business", "acme"], argv, script: unitScript = UNIT } = {}) {
const serve = flags ?? (profile === "factory" ? factoryFlags() : libraryFlags(h));
const args = argv ?? ["--user", "mosaic", "--home", h.home, ...outer, "--copy", copy, "--node", node, "--profile", profile, ...extra, "--", ...serve];
const r = spawnSync(UNIT, args, {
const r = spawnSync(unitScript, args, {
encoding: "utf8",
timeout: 30000,
env: { PATH: `${h.bin}:${process.env.PATH}`, CASE2_UNIT_DIR: h.units, CASE2_SYSTEMCTL: join(h.bin, "systemctl"), CASE2_GETENT: join(h.bin, "getent"), ...env },
env: { HOME: process.env.HOME, TMPDIR: process.env.TMPDIR, LANG: process.env.LANG, PATH: `${h.bin}:${process.env.PATH}`, CASE2_UNIT_DIR: h.units, CASE2_SYSTEMCTL: join(h.bin, "systemctl"), CASE2_GETENT: join(h.bin, "getent"), ...env },
});
return { code: r.status, out: r.stdout, err: r.stderr };
}
@@ -197,12 +198,27 @@ for(const kind of ['sleep','signals','member']) test(`row253 C4 ${kind} query ha
const member=join(h.root,'q ) '+String.fromCharCode(10)+'mem'),proof=join(h.root,'comm-proof');
if(kind==='member')symlinkSync('/usr/bin/sleep',member);
writeFileSync(join(copyDir(h),'packages/cli/src/config.mjs'),blockingQuerySource(queries,kind,member,proof));
const capture=cleanupFixture(t,files);const stopGuard=fixtureGuard(t,files);
const start=performance.now(),r=unit(h,{profile:'factory'}),seconds=(performance.now()-start)/1000;capture();
const clock=join(h.root,'inner-clock'),observed=join(h.root,'observed/scripts/case2-unit.sh');
mkdirSync(dirname(observed),{recursive:true});
symlinkSync(join(REPO,'scripts/selector.sh'),join(dirname(observed),'selector.sh'));
writeFileSync(clock,'',{mode:0o600});
// The 7s outer watchdog spans startup and cleanup too. Give the private copy
// cleanup room while retaining a separate band on the unchanged 5s inner timer.
const source=read(UNIT),call='query="$(tester_capture 5 7 4096';
assert.equal(source.split(call).length,2,'tester query budget anchor is unique');
script(observed,instrumentInnerTimer(source.replace(call,call.replace(' 5 7 ',' 5 15 ')),clock));
const capture=cleanupFixture(t,files);const stopGuard=fixtureGuard(t,files,9500,clock);
const start=performance.now(),r=unit(h,{profile:'factory',script:observed,env:{
CASE2_BUS_TEMPLATE:BUS_TEMPLATE,CASE2_WEBUI_TEMPLATE:WEBUI_TEMPLATE,
CASE2_PROXY_TEMPLATE:join(REPO,'packages/cli/systemd/mosaic-proxy.service.in'),
}}),wall=(performance.now()-start)/1000;capture();
refuses(r,/^case2-unit: tester config query inner-timeout\n$/);
const seconds=readProbeSeconds(read(clock));
const [queryPid,querySid]=read(queries).trim().split('\n')[0].trim().split(/\s+/).map(Number);
assert.equal(queryPid,querySid,`${kind}/${mode}: direct query PID must equal its kernel SID`);
assert.ok(seconds<7,`${kind}/${mode}: ${seconds}s exceeds outer bound`);
assert.ok(seconds>=4.25&&seconds<5.75,`${kind}/${mode}: inner timer ${seconds}s (wall ${wall}s)`);
assert.ok(wall>=seconds,`${mode}: the call contains its timer interval`);
t.diagnostic(`${kind}/${mode}: inner ${seconds}s; wall ${wall}s`);
assert.deepEqual(await pollFixtureGone(files),[],mode);
if(kind==='member')assert.ok(read(proof).includes('('+member.split('/').at(-1)+') '),'member comm with newline and closing parenthesis was observed');
assert.equal(existsSync(h.log),false,mode);await stopGuard();
@@ -364,7 +380,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 +431,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 +568,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 +724,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 +734,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 +1568,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);
});
+15 -1
View File
@@ -804,13 +804,27 @@ test("land: a failed gate is refused gate-failed with its step, told to the role
assert.equal(ticket, TICKET);
assert.equal(a.code, "gate-failed");
assert.equal(a.notify, true);
assert.match(a.detail, /^pkg-bus failed, see \/.*\/bus-host\/land\/land-[0-9a-f]{12}\/pkg-bus\.log$/);
assert.match(a.detail, /^pkg-bus failed, see land\/land-[0-9a-f]{12}\/pkg-bus\.log in the bus host directory$/);
assert.equal(w.git("rev-parse", "refs/heads/main"), w.main);
assert.equal(w.git("cat-file", "-t", r.commit), "commit", "the squash was made, and only main didn't move");
assert.deepEqual(w.of("recordLand"), []);
assert.equal(w.trees(), 0);
});
// PR #1916 (org-redact5): the detail reaches the role, so it never carries the log's host
// path; a log file name outside the id pattern is dropped too.
test("land: a failed gate's detail names the land directory, never the log's host path", async (t) => {
const SENTINEL = "sk-test-NOT-A-SECRET-1234";
let log = null;
const w = await landSetup(t, { gate: { run: async ({ logs }) => ({ ok: false, step: "pkg-bus", log: (log = join(logs, SENTINEL, `${SENTINEL}.log`)) }) } });
await w.land();
await w.ended();
const [[, a]] = w.of("landRefused");
assert.equal(a.code, "gate-failed");
assert.match(a.detail, /^pkg-bus failed, see land\/land-[0-9a-f]{12} in the bus host directory$/);
assert.ok(!a.detail.includes(SENTINEL) && !a.detail.includes(log) && !a.detail.includes(w.f.dataRoot), a.detail);
});
test("land: a record the broker refuses moves main back, and the landing is refused with the broker's code", async (t) => {
const w = await landSetup(t);
w.state.recordFails = "decision-consumed";
+8 -2
View File
@@ -315,7 +315,7 @@ test("a host.lock that goes between the check and the read is tried again; one t
assert.deepEqual(readdirSync(hostDir(dataRoot)), ["host.lock"]);
writeFileSync(hostLock(dataRoot), "{not json", { mode: 0o600 });
assert.throws(() => claimHostLock(dataRoot, mine), (e) => e.exitCode === 3 && e.message === `bus host state is unreadable: ${hostLock(dataRoot)}`);
assert.throws(() => claimHostLock(dataRoot, mine), (e) => e.exitCode === 3 && e.message === "bus host state is unreadable");
assert.equal(readFileSync(hostLock(dataRoot), "utf8"), "{not json");
assert.deepEqual(readdirSync(hostDir(dataRoot)), ["host.lock"]);
});
@@ -749,8 +749,14 @@ test("a stale organization writer lock refuses start and status without removing
});
test("organization bus start refuses without a notifier config", async t => {
// PR #1916 (org-redact5): the refusal names the log inside the organization's
// runtime directory, never by host path. (A sentinel directory in the root would
// push the bus socket path past the socket length limit, so the check is on the
// fixture's own roots.)
const f = await orgFixture(t, { notify: false });
await assert.rejects(busStart(f.ctx, "acme", { detach: true }), /failed to start/i);
await assert.rejects(busStart(f.ctx, "acme", { detach: true }), (e) => e.exitCode === 3
&& e.message === "bus failed to start; see bus-host/host.log in the runtime directory of organization acme"
&& !e.message.includes(f.dataRoot) && !e.message.includes(f.instanceRoot));
assert.match(readFileSync(join(f.dataRoot, "bus-host", "host.log"), "utf8"), /no notifier config/);
assert.equal(existsSync(join(f.dataRoot, "bus", "writer.lock")), false);
});
+9 -4
View File
@@ -21,10 +21,15 @@ function fixture(t) {
};
return { root, file, env, run, boot };
}
function redacted(result, file, status) {
// label: the CLI refusal names the file by this label and never by host path (PR #1916).
// Without it, the refusal comes from scripts/mosaic-config.mjs, which still names the file.
function redacted(result, file, status, label) {
assert.equal(result.status, status, result.stderr);
assert.equal(result.stdout, '');
assert.ok(result.stderr.includes(file), result.stderr);
if (label) {
assert.ok(!result.stderr.includes(file), result.stderr);
assert.ok(result.stderr.includes(label), result.stderr);
} else assert.ok(result.stderr.includes(file), result.stderr);
assert.match(result.stderr, /not valid JSON/);
assert.doesNotMatch(result.stderr, /sk-test|Unexpected token/);
}
@@ -40,7 +45,7 @@ test('system config parse refusal and CLI relay omit parser bytes', t => {
assert.doesNotMatch(error.message, /sk-test|Unexpected token/);
return true;
});
redacted(f.run(cli, 'org', 'config', 'validate', 'alpha'), f.file, 3);
redacted(f.run(cli, 'org', 'config', 'validate', 'alpha'), f.file, 3, 'configuration is not valid JSON; expected readable JSON: the system config MOSAIC_CONFIG names');
});
test('named instance marker parse refusal omits parser bytes', t => {
@@ -55,5 +60,5 @@ for (const operation of ['validate', 'create', 'update']) test(`org ${operation}
const file = join(f.root, 'alpha.json'); writeFileSync(file, secret);
const args = operation === 'validate' ? ['validate', file]
: [operation, 'alpha', '--from', file, ...(operation === 'update' ? ['--if-revision', '1'] : [])];
redacted(f.run(cli, 'org', ...args), file, 2);
redacted(f.run(cli, 'org', ...args), file, 2, 'mosaic: cannot read organization document: not valid JSON\n');
});
+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.match(err, /the system config MOSAIC_CONFIG names/); 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, /project file of project stack has id that does not match the declared project key/); 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.match(r.stderr, /: the system config MOSAIC_CONFIG names\n$/); 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/);
});
+8 -8
View File
@@ -358,7 +358,7 @@ test("the journal: a line with a wrong type refuses with exit 3 and names the fi
for (const [field, r] of bad) {
const text = `${JSON.stringify(dm)}\n${JSON.stringify(r)}\n`;
writeFileSync(file, text);
assert.throws(() => openJournal(file), (e) => e.exitCode === 3 && e.message === `notify journal line 2 is malformed (${field}): ${file}`, `${field}: ${JSON.stringify(r)}`);
assert.throws(() => openJournal(file), (e) => e.exitCode === 3 && e.message === `notify journal line 2 is malformed (${field})` && !e.message.includes(file), `${field}: ${JSON.stringify(r)}`);
assert.equal(readFileSync(file, "utf8"), text, "a refusal changes nothing");
}
const good = [dm, digest, { ...dm, outcome: "refused", messageId: null, status: 403 }, { ...dm, outcome: "gave-up", messageId: null }, { ...digest, outcome: "unknown", messageId: null }];
@@ -377,7 +377,7 @@ test("the journal: a symlinked directory refuses and says it is a link", (t) =>
const file = journalPath(root, "demo");
mkdirSync(dirname(dirname(file)), { mode: 0o700 });
symlinkSync(real, dirname(file));
assert.throws(() => openJournal(file), (e) => e.exitCode === 3 && e.message === `notify journal directory must not be a symlink: ${dirname(file)}`);
assert.throws(() => openJournal(file), (e) => e.exitCode === 3 && e.message === `notify journal directory must not be a symlink` && !e.message.includes(dirname(file)));
assert.deepEqual(readdirSync(real), [], "nothing was created through the link");
});
@@ -386,13 +386,13 @@ test("the journal: a dangling directory link, a parent that is a file and a jour
const dangling = journalPath(root, "demo");
mkdirSync(dirname(dirname(dangling)), { mode: 0o700 });
symlinkSync(join(root, "missing"), dirname(dangling));
assert.throws(() => openJournal(dangling), (e) => e.exitCode === 3 && e.message === `notify journal directory must not be a symlink: ${dirname(dangling)}`);
assert.throws(() => openJournal(dangling), (e) => e.exitCode === 3 && e.message === `notify journal directory must not be a symlink` && !e.message.includes(dirname(dangling)));
const under = join(root, "plain", "notify", "demo", "sent.jsonl");
writeFileSync(join(root, "plain"), "");
assert.throws(() => openJournal(under), (e) => e.exitCode === 3 && e.message === `notify journal directory cannot be created (ENOTDIR): ${dirname(under)}`);
assert.throws(() => openJournal(under), (e) => e.exitCode === 3 && e.message === `notify journal directory cannot be created (ENOTDIR)` && !e.message.includes(dirname(under)));
const asDir = journalPath(root, "acme");
mkdirSync(asDir, { recursive: true, mode: 0o700 });
assert.throws(() => openJournal(asDir), (e) => e.exitCode === 3 && e.message === `notify journal must be a regular file, mode 0600, owned by this user: ${asDir}`);
assert.throws(() => openJournal(asDir), (e) => e.exitCode === 3 && e.message === `notify journal must be a regular file, mode 0600, owned by this user` && !e.message.includes(asDir));
});
test("the journal: an append after the file was swapped for a symlink refuses and writes nothing through it", (t) => {
@@ -414,18 +414,18 @@ test("the journal: a directory it cannot write or create refuses with exit 3 and
// Modes are restored in finally: tmp()'s cleanup runs first among the after hooks.
chmodSync(dirname(file), 0o500);
try {
assert.throws(() => openJournal(file), (e) => e.exitCode === 3 && e.message === `notify journal directory is not writable (EACCES): ${dirname(file)}`);
assert.throws(() => openJournal(file), (e) => e.exitCode === 3 && e.message === `notify journal directory is not writable (EACCES)` && !e.message.includes(dirname(file)));
} finally {
chmodSync(dirname(file), 0o700);
}
openJournal(file);
chmodSync(file, 0o400);
assert.throws(() => openJournal(file), (e) => e.exitCode === 3 && e.message === `notify journal is not writable (EACCES): ${file}`);
assert.throws(() => openJournal(file), (e) => e.exitCode === 3 && e.message === `notify journal is not writable (EACCES)` && !e.message.includes(file));
const parent = join(root, "notify");
const other = journalPath(root, "acme");
chmodSync(parent, 0o500);
try {
assert.throws(() => openJournal(other), (e) => e.exitCode === 3 && e.message === `notify journal directory cannot be created (EACCES): ${dirname(other)}`);
assert.throws(() => openJournal(other), (e) => e.exitCode === 3 && e.message === `notify journal directory cannot be created (EACCES)` && !e.message.includes(dirname(other)));
} finally {
chmodSync(parent, 0o700);
}
+137
View File
@@ -0,0 +1,137 @@
// #1873 T1: org:<name> definitions at launch, and `mosaic org definitions`.
import test from 'node:test';
import assert from 'node:assert/strict';
import { join } from 'node:path';
import { readdirSync, readFileSync, writeFileSync } from 'node:fs';
import { createLauncher } from '../src/launcher.mjs';
import { main } from '../src/cli.mjs';
import { orgCommand } from '../src/org.mjs';
import { bootConfig, loadSystem } from '../src/config.mjs';
import { BusError } from '../../bus/src/broker.mjs';
import { startBroker } from '../../bus/src/runtime.mjs';
import { initializeControl } from '../../processing-control/src/state.mjs';
import { launchLogFile } from '../../seat/src/session.mjs';
import { entryFromTemplate, readTemplate, templateHash } from '../../organization/src/definitions.mjs';
import { REPO_ROLES, writeJson } from '../../organization/tests/helpers.mjs';
import { fixture, io, tmp, withoutHostCredentials } from './helpers.mjs';
const SCOUT = { rev: 1, role: { tools: ['read'], network: 'none', authority: { withinRole: [], crossRole: [] }, credentials: [] }, instructions: 'Read and report.\n' };
const stop = () => Object.assign(Error('fixture authorization stop'), { code: 'fixture-policy' });
const host = { business: 'acme', beforeClose: () => {}, op: async (x) => {
if (x.op === 'identity') return { business: 'acme', role: 'pm' };
if (x.op === 'mayLaunch') return { allow: true };
if (x.op === 'authorizeLaunch') throw stop();
return {};
} };
test('T1 an org: instance passes every launch check, and a missing definition refuses naming instance and definition', async (t) => {
const root = tmp(t);
// The org's own copy of the coder template, so the instance's credentials and tracker still match.
const builder = entryFromTemplate(readTemplate(REPO_ROLES, 'coder'));
const f = fixture(root, 'acme', (doc) => ({ ...doc, definitions: { builder }, roles: { ...doc.roles, coder: { ...doc.roles.coder, definition: 'org:builder' } } }));
const file = join(root, 'config/businesses/acme.json');
withoutHostCredentials(f.env, { piKey: true });
initializeControl(f.dataRoot);
const launcher = createLauncher({ host, system: JSON.parse(readFileSync(f.config, 'utf8')), env: f.env, bwrap: '/usr/bin/true', log: () => {} });
// Resolve, route, contract (the definition's instructions) and skills all pass; the fixture stops at authorization.
assert.equal((await launcher.fromSession('fixture-cap', 'coder')).error, 'fixture-policy');
// The chief names a definition the file doesn't declare: the whole file refuses, so a session's
// launch and the chief's launch both refuse.
writeJson(file, { ...f.doc, roles: { ...f.doc.roles, chief: { ...f.doc.roles.chief, definition: 'org:ghost' } } });
assert.deepEqual(await launcher.fromSession('fixture-cap', 'coder'), { ok: false, error: 'organization-refused' });
await assert.rejects(launcher.launchPm(), (e) => e.code === 'organization-refused' && /roles\.chief\.definition names org:ghost/.test(e.message));
const event = JSON.parse(readFileSync(launchLogFile(f.dataRoot, 'acme'), 'utf8').trim().split('\n').at(-1));
assert.equal(event.code, 'organization-refused');
assert.equal(event.instance, 'chief');
assert.match(event.why, /definition-missing: .*roles\.chief\.definition names org:ghost/);
await launcher.close?.();
// A host does not start on a file that names a missing definition.
assert.throws(() => createLauncher({ host, system: JSON.parse(readFileSync(f.config, 'utf8')), env: f.env, bwrap: '/usr/bin/true', log: () => {} }), /org:ghost/);
});
// The fixture bus holds SENTINEL as coder's gitea token, so the bus's secret check refuses it.
const SENTINEL = 'sk-test-NOT-A-SECRET-1234';
function files(dir) {
return readdirSync(dir, { withFileTypes: true }).flatMap((e) => e.isDirectory() ? files(join(dir, e.name)) : e.isFile() ? [join(dir, e.name)] : []);
}
test('T1 mosaic org definitions: writes go through the bus and its secret check, each prints its receipt, and an agent shell refuses', async (t) => {
const root = tmp(t);
const builder = entryFromTemplate(readTemplate(REPO_ROLES, 'coder'));
const f = fixture(root, 'acme', (doc) => ({ ...doc, definitions: { builder }, roles: { ...doc.roles, coder: { ...doc.roles.coder, definition: 'org:builder' } } }));
const file = join(root, 'config/businesses/acme.json');
const changes = join(root, 'config/businesses/.history/acme/changes.jsonl');
writeFileSync(f.doc.roles.coder.credentials.gitea.file, SENTINEL);
const boot = bootConfig({ system: loadSystem({ env: f.env }), businessId: 'acme', env: f.env });
const b = await startBroker({ dataRoot: f.dataRoot, businesses: boot.businesses, organization: boot.organization });
t.after(() => b.close());
const human = b.broker.bindHuman({ business: 'acme', human: f.doc.human, via: 'cli', outsideAgent: true });
const calls = [];
// The human transport's shape: a bus refusal arrives as its code.
const deps = { system: { dataRoot: f.dataRoot }, transport: () => async (verb, args = {}) => {
calls.push(verb);
try { return structuredClone(b.broker.request(human, { verb, args })); }
catch (e) { if (!(e instanceof BusError)) throw e; throw Object.assign(new Error(e.code), { code: e.code }); }
} };
const run = async (args, env = f.env) => { const x = io(env); await main(['org', 'definitions', ...args], x, deps); return JSON.parse(x.out.text); };
const hash = templateHash(f.roles, 'coder');
assert.ok((await run(['templates'])).some((h) => h.template === 'coder' && h.sha256 === hash));
const imported = await run(['acme', 'import', 'coder', '--as', 'copy']);
assert.deepEqual({ ...imported, revision: 0, transaction: 0 }, { verb: 'import', organization: 'acme', definition: 'copy', rev: 1, revision: 0, transaction: 0, from: { template: 'coder', sha256: hash } });
const spec = join(root, 'scout.json');
writeFileSync(spec, JSON.stringify({ role: SCOUT.role, instructions: SCOUT.instructions }));
assert.equal((await run(['acme', 'invent', 'scout', '--from', spec])).rev, 1);
writeFileSync(spec, JSON.stringify({ role: SCOUT.role, instructions: 'Read, then report.\n' }));
const edited = await run(['acme', 'edit', 'scout', '--from', spec, '--if-rev', '1']);
assert.equal(edited.verb, 'edit');
assert.equal(edited.rev, 2);
assert.deepEqual((await run(['acme', 'list'])).definitions.map((d) => [d.name, d.rev]), [['builder', 1], ['copy', 1], ['scout', 2]]);
assert.equal((await run(['acme', 'show', 'scout'])).instructions, 'Read, then report.\n');
assert.equal((await run(['acme', 'diff', 'copy'])).changed, false);
assert.equal((await run(['acme', 'remove', 'scout', '--if-rev', '2'])).removedRev, 2);
assert.deepEqual(calls, ['definition.import', 'definition.invent', 'definition.edit', 'definition.remove']);
const committed = readFileSync(changes, 'utf8').trim().split('\n').map((l) => JSON.parse(l)).filter((e) => e.state === 'committed').slice(-4);
assert.deepEqual(committed.map((e) => e.actor), Array(4).fill(f.doc.human));
// A definition holding a token the bus holds refuses on invent and on edit, in valid JSON, and
// the token reaches no message, no organization file and nothing the bus wrote.
const before = readFileSync(file);
const beforeChanges = readFileSync(changes);
writeFileSync(spec, JSON.stringify({ role: SCOUT.role, instructions: `Use ${SENTINEL} to report.\n` }));
for (const args of [['acme', 'invent', 'leak', '--from', spec], ['acme', 'edit', 'copy', '--from', spec, '--if-rev', '1']]) {
await assert.rejects(run(args), (e) => e.code === 'credential-leak' && e.exitCode === 2 && !e.message.includes(SENTINEL) && !String(e.stack).includes(SENTINEL));
}
assert.deepEqual(readFileSync(file), before);
assert.deepEqual(readFileSync(changes), beforeChanges);
for (const path of files(f.dataRoot)) assert.ok(!readFileSync(path).includes(SENTINEL), path);
// An unparsable file never echoes; a key the file's checks refuse is named before the bus is
// called; a stale --if-rev and an in-use definition refuse with what they mean.
writeFileSync(spec, `{"role": ${SENTINEL}`);
await assert.rejects(run(['acme', 'invent', 'other', '--from', spec]), (e) => e.exitCode === 2 && !e.message.includes('sk-test'));
writeFileSync(spec, JSON.stringify({ role: { ...SCOUT.role, network: 'everywhere' }, instructions: 'x\n' }));
const asked = calls.length;
await assert.rejects(run(['acme', 'invent', 'other', '--from', spec]), /definitions\.other/);
assert.equal(calls.length, asked);
await assert.rejects(run(['acme', 'remove', 'copy', '--if-rev', '7']), (e) => /^definition-rev-conflict: definition copy is not at rev 7/.test(e.message));
await assert.rejects(run(['acme', 'remove', 'builder', '--if-rev', '1']), (e) => /^definition-in-use: definition builder is named by coder/.test(e.message));
assert.deepEqual(readFileSync(file), before);
// Inside an agent run every write refuses before it reads anything or calls the bus; reads still work.
const agent = { ...f.env, MOSAIC_RUN_ID: 'run-1' };
const sent = calls.length;
for (const args of [['acme', 'import', 'reviewer'], ['acme', 'invent', 'x', '--from', spec], ['acme', 'edit', 'copy', '--from', spec, '--if-rev', '1'], ['acme', 'remove', 'copy', '--if-rev', '1']]) {
await assert.rejects(run(args, agent), (e) => e.exitCode === 3 && /human shell/.test(e.message));
}
assert.equal(calls.length, sent);
assert.deepEqual(readFileSync(file), before);
assert.equal((await run(['acme', 'list'], agent)).definitions.length, 2);
// With no bus call to make, a write refuses rather than writing without the secret check.
const x = io(f.env);
await assert.rejects(orgCommand(['definitions', 'acme', 'remove', 'copy', '--if-rev', '1'], x), (e) => e.exitCode === 3 && /organization bus/.test(e.message));
assert.deepEqual(readFileSync(file), before);
});
+5 -3
View File
@@ -40,8 +40,10 @@ test('console stop quarantines dead and wrong-process states without signaling',
});
test('live console from another entry path remains recorded and is not signalled', { timeout: 30000 }, async t => {
const f = await fixture(), dir = join(f.ctx.dataRoot, 'console'), alias = join(f.root, 'console.mjs');
mkdirSync(dir, { mode: 0o700 });
// The other entry sits under the sentinel directory: the refusal must name neither (PR #1916).
const SENTINEL = 'sk-test-NOT-A-SECRET-1234';
const f = await fixture(), dir = join(f.ctx.dataRoot, 'console'), alias = join(f.root, SENTINEL, 'console.mjs');
mkdirSync(dir, { mode: 0o700 }); mkdirSync(join(f.root, SENTINEL), { mode: 0o700 });
symlinkSync(new URL('../../webui/src/cli.mjs', import.meta.url).pathname, alias);
const child = spawn(process.execPath, [alias, 'serve', '--port', '0'], { env: humanEnv({ HOME: f.root, MOSAIC_CONFIG: join(f.ctx.configDir, 'config.json') }), stdio: ['ignore', 'pipe', 'pipe'] });
t.after(async () => { if (child.exitCode === null && child.signalCode === null) { const ended = once(child, 'exit'); child.kill('SIGTERM'); await ended; } });
@@ -53,7 +55,7 @@ test('live console from another entry path remains recorded and is not signalled
const record = { pid: child.pid, startTime: startTimeOf(child.pid), url };
json(join(dir, 'console.json'), record);
await assert.rejects(consoleStart(f.ctx), /console state exists/);
await assert.rejects(consoleStop(f.ctx), e => /another path/.test(e.message) && e.message.includes(alias));
await assert.rejects(consoleStop(f.ctx), e => { assert.equal(e.exitCode, 3); assert.equal(e.message, 'console was started from another checkout; stop it there'); assert.ok(!e.message.includes(SENTINEL) && !e.message.includes(alias), e.message); return true; });
assert.deepEqual(JSON.parse(readFileSync(join(dir, 'console.json'), 'utf8')), record);
assert.equal(startTimeOf(child.pid), record.startTime);
assert.equal(readdirSync(dir).filter(x => x.startsWith('console.json.stale-')).length, 0);
+4 -2
View File
@@ -38,10 +38,12 @@ const IMPORT=new RegExp([
`${DYNAMIC.source}\\s*['"](\\.{1,2}\\/[^'"]+)['"]\\s*\\)`,
].join('|'),'g');
const DYNAMIC_LITERAL=/^import\s*\(\s*(['"])[^'"\n]*\1\s*\)/;
// The plug-in loader's one computed import, allowed only in that file and only in this exact form. It loads
// The plug-in loader's computed imports, allowed only in that file and these exact forms. Core loads
// packages/plugins/<kind>/<name>/index.mjs after checking that path, and a real copy ships all of
// packages/plugins, so a fixture that never loads a plug-in needs nothing more from it.
const LOADER_FILE='packages/plugins/src/loader.mjs',LOADER_IMPORT=/^import\(pathToFileURL\(expected\)\.href\)/;
// PL1 adds the checked instance package entry with its verified hash as a cache key. Installed
// bytes belong to the instance, not to a release fixture's import closure.
const LOADER_FILE='packages/plugins/src/loader.mjs',LOADER_IMPORT=/^import\(pathToFileURL\(expected\)\.href\)|^import\(pathToFileURL\(entry\)\.href \+ '\?pin=' \+ checked\.manifest\.sha256\)/;
const REGEX_AFTER='(,=:[!&|?{};+-*%<>~^';
const inert=(literals,index)=>literals.some(([from,to])=>index>=from&&index<to);
const sources=(dir)=>readdirSync(dir,{withFileTypes:true}).flatMap((e)=>e.isDirectory()?sources(join(dir,e.name)):e.name.endsWith('.mjs')?[join(dir,e.name)]:[]);
@@ -43,6 +43,14 @@ test('the loader exception holds only for the loader file and its exact computed
assert.throws(()=>organizationImportedFiles(tree({[`${ORG}a.mjs`]:"import '../../plugins/src/loader.mjs';\n",'repo/packages/plugins/src/loader.mjs':"await import(x);\n"})),/not a plain string literal/);
});
test('PL1 pinned import exception holds only for the checked loader entry and exact hash cache key',()=>{
const computed="await import(pathToFileURL(entry).href + '?pin=' + checked.manifest.sha256);\n";
const at=(file,text=computed)=>tree({[`${ORG}a.mjs`]:`import '../../plugins/src/${file}';\n`,[`repo/packages/plugins/src/${file}`]:text});
assert.deepEqual(organizationImportedFiles(at('loader.mjs')),['packages/plugins/src/loader.mjs']);
assert.throws(()=>organizationImportedFiles(at('other.mjs')),/not a plain string literal/);
for(const changed of [computed.replace('entry','arbitrary'),computed.replace('?pin=','?unchecked='),computed.replace('checked.manifest.sha256','input')])assert.throws(()=>organizationImportedFiles(at('loader.mjs',changed)),/not a plain string literal/);
});
test('every static form and a literal dynamic import is found, and the imports of what it finds in turn', ()=>{
const repo=withOrg([
"import x from '../../other/src/from.mjs';",
+312
View File
@@ -0,0 +1,312 @@
// PR #1916 redaction (org-redact5): a CLI refusal names the file by its label,
// never by host path. Every fixture here sits under a directory named for the
// sentinel, so a refusal that quoted the path would carry it.
import { test } from "node:test";
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { createServer } from "node:net";
import { chmodSync, mkdirSync, mkdtempSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { dirname, join } from "node:path";
import { gitSocketPath } from "../../seat/src/session.mjs";
import { withLock } from "../src/access.mjs";
import { fixtureFile, readFixture } from "../src/dev-fixture.mjs";
import { createGitSocket } from "../src/git-socket.mjs";
import { consoleStart, consoleStop } from "../src/org-console.mjs";
import { claimHostLock, hostLock } from "../src/host.mjs";
import { journalPath, openJournal } from "../src/notifier.mjs";
import { readNotifyConfig } from "../src/notify-config.mjs";
import { orgCommand } from "../src/org.mjs";
import { instancesCommand } from "../src/instances.mjs";
import { FIXTURE_ENTRY } from "../../plugins/model-provider/fixture/index.mjs";
const SENTINEL = "sk-test-NOT-A-SECRET-1234";
const ROOT_SKIP = process.getuid() === 0 && "root ignores the modes";
function under(t) {
const top = mkdtempSync(join(tmpdir(), "redact-"));
t.after(() => rmSync(top, { recursive: true, force: true }));
const root = join(top, SENTINEL);
mkdirSync(root, { mode: 0o700 });
return root;
}
// The refusal is exactly `message` (a string) or matches it (a RegExp), with exit
// code `exitCode`, and carries neither the sentinel nor any of `paths`.
function refusal(message, { exitCode = 3, paths = [], code } = {}) {
return (error) => {
assert.equal(error.name, "CliError");
assert.equal(error.exitCode, exitCode);
if (code !== undefined) assert.equal(error.code, code);
if (message instanceof RegExp) assert.match(error.message, message);
else assert.equal(error.message, message);
assert.ok(!error.message.includes(SENTINEL), error.message);
for (const path of paths) assert.ok(!error.message.includes(path), error.message);
return true;
};
}
// --- access.json lock ---
test("access lock: an unreadable lock is named by label, never by host path", (t) => {
const file = join(under(t), "access.json");
writeFileSync(`${file}.lock`, "not-a-pid\n", { mode: 0o600 });
assert.throws(() => withLock(file, () => assert.fail("must refuse")),
refusal("refused: access file lock is unreadable or not a pid; if no write is running, remove it by hand and try again", { paths: [file] }));
});
test("access lock: a lock left by a dead pid is named by label, never by host path", (t) => {
const file = join(under(t), "access.json");
const dead = spawnSync(process.execPath, ["-e", ""]);
writeFileSync(`${file}.lock`, `${dead.pid}\n`, { mode: 0o600 });
assert.throws(() => withLock(file, () => assert.fail("must refuse")),
refusal(`refused: access file lock is left by pid ${dead.pid}, which is not running; if no write is running, remove it by hand and try again`, { paths: [file] }));
});
// --- bus host lock ---
const MINE = { pid: process.pid, startTime: "1", business: "acme", startedAt: "2026-11-10T00:00:00.000Z" };
const STALE = { pid: 999999999, startTime: "1", business: "acme", startedAt: "2026-11-09T00:00:00.000Z" };
function hostRoot(t, lock) {
const dataRoot = join(under(t), "data");
mkdirSync(join(dirname(hostLock(dataRoot))), { recursive: true, mode: 0o700 });
if (lock !== undefined) writeFileSync(hostLock(dataRoot), lock, { mode: 0o600 });
return dataRoot;
}
test("host lock: an unreadable host.lock refuses by label, never by host path", (t) => {
const dataRoot = hostRoot(t, "{not json");
assert.throws(() => claimHostLock(dataRoot, MINE), refusal("bus host state is unreadable", { paths: [dataRoot] }));
});
test("host lock: a malformed host.lock refuses by label, never by host path", (t) => {
const dataRoot = hostRoot(t, JSON.stringify({ pid: "x" }));
assert.throws(() => claimHostLock(dataRoot, MINE), refusal("bus host state is malformed", { paths: [dataRoot] }));
});
test("host lock: a stale host.lock refused on request is named by label, never by host path", (t) => {
const dataRoot = hostRoot(t, JSON.stringify(STALE));
assert.throws(() => claimHostLock(dataRoot, MINE, { refuseStale: true }), refusal("stale bus host lock", { paths: [dataRoot] }));
});
test("host lock: a lock that keeps changing is named by label, never by host path", (t) => {
const dataRoot = hostRoot(t, JSON.stringify(STALE));
let n = 0;
const race = () => writeFileSync(hostLock(dataRoot), JSON.stringify({ ...STALE, startedAt: `2026-11-09T00:00:${String(++n % 60).padStart(2, "0")}.000Z` }), { mode: 0o600 });
assert.throws(() => claimHostLock(dataRoot, MINE, { between: race }), refusal("bus host lock kept changing; no bus host started", { paths: [dataRoot] }));
});
// --- notify journal ---
test("notify journal: a directory link, dangling or not, is refused by label", (t) => {
const root = under(t);
const dangling = journalPath(root, "demo");
mkdirSync(dirname(dirname(dangling)), { recursive: true, mode: 0o700 });
symlinkSync(join(root, "missing"), dirname(dangling));
assert.throws(() => openJournal(dangling), refusal("notify journal directory must not be a symlink", { paths: [dangling] }));
const real = join(root, "real");
mkdirSync(real, { mode: 0o700 });
const linked = journalPath(root, "acme");
symlinkSync(real, dirname(linked));
assert.throws(() => openJournal(linked), refusal("notify journal directory must not be a symlink", { paths: [linked] }));
});
test("notify journal: a parent that is a file is refused by its error code", (t) => {
const root = under(t);
writeFileSync(join(root, "plain"), "");
const file = join(root, "plain", "notify", "demo", "sent.jsonl");
assert.throws(() => openJournal(file), refusal("notify journal directory cannot be created (ENOTDIR)", { paths: [file] }));
});
test("notify journal: a directory of the wrong mode is refused by label", (t) => {
const file = journalPath(under(t), "demo");
mkdirSync(dirname(file), { recursive: true, mode: 0o755 });
chmodSync(dirname(file), 0o755);
assert.throws(() => openJournal(file), refusal("notify journal directory must be mode 0700 and owned by this user", { paths: [file] }));
});
test("notify journal: a directory it cannot write or create is refused by its error code", { skip: ROOT_SKIP }, (t) => {
const root = under(t);
const file = journalPath(root, "demo");
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
chmodSync(dirname(file), 0o500);
try {
assert.throws(() => openJournal(file), refusal("notify journal directory is not writable (EACCES)", { paths: [file] }));
} finally {
chmodSync(dirname(file), 0o700);
}
const parent = dirname(dirname(file));
const other = journalPath(root, "acme");
chmodSync(parent, 0o500);
try {
assert.throws(() => openJournal(other), refusal("notify journal directory cannot be created (EACCES)", { paths: [other] }));
} finally {
chmodSync(parent, 0o700);
}
});
test("notify journal: a journal link, an unwritable journal and a directory journal are refused by label", { skip: ROOT_SKIP }, (t) => {
const root = under(t);
const file = journalPath(root, "demo");
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
writeFileSync(join(root, "elsewhere.jsonl"), "", { mode: 0o600 });
symlinkSync(join(root, "elsewhere.jsonl"), file);
assert.throws(() => openJournal(file), refusal("notify journal must not be a symlink", { paths: [file] }));
rmSync(file);
writeFileSync(file, "", { mode: 0o400 });
chmodSync(file, 0o400);
assert.throws(() => openJournal(file), refusal("notify journal is not writable (EACCES)", { paths: [file] }));
const asDir = journalPath(root, "acme");
mkdirSync(asDir, { recursive: true, mode: 0o700 });
assert.throws(() => openJournal(asDir), refusal("notify journal must be a regular file, mode 0600, owned by this user", { paths: [asDir] }));
});
test("notify journal: a journal of the wrong mode and a malformed line are refused by label", (t) => {
const file = journalPath(under(t), "demo");
mkdirSync(dirname(file), { recursive: true, mode: 0o700 });
writeFileSync(file, "", { mode: 0o644 });
chmodSync(file, 0o644);
assert.throws(() => openJournal(file), refusal("notify journal must be a regular file, mode 0600, owned by this user", { paths: [file] }));
chmodSync(file, 0o600);
writeFileSync(file, "not json\n");
assert.throws(() => openJournal(file), refusal("notify journal line 1 is malformed (json)", { paths: [file] }));
});
// --- notifier config ---
function notifyRoot(t, text, mode = 0o600) {
const dataRoot = join(under(t), "data");
const dir = join(dataRoot, "notify", "acme");
mkdirSync(dir, { recursive: true, mode: 0o700 });
if (text !== undefined) {
writeFileSync(join(dir, "notify.json"), text, { mode });
chmodSync(join(dir, "notify.json"), mode);
}
return dataRoot;
}
test("notifier config: a missing file names the business and its place under the data root", (t) => {
const dataRoot = notifyRoot(t);
assert.throws(() => readNotifyConfig(dataRoot, "acme"),
refusal('no notifier config for acme: write {"notifyVersion": 1, "binding": "<discord binding>"} to notify/acme/notify.json under the data root, mode 0600, or "binding": null to run without DMs', { paths: [dataRoot] }));
});
test("notifier config: a file of the wrong mode names the business only", (t) => {
const dataRoot = notifyRoot(t, JSON.stringify({ notifyVersion: 1, binding: null }), 0o644);
assert.throws(() => readNotifyConfig(dataRoot, "acme"), refusal("notifier config for acme must be a regular file, mode 0600, owned by this user", { paths: [dataRoot] }));
});
test("notifier config: a file that is not JSON names the business only", (t) => {
const dataRoot = notifyRoot(t, "{not json");
assert.throws(() => readNotifyConfig(dataRoot, "acme"), refusal("notifier config for acme is not JSON", { paths: [dataRoot] }));
});
test("notifier config: a file of the wrong shape names the business only", (t) => {
const dataRoot = notifyRoot(t, JSON.stringify({ notifyVersion: 1, binding: null, extra: 1 }));
assert.throws(() => readNotifyConfig(dataRoot, "acme"), refusal('notifier config for acme must be exactly {"notifyVersion": 1, "binding": <binding name> | null}', { paths: [dataRoot] }));
});
// --- dev fixture ---
const RECORD = { fixtureVersion: 1, model: "deterministic", endpoint: "http://127.0.0.1:45678", port: 45678, pid: 999999, instance: "fxtest", entry: FIXTURE_ENTRY };
function fixtureRoot(t, { record = RECORD, text = JSON.stringify(record), dirMode = 0o700, fileMode = 0o600 } = {}) {
const dataRoot = join(under(t), "data");
const file = fixtureFile(dataRoot);
mkdirSync(dirname(file), { recursive: true, mode: dirMode });
chmodSync(dirname(file), dirMode);
if (text !== null) {
writeFileSync(file, text, { mode: fileMode });
chmodSync(file, fileMode);
}
return dataRoot;
}
test("dev fixture: a shared fixture directory is refused by label", (t) => {
const dataRoot = fixtureRoot(t, { dirMode: 0o755 });
assert.throws(() => readFixture(dataRoot), refusal("fixture-state-unsafe: dev-fixture directory is not a private directory of this user", { code: "fixture-state-unsafe", paths: [dataRoot] }));
});
test("dev fixture: a linked fixture file is refused by label and error code", (t) => {
const dataRoot = fixtureRoot(t, { text: null });
writeFileSync(join(dataRoot, "elsewhere.json"), JSON.stringify(RECORD), { mode: 0o600 });
symlinkSync(join(dataRoot, "elsewhere.json"), fixtureFile(dataRoot));
assert.throws(() => readFixture(dataRoot), refusal("fixture-state-unsafe: fixture.json can't be opened (ELOOP)", { code: "fixture-state-unsafe", paths: [dataRoot] }));
});
test("dev fixture: a shared fixture file is refused by label", (t) => {
const dataRoot = fixtureRoot(t, { fileMode: 0o644 });
assert.throws(() => readFixture(dataRoot), refusal("fixture-state-unsafe: fixture.json is not a private file of this user", { code: "fixture-state-unsafe", paths: [dataRoot] }));
});
test("dev fixture: a fixture file that is not JSON is refused by label", (t) => {
const dataRoot = fixtureRoot(t, { text: "not json" });
assert.throws(() => readFixture(dataRoot), refusal("fixture-state-invalid: fixture.json is not JSON", { code: "fixture-state-invalid", paths: [dataRoot] }));
});
test("dev fixture: a non-loopback endpoint and a record of the wrong shape are refused by label", (t) => {
let dataRoot = fixtureRoot(t, { record: { ...RECORD, endpoint: "http://0.0.0.0:45678" } });
assert.throws(() => readFixture(dataRoot), refusal("fixture-endpoint-not-loopback: fixture.json names an endpoint that is not http://127.0.0.1:<port>", { code: "fixture-endpoint-not-loopback", paths: [dataRoot] }));
dataRoot = fixtureRoot(t, { record: { ...RECORD, port: 1 } });
assert.throws(() => readFixture(dataRoot), refusal("fixture-state-invalid: fixture.json is not a fixture record", { code: "fixture-state-invalid", paths: [dataRoot] }));
});
// --- git socket ---
test("git socket: a path that exists and is not a socket is refused by label", async (t) => {
const dataRoot = join(under(t), "data");
const path = gitSocketPath(dataRoot);
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
writeFileSync(path, "", { mode: 0o600 });
const sock = createGitSocket({ host: {}, business: { id: "acme", projects: {} }, dataRoot, env: {}, log: () => {} });
await assert.rejects(sock.listen(), refusal("git socket path exists and isn't a socket", { paths: [dataRoot, path] }));
});
// --- console launch ---
// The Console is given a port this test holds, so its listen fails and it exits at
// startup. Should it start anyway, it is stopped before the tree goes.
test("console: a Console that exits at startup is named by its log under the data root, never by host path", { timeout: 60000 }, async (t) => {
const root = under(t);
const ctx = { configDir: join(root, "config"), dataRoot: join(root, "data"), instance: "redact", env: { PATH: process.env.PATH, HOME: root } };
for (const dir of [ctx.configDir, ctx.dataRoot]) mkdirSync(dir, { mode: 0o700 });
const held = createServer();
await new Promise((resolve, reject) => held.once("error", reject).listen(0, "127.0.0.1", resolve));
try {
await assert.rejects(consoleStart(ctx, { detach: true, port: String(held.address().port), warn: () => {} }),
refusal(/^console failed to start; see console\/console-[0-9]+-[0-9]+\.log under the data root$/, { paths: [ctx.dataRoot] }));
} finally {
await consoleStop(ctx, { warn: () => {} }).catch(() => {});
await new Promise((resolve) => held.close(resolve));
}
});
// --- review round (org-redact5 Sol P2 #3): documents, --vars and raw fs errors ---
const quietIo = () => ({ stdout: { write: () => true }, stderr: { write: () => true } });
test("org validate: a missing or invalid document is refused by kind, never by host path or bytes", async (t) => {
const root = under(t), missing = join(root, "missing.json"), bad = join(root, "bad.json");
writeFileSync(bad, `{"${SENTINEL}": `, { mode: 0o600 });
await assert.rejects(orgCommand(["validate", missing], quietIo()), refusal("cannot read organization document: ENOENT", { exitCode: 2, paths: [missing, root] }));
await assert.rejects(orgCommand(["validate", bad], quietIo()), refusal("cannot read organization document: not valid JSON", { exitCode: 2, paths: [bad, root] }));
});
test("org template render: a raw filesystem error is named by operation and code, never by host path", async (t) => {
const root = under(t), output = join(root, "alpha.json");
writeFileSync(output, "{}\n", { mode: 0o600 });
await assert.rejects(orgCommand(["template", "render", "alpha", "--output", output], quietIo()),
refusal("EEXIST: cannot open (EEXIST)", { exitCode: 2, paths: [output, root] }));
});
test("instance set: a missing or invalid --vars file is refused by flag and kind, never by host path or bytes", async (t) => {
const root = under(t), missing = join(root, "vars.json"), bad = join(root, "bad.json");
writeFileSync(bad, `{"${SENTINEL}": `, { mode: 0o600 });
const rev = "a".repeat(64);
const callFor = () => assert.fail("must refuse before any call");
await assert.rejects(instancesCommand(["instance", "set", "acme", "coder", "--vars", missing, "--revision", rev], quietIo(), callFor),
refusal("--vars file: ENOENT", { exitCode: 4, paths: [missing, root] }));
await assert.rejects(instancesCommand(["instance", "set", "acme", "coder", "--vars", bad, "--revision", rev], quietIo(), callFor),
refusal("--vars file: not valid JSON", { exitCode: 4, paths: [bad, root] }));
});
@@ -169,7 +169,7 @@ test("rename r2 B2: a draft file named like an installed ID: org validate reads
rmSync(join(cwd, "acme"));
const noDraft = spawn(MOSAIC, ["org", "validate", "acme"], env, cwd);
assert.equal(noDraft.status, 2);
assert.match(noDraft.stderr, /^mosaic: cannot read document acme: /);
assert.equal(noDraft.stderr, "mosaic: cannot read organization document: ENOENT\n");
writeJson(join(cwd, "acme"), await templateRender("acme", { fixture: "local" }));
rmSync(join(root, "config", "businesses", "acme.json"));
const noInstalled = spawn(MOSAIC, ["org", "config", "validate", "acme"], env, cwd);
@@ -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); }
+23 -2
View File
@@ -21,11 +21,11 @@ const OTHER = '11111111-2222-4333-8444-555555555555';
let n = 0;
// An instance with org alpha and one finished run for THREAD.
async function world({ run = 'r0123456789ab' } = {}) {
async function world({ run = 'r0123456789ab', under = HOME } = {}) {
const name = `ev${++n}`;
const env = { PATH: process.env.PATH, HOME, MOSAIC_CONFIG: join(HOME, '.config', `mosaic-${name}`, 'config.json') };
mkdirSync(dirname(env.MOSAIC_CONFIG), { recursive: true, mode: 0o700 });
const dataRoot = join(HOME, `data-${name}`); mkdirSync(dataRoot, { mode: 0o700 });
const dataRoot = join(under, `data-${name}`); mkdirSync(dataRoot, { recursive: true, mode: 0o700 });
const org = await reserveOrg({ configDir: dirname(env.MOSAIC_CONFIG), dataRoot, instance: name, principal: DEV_OWNER }, 'alpha');
const runDir = join(runsDir(org.root, 'alpha'), run);
mkdirSync(join(runDir, 'bundle'), { recursive: true, mode: 0o700 });
@@ -132,6 +132,27 @@ test('no launch for the thread, an unknown org and a usage error', async () => {
await assert.rejects(runCommand(['evidence', 'alpha', '--thread', THREAD], io({ PATH: process.env.PATH, HOME }), { system: { dataRoot: w.dataRoot } }), e => e.exitCode === 3 && e.code === 'run-evidence-instance-refused');
});
// PR #1916 (org-redact5): a refusal names the launch log by label, never by host path.
const SENTINEL = 'sk-test-NOT-A-SECRET-1234';
const noPath = (e, w) => !e.message.includes(SENTINEL) && !e.message.includes(w.dataRoot) && !e.message.includes(w.log);
test('a launch log of the wrong mode refuses by label, never by host path', async () => {
const w = await world({ under: join(HOME, SENTINEL) });
chmodSync(w.log, 0o644);
const r = await evidence(w, ['--thread', THREAD, '--json']);
assert.equal(r.error?.exitCode, 3);
assert.equal(r.error.message, 'run-evidence-refused: launch log: mode 644, expected 600');
assert.ok(noPath(r.error, w), r.error.message);
});
test('no launch for the thread names the launch log by label, never by host path', async () => {
const w = await world({ under: join(HOME, SENTINEL) });
const r = await evidence(w, ['--thread', '99999999-2222-4333-8444-555555555555', '--json']);
assert.equal(r.error?.exitCode, 2);
assert.equal(r.error.message, 'no launch for thread 99999999-2222-4333-8444-555555555555 in the launch log');
assert.ok(noPath(r.error, w), r.error.message);
});
test('a thread name resolves through the bus with the org as business', async () => {
const w = await world();
const seen = [];
+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);
+7 -2
View File
@@ -130,8 +130,13 @@ export function writeTurn(dir, id, record) {
return path;
}
// A parse refusal names the turn by its id, never by host path (PR #1916); a
// listed file's id is named only when it passes TURN_ID.
const turnLabel = (id) => (TURN_ID.test(id) ? `turn record ${id}` : "turn record");
export function readTurn(dir, id) {
return JSON.parse(readFileSync(turnPath(dir, id), "utf8"));
const path = turnPath(dir, id);
return parseJsonFileText(readFileSync(path, "utf8"), path, turnLabel(String(id)));
}
export function listTurns(dir) {
@@ -139,7 +144,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), turnLabel(f.slice(0, -".json".length))));
}
// 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,44 @@
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 = join(scratch(t), secret); mkdirSync(join(root, 'turns'), { recursive: true });
const file = join(root, 'turns', 'turn-1.json'); writeFileSync(file, secret);
// The refusal names the turn by its id, never by host path (PR #1916).
assert.throws(() => mode === 'read' ? readTurn(root, 'turn-1') : listTurns(root), safe('turn record turn-1 is not valid JSON', SyntaxError, {}, file));
});
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" } } }));
+1 -1
View File
@@ -41,7 +41,7 @@ export function webPlan(resolved, harness, builtinWeb) {
}
return { source, builtin: granted && source === "harness-builtin" };
}
export const builtinTools = (tools) => tools.filter((t) => t !== "web");
export const builtinTools = (tools) => tools.filter((t) => t !== "web" && !t.includes("."));
export const SOURCES = Object.freeze({
piExtension: fileURLToPath(new URL("./pi-extension.mjs", import.meta.url)),
claudeGate: fileURLToPath(new URL("./claude-gate.mjs", import.meta.url)),
+16 -3
View File
@@ -363,11 +363,12 @@ export function toolServer({ path, tools, client, local }) {
if (!tool) return answer({ ok: false, error: "unknown-tool" });
const args = request.args && typeof request.args === "object" && !Array.isArray(request.args) ? request.args : {};
try {
const result = Object.hasOwn(local, tool.verb) ? await local[tool.verb](args, { signal: controller.signal }) : await client.call(tool.verb, args);
const result = Object.hasOwn(local, tool.verb) ? await local[tool.verb](args, { signal: controller.signal }) : await client.call(tool.verb, tool.pluginTool ? {tool:tool.pluginTool,arguments:args} : args);
answer({ ok: true, result });
} catch (e) {
answer({ ok: false, error: /^[a-z-]{1,64}$/.test(e.code ?? "") ? e.code : "tool-failed",
...refusalGuidance(e.code),
...(e.code === "plugin-disabled" && /^[a-z][a-z0-9-]{0,31}$/.test(e.reason ?? "") ? {plugin:e.reason} : {}),
...(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) } : {}) });
@@ -525,14 +526,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)) {
@@ -569,7 +576,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,
+3 -1
View File
@@ -96,7 +96,9 @@ const MESSAGE_CLASSES = ["REQUEST", "ASSIGNMENT", "REVIEW-REQUEST", "REVIEW-RESU
// "working.land" go to the host's git socket.
export function typedTools({ resolved, arbiter = false, tracker = false, projects = [], webTyped = true, land = false, library = false }) {
const held = new Set([...resolved.limits.authority.withinRole, ...resolved.limits.authority.crossRole]);
const tools = [];
const tools = (resolved.pluginTools ?? []).filter(t=>resolved.limits.tools.includes(t.name)).map(t=>({
name:t.name, verb:"plugin.tool", pluginTool:t.name, action:null, description:t.description, parameters:t.arguments,
}));
const add = (name, verb, action, description, parameters) => tools.push({ name, verb, action, description, parameters });
if (held.has("message.send")) {
add("send_message", "message.send", "message.send", "Send a message to another role instance, or to \"human\". Your final answer to a message is sent back to its sender for you; use this for anything else. A reply to an answer gets no automatic answer; use class REQUEST to ask a follow-up.", obj({
@@ -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/);
});
+9 -9
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();
@@ -167,8 +167,8 @@ export function checkAccess(document, context) {
// other, as the business file is.
export function loadAccess(file, context, { uid = process.getuid() } = {}) {
const document = readJsonFile(file, "access file", (stat) => {
if (stat.uid !== uid) refuse(`access file must belong to uid ${uid}: ${file}`);
if ((stat.mode & 0o022) !== 0) refuse(`access file must not be writable by group or other (mode ${(stat.mode & 0o777).toString(8)}): ${file}`);
if (stat.uid !== uid) refuse(`access file must belong to uid ${uid}`);
if ((stat.mode & 0o022) !== 0) refuse(`access file must not be writable by group or other (mode ${(stat.mode & 0o777).toString(8)})`);
});
return checkAccess(document, context);
}
+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, dataRoot = null }) {
const copies = snapshot?.copies ?? {};
if (Object.keys(copies).length === 0 && !hasProjectAgents(document)) return validateBusinessDocument(document, file, { rolesDir, dataRoot });
// The document's own refusals come first, whole.
validateBusinessDocument(document, file, { rolesDir, dataRoot });
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, dataRoot });
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, dataRoot });
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, dataRoot = null }) {
const plain = loadBusiness(id, { dir, rolesDir, dataRoot });
const { document } = organizationWriter({ dir, rolesDir, dataRoot }).read(id);
if ((!snapshot || Object.keys(snapshot.copies ?? {}).length === 0) && !hasProjectAgents(document)) return plain;
return withCopies(document, businessFilePath(id, dir), { rolesDir, snapshot, dataRoot });
}
// 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}` };
}
@@ -11,11 +11,12 @@
// 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, lstatSync } from 'node:fs';
import { constants } from 'node:fs';
import * as fs from 'node:fs/promises';
import { createHash, randomBytes } from 'node:crypto';
import { join, parse, resolve } from 'node:path';
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';
@@ -38,27 +39,9 @@ export function requireRootOwner(actor, owner) {
fail('human-required', 'only the signed-in owner writes the root agent file');
}
// Every ancestor 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), so no other user can rename or
// replace configDir between these checks and the path-based lock, temp file and rename. configDir
// itself is this uid's and not group or other writable.
function assertConfigDir(configDir, uid) {
if (typeof configDir !== 'string' || resolve(configDir) !== configDir) fail('agents-unsafe', 'config directory must be an absolute normalized path');
let current = parse(configDir).root;
const trusted = st => (st.uid === 0 || st.uid === uid) && (!(st.mode & 0o022) || (st.mode & 0o1000));
try { if (!trusted(lstatSync(current))) fail('agents-unsafe', `a config directory ancestor must belong to root or uid ${uid} and not be writable by others without the sticky bit`); }
catch (error) { if (error instanceof OrgError) throw error; fail('agents-unsafe', 'config directory is missing'); }
for (const part of configDir.slice(current.length).split('/').filter(Boolean)) {
current = join(current, part);
let st;
try { st = lstatSync(current); } catch { fail('agents-unsafe', 'config directory is missing'); }
if (!st.isDirectory() || st.isSymbolicLink()) fail('agents-unsafe', 'config path contains a symbolic link or non-directory');
if (current !== configDir && !trusted(st)) fail('agents-unsafe', `a config directory ancestor must belong to root or uid ${uid} and not be writable by others without the sticky bit`);
}
const st = lstatSync(configDir);
if (st.uid !== uid) fail('agents-unsafe', `config directory must belong to uid ${uid}`);
if (st.mode & 0o022) fail('agents-unsafe', 'config directory must not be writable by group or other');
}
// 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.
+3 -3
View File
@@ -86,9 +86,9 @@ export function loadRootAgents(configDir, { rolesDir } = {}) {
} catch (error) {
if (error?.name !== "BusinessError") throw error;
if (error.exitCode === 4 && /not found/.test(error.message)) return deepFreeze({ state: "absent", agents: {}, assistant: null });
// The read's messages carry the host path; the Console shows this one, so it names the file only.
// Keep parse refusals file-only here too, without the host path.
if (/ is not valid JSON /.test(error.message)) refuse(`root agent file is not valid JSON (${ROOT_FILE})`, error.exitCode);
// The Console shows this one, so it names the file only. The shared reader no longer quotes the
// host path; the replaceAll below stays as a second guard.
if (/ is not valid JSON$/.test(error.message)) refuse(`root agent file is not valid JSON (${ROOT_FILE})`, error.exitCode);
refuse(error.message.replaceAll(file, ROOT_FILE), error.exitCode);
}
try { return deepFreeze({ state: "ok", ...checkRootAgents(document, { rolesDir }) }); }
+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 } : {}),
+10 -2
View File
@@ -34,7 +34,7 @@ const AUTHORITY_KEYS = Object.freeze(["withinRole", "crossRole"]);
const MESSAGING_KEYS = Object.freeze(["sendTo", "receiveFrom"]);
const PERSONALITY_KEYS = Object.freeze(["summary", "traits", "voice"]);
const CREDENTIAL_KEYS = Object.freeze(["service", "scopes"]);
const PLUGIN_KEYS = Object.freeze(["name", "tools", "actions", "services"]);
const PLUGIN_KEYS = Object.freeze(["name", "tools", "actions", "services", "roleTemplates"]);
const PLUGIN_SERVICE_KEYS = Object.freeze(["name", "scopes"]);
// Step 6's length bounds, in characters or entries. The design gives none, so P1 sets them. The
@@ -137,7 +137,14 @@ function checkPlugin(plugin, i, core) {
const names = [...tools, ...actions, ...services.map((s) => s.name.slice(name.length + 1))];
const twice = names.find((n, j) => names.indexOf(n) !== j);
if (twice !== undefined) refuse(`${where} declares ${JSON.stringify(full(twice))} twice`);
return { name, tools: tools.map(full), actions: actions.map(full), services };
if (plugin.roleTemplates !== undefined && !Array.isArray(plugin.roleTemplates)) refuse("invalid plugin role templates");
const roleTemplates=(plugin.roleTemplates ?? []).map(t=>{
if(!isPlain(t) || Object.keys(t).sort().join(",")!=="file,name" || typeof t.name!=="string" || !DECLARED_NAME.test(t.name) || coreName(t.name)
|| typeof t.file!=="string" || !/^[a-zA-Z0-9._/-]+$/.test(t.file) || t.file.split("/").some(p=>!p || p==="." || p==="..")) refuse("invalid plugin role template");
return {name:full(t.name),file:t.file};
});
if(new Set(roleTemplates.map(t=>t.name)).size!==roleTemplates.length)refuse("duplicate plugin role template");
return { name, tools: tools.map(full), actions: actions.map(full), services, roleTemplates };
}
// The registry checkComposition reads: the core set plus each plugin's declared set. The plugin
@@ -162,6 +169,7 @@ export function buildRegistry({ plugins = [] } = {}) {
routed: [...ROUTED, ...pluginActions],
services,
plugins: declared,
roleTemplates: declared.flatMap(p=>p.roleTemplates),
});
BUILT.add(registry);
return registry;
+16 -2
View File
@@ -67,7 +67,21 @@ 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: ";
// Name the config by where it came from, never by host path (PR #1916).
fail(3, `system config problem (mosaic-config exit ${proc.status}): ${detail}${env.MOSAIC_CONFIG ? "the system config MOSAIC_CONFIG names" : "the default system config"}${proc.error?.code ? ` (${proc.error.code})` : ""}`);
}
try {
return JSON.parse(proc.stdout);
} catch {
@@ -106,7 +120,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 of project ${id} 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');
}
+4 -2
View File
@@ -8,6 +8,8 @@ import { refuse } from "./errors.mjs";
import { credentialKind, isHostKind } from "./vocabulary.mjs";
import { requireObject, rejectUnknownKeys, requireDate } from "./util.mjs";
import { pluginName } from "../../plugins/src/organization.mjs";
const ENV_NAME = /^[A-Z][A-Z0-9_]{0,63}$/;
export const DATE_KEY = Object.freeze({ gitea: "rotateBy", vikunja: "expires" });
const WARN_DAYS = 7;
@@ -21,10 +23,10 @@ const DAY_MS = 24 * 60 * 60 * 1000;
// can't use the form. A `file` reference into the agents' store must be the
// agent's own path (`credential-not-yours`).
export function parseCredentialRef(ref, service, where, { store = null } = {}) {
if (!credentialKind(service)) refuse(`${where}: unknown credential service ${JSON.stringify(service)}`);
if (!credentialKind(service) && !pluginName(service)) refuse(`${where}: unknown credential service ${JSON.stringify(service)}`);
requireObject(ref, where);
// Row 210 (#1707, design D1): an ssh key doesn't expire, so host.<id> takes rotateBy as gitea does.
const dateKey = isHostKind(service) ? "rotateBy" : DATE_KEY[service];
const dateKey = isHostKind(service) ? "rotateBy" : (DATE_KEY[service] ?? "rotateBy");
rejectUnknownKeys(ref, ["file", "env", "store", dateKey], where);
const hasFile = ref.file !== undefined;
const hasEnv = ref.env !== undefined;
@@ -0,0 +1,129 @@
// Roles round 2, slice T1 (#1873): the human verbs on an organization's definitions, and the reads.
// Design: docs/plans/2026-11-10_roles-and-rails-design.md section 4.
//
// Each write needs the caller's org.hire right (VERB_RIGHTS) and refuses every agent caller, then
// goes through the writer's setDefinition: only that definition changes, rev counts up, the entry
// passes checkComposition, and a definition an instance names refuses definition-in-use.
//
// caller: { actor, rights, agent } — actor is the authenticated account the writer logs; rights is
// the caller's resolved right list (an owner holds ROLE_RIGHTS.owner); agent is true for any agent
// session, which never writes a definition. The receipt names the verb, organization, definition,
// rev, revision and transaction, never role text.
import { VERB_RIGHTS } from "./org-rights.mjs";
import { OrganizationError } from "./organization.mjs";
import {
diffDefinition, entryFromTemplate, instancesNaming, readTemplate, templateHashes, checkDefinitionName, ORG_PREFIX,
} from "./definitions.mjs";
// The verb names the policy table maps, following R1's hire.resolve.console.
export const DEFINITION_VERBS = Object.freeze({
import: "definition.import.console",
edit: "definition.edit.console",
invent: "definition.invent.console",
remove: "definition.remove.console",
});
// The bus verbs for the writes (the broker's DEFINITION_BUS_KEYS checks their arguments). The bus
// runs them for the business's human at the cli, with its credential store's secret check; the CLI's
// writes go through them, as no other process holds the tokens that check looks for.
export const DEFINITION_BUS_VERBS = Object.freeze({
"definition.import": "import", "definition.invent": "invent", "definition.edit": "edit", "definition.remove": "remove",
});
const fail = (code, message = code) => {
throw new OrganizationError(code, message);
};
function authorize(caller, verb) {
if (caller === null || typeof caller !== "object") fail("caller-required");
if (caller.agent !== false) fail("human-required", "an agent never writes a definition");
const right = VERB_RIGHTS[DEFINITION_VERBS[verb]];
if (typeof right !== "string") fail("verb-unmapped");
if (!Array.isArray(caller.rights) || !caller.rights.includes(right)) fail("right-required", `${verb} needs ${right}`);
}
// The parts of an entry a caller may supply for invent and edit: role, instructions, personality.
const SUPPLIED = ["role", "instructions", "personality"];
function supplied(input) {
if (input === null || typeof input !== "object" || Array.isArray(input)) fail("definition-invalid", "the definition must be a JSON object");
const bad = Object.keys(input).findIndex((key) => !SUPPLIED.includes(key));
if (bad !== -1) fail("definition-invalid", `the definition has an unknown key (key ${bad + 1}; allowed: ${SUPPLIED.join(", ")})`);
const out = { role: input.role, instructions: input.instructions };
if (input.personality !== undefined) out.personality = input.personality;
return out;
}
function receipt(verb, id, name, result) {
return { verb, organization: id, definition: name, rev: result.rev, revision: result.revision, transaction: result.transaction };
}
export function definitionVerbs({ writer, rolesDir, assertClean }) {
const options = (verb, caller, extra) => ({ ...extra, actor: caller.actor, assertClean, detail: { verb: DEFINITION_VERBS[verb] } });
// Copies a template into definitions with from set to its name and current hash.
function importTemplate(id, { revision, template, as = template }, caller) {
authorize(caller, "import");
checkDefinitionName(as, "definition name");
const source = readTemplate(rolesDir, template);
const result = writer.setDefinition(id, as, (current) => {
if (current) fail("definition-exists", `definitions already declares ${as}`);
const { rev: _, ...entry } = entryFromTemplate(source);
return entry;
}, options("import", caller, { revision }));
return { ...receipt("import", id, as, result), from: { template, sha256: source.sha256 } };
}
// Changes an existing definition: the caller supplies the whole role, instructions and
// personality; from stays as it was.
function edit(id, { revision, rev, name, definition }, caller) {
authorize(caller, "edit");
if (!Number.isSafeInteger(rev)) fail("definition-rev-required", "edit needs the definition's current rev");
const entry = supplied(definition);
const result = writer.setDefinition(id, name, (current) => {
if (!current) fail("definition-missing", `definitions declares no ${name}`);
return current.from ? { from: current.from, ...entry } : entry;
}, options("edit", caller, { revision, rev }));
return receipt("edit", id, name, result);
}
// Writes a new definition with no from.
function invent(id, { revision, name, definition }, caller) {
authorize(caller, "invent");
const entry = supplied(definition);
const result = writer.setDefinition(id, name, (current) => {
if (current) fail("definition-exists", `definitions already declares ${name}`);
return entry;
}, options("invent", caller, { revision }));
return receipt("invent", id, name, result);
}
function remove(id, { revision, rev, name }, caller) {
authorize(caller, "remove");
if (!Number.isSafeInteger(rev)) fail("definition-rev-required", "remove needs the definition's current rev");
const result = writer.setDefinition(id, name, () => null, options("remove", caller, { revision, rev }));
return { ...receipt("remove", id, name, result), rev: null, removedRev: rev };
}
// Reads. Each reads the validated document.
function list(id) {
const { document, revision } = writer.read(id);
const definitions = Object.entries(document.definitions ?? {}).map(([name, entry]) => ({
name, reference: ORG_PREFIX + name, rev: entry.rev, from: entry.from ?? null, usedBy: instancesNaming(document, name),
}));
return { organization: id, revision, definitions };
}
function show(id, name) {
const { document, revision } = writer.read(id);
if (!Object.hasOwn(document.definitions ?? {}, name)) fail("definition-missing", `definitions declares no ${name}`);
return { organization: id, revision, name, reference: ORG_PREFIX + name, usedBy: instancesNaming(document, name), ...structuredClone(document.definitions[name]) };
}
function diff(id, name) {
const { document, revision } = writer.read(id);
return { organization: id, revision, ...diffDefinition(document, name, rolesDir) };
}
return { import: importTemplate, edit, invent, remove, list, show, diff, templates: () => templateHashes(rolesDir) };
}
+270
View File
@@ -0,0 +1,270 @@
// Roles round 2, slice T1 (#1873): an organization's own role definitions and the templates they
// copy. Design: docs/plans/2026-11-10_roles-and-rails-design.md sections 4 and 5, and the T1
// paragraph of section 13.
//
// The organization file's `definitions` key holds one entry per name:
// { rev, from?: { template, sha256 }, role: { tools, network, authority, credentials,
// messaging?, skills? }, instructions, personality? }
// An instance names one as `definition: "org:<name>"`. Every entry passes checkComposition (P1)
// whenever the file is validated, so no path writes or loads a definition that skips it. A refusal
// names the definition and the key at fault, never a value: composition.mjs quotes only registered
// names, and this module quotes only definition and template names, which match ID_PATTERN.
//
// A template is a shipped roles/<name>.json (role version 2) with its contract. Its identity is the
// name plus templateHash: sha256 over the JSON bytes and the contract bytes together, each preceded
// by its byte length and a newline, so no field goes into roles/.
//
// Pure reads only. The writer verbs live in definition-verbs.mjs, which organization.mjs's writer
// backs; this module is imported by organization-config.mjs and must not import the writer.
import { readFileSync, readdirSync } from "node:fs";
import { join } from "node:path";
import { createHash } from "node:crypto";
import { refuse } from "./errors.mjs";
import { buildRegistry, checkComposition } from "./composition.mjs";
import { validateRoleDocument } from "./role.mjs";
import { ID_PATTERN } from "./vocabulary.mjs";
import { canonicalJson, deepFreeze } from "./util.mjs";
export const ORG_PREFIX = "org:";
export const ENTRY_KEYS = Object.freeze(["rev", "from", "role", "instructions", "personality"]);
// The primitives an entry keeps under `role`; instructions and personality sit beside it.
export const ROLE_KEYS = Object.freeze(["tools", "network", "authority", "credentials", "messaging", "skills"]);
// What the diff read compares, in section 4's order.
export const PRIMITIVES = Object.freeze([...ROLE_KEYS, "instructions", "personality"]);
const FROM_KEYS = Object.freeze(["template", "sha256"]);
const HEX64 = /^[0-9a-f]{64}$/;
// The core registry. Plugin-declared primitives arrive with PL1; until then an org definition uses
// core names only.
const CORE = buildRegistry();
// Validation keeps no flagged lines: A4 computes them at the decision, against the chief's live
// role. Step 7 only flags, so a chief holding nothing refuses nothing here.
const NO_CHIEF = Object.freeze({
tools: Object.freeze([]), network: "none",
authority: Object.freeze({ withinRole: Object.freeze([]), crossRole: Object.freeze([]) }),
credentials: Object.freeze([]), messaging: null,
});
// The file-load check has no credential store: a load runs before the bus builds one from the file.
// The held-secret scan runs where definitions are written (setDefinition requires it, and the bus
// passes credentials.assertClean). A load still runs every other composition check.
const NO_STORE = () => {};
const plain = (value) => value !== null && typeof value === "object" && !Array.isArray(value)
&& [Object.prototype, null].includes(Object.getPrototypeOf(value));
// The definition name an instance's `definition` value names, or null for a template name.
export function orgDefinitionName(value) {
return typeof value === "string" && value.startsWith(ORG_PREFIX) ? value.slice(ORG_PREFIX.length) : null;
}
export function checkDefinitionName(name, where) {
if (typeof name !== "string" || !ID_PATTERN.test(name)) refuse(`${where} must be a definition name matching ${ID_PATTERN}`);
return name;
}
function keysOnly(object, allowed, where) {
Object.keys(object).forEach((key, i) => {
if (!allowed.includes(key)) refuse(`${where} has an unknown key (key ${i + 1}; allowed: ${allowed.join(", ")})`);
});
}
// The composition an entry describes, for checkComposition. Absent optional keys stay absent.
export function compositionOf(entry) {
const out = { ...entry.role, instructions: entry.instructions };
if (entry.personality !== undefined) out.personality = entry.personality;
return out;
}
// One entry: its own keys, then checkComposition. Returns { composition, flagged }. options.assertClean
// is the credential store's check; a write always passes it (setDefinition), a file load can't.
export function checkDefinition(name, entry, { assertClean = NO_STORE, where = "definitions" } = {}) {
const at = `${where}.${checkDefinitionName(name, `${where} key`)}`;
if (!plain(entry)) refuse(`${at} must be a JSON object`);
keysOnly(entry, ENTRY_KEYS, at);
if (!Number.isSafeInteger(entry.rev) || entry.rev < 1) refuse(`${at}.rev must be a positive integer`);
if (entry.from !== undefined) {
if (!plain(entry.from)) refuse(`${at}.from must be a JSON object`);
keysOnly(entry.from, FROM_KEYS, `${at}.from`);
if (typeof entry.from.template !== "string" || !ID_PATTERN.test(entry.from.template)) refuse(`${at}.from.template must be a template name matching ${ID_PATTERN}`);
if (typeof entry.from.sha256 !== "string" || !HEX64.test(entry.from.sha256)) refuse(`${at}.from.sha256 must be 64 lowercase hex characters`);
}
if (!plain(entry.role)) refuse(`${at}.role must be a JSON object`);
keysOnly(entry.role, ROLE_KEYS, `${at}.role`);
try {
return checkComposition(compositionOf(entry), CORE, NO_CHIEF, { assertClean });
} catch (error) {
// composition.mjs's refusals quote registered names only; keep its text, add where.
if (error?.name === "BusinessError") refuse(`${at}: ${error.message}`);
throw error;
}
}
// The resolved role an `org:` instance launches with: loadRole's shape, so the business checks,
// the resolver and the broker read it as they read a template. Instructions stand in for the
// contract file (contractPath null). A definition hires nobody: hireable stays empty.
function resolved(name, entry, composition) {
return {
roleVersion: 2, name: ORG_PREFIX + name, title: name, contract: null, contractPath: null,
tools: composition.tools, network: composition.network, authority: composition.authority,
credentials: composition.credentials, messaging: composition.messaging, skills: composition.skills,
hireable: [], instructions: composition.instructions, personality: composition.personality,
rev: entry.rev, from: entry.from === undefined ? null : { ...entry.from },
};
}
// The whole `definitions` key. Returns a frozen map "org:<name>" -> resolved role. A bad entry
// refuses the whole file.
export function checkDefinitions(definitions, file, options = {}) {
if (definitions === undefined) return deepFreeze({});
const where = `${file} definitions`;
if (!plain(definitions)) refuse(`${where} must be a JSON object`);
const out = {};
Object.keys(definitions).forEach((name, i) => {
checkDefinitionName(name, `${where} key ${i + 1}`);
const { composition } = checkDefinition(name, definitions[name], { ...options, where });
out[ORG_PREFIX + name] = resolved(name, definitions[name], composition);
});
return deepFreeze(out);
}
// The instances that name `org:<name>`, sorted.
export function instancesNaming(document, name) {
return Object.entries(document?.roles ?? {})
.filter(([, entry]) => entry?.definition === ORG_PREFIX + name).map(([instance]) => instance).sort();
}
// ---- Templates ----
function frame(...parts) {
const hash = createHash("sha256");
for (const part of parts) {
hash.update(`${part.length}\n`);
hash.update(part);
}
return hash.digest("hex");
}
// One template, read once: its validated role, its contract text and its hash. Refuses a missing,
// invalid or version 1 template.
export function readTemplate(rolesDir, name) {
if (typeof name !== "string" || !ID_PATTERN.test(name)) refuse(`template name must match ${ID_PATTERN}`);
const file = join(rolesDir, `${name}.json`);
let json;
try {
json = readFileSync(file);
} catch {
refuse(`template ${name} not found`);
}
let document;
try {
document = JSON.parse(json.toString("utf8"));
} catch {
refuse(`template ${name} is not valid JSON`);
}
const role = validateRoleDocument(document, file, { hireable: false });
if (role.roleVersion !== 2) refuse(`template ${name} is role version 1 and has no contract`);
const contract = readFileSync(role.contractPath);
return { name, role, contract: contract.toString("utf8"), sha256: frame(json, contract) };
}
export function templateHash(rolesDir, name) {
return readTemplate(rolesDir, name).sha256;
}
// The read: every version 2 template in rolesDir with its current hash, sorted by name. A file that
// is not a usable version 2 role (conductor-policy.json, a version 1 role) is not a template.
export function templateHashes(rolesDir) {
const out = [];
for (const entry of readdirSync(rolesDir).filter((f) => f.endsWith(".json")).sort()) {
const name = entry.slice(0, -".json".length);
if (!ID_PATTERN.test(name)) continue;
let template;
try {
template = readTemplate(rolesDir, name);
} catch (error) {
if (error?.name === "BusinessError") continue;
throw error;
}
out.push({ template: name, sha256: template.sha256 });
}
return out;
}
// The entry an import writes: the template's primitives and its contract as instructions.
export function entryFromTemplate(template) {
const { role } = template;
const out = {
tools: [...role.tools], network: role.network,
authority: { withinRole: [...role.authority.withinRole], crossRole: [...role.authority.crossRole] },
credentials: role.credentials.map((c) => (c.scopes === undefined ? { service: c.service } : { service: c.service, scopes: structuredClone(c.scopes) })),
};
if (role.messaging) out.messaging = { sendTo: [...role.messaging.sendTo], receiveFrom: [...role.messaging.receiveFrom] };
if (role.skills.length) out.skills = [...role.skills];
return { rev: 1, from: { template: template.name, sha256: template.sha256 }, role: out, instructions: template.contract };
}
// ---- The diff read ----
// A line diff of two texts: [{ op: " " | "-" | "+", line }]. Longest common subsequence over lines;
// past MAX_CELLS it reports the old text removed and the new one added, which is still exact.
const MAX_CELLS = 4_000_000;
export function lineDiff(before, after) {
const a = before.split("\n");
const b = after.split("\n");
if (a.length * b.length > MAX_CELLS) {
return [...a.map((line) => ({ op: "-", line })), ...b.map((line) => ({ op: "+", line }))];
}
const width = b.length + 1;
const table = new Uint32Array((a.length + 1) * width);
for (let i = a.length - 1; i >= 0; i--) {
for (let j = b.length - 1; j >= 0; j--) {
table[i * width + j] = a[i] === b[j] ? table[(i + 1) * width + j + 1] + 1
: Math.max(table[(i + 1) * width + j], table[i * width + j + 1]);
}
}
const out = [];
let i = 0;
let j = 0;
while (i < a.length && j < b.length) {
if (a[i] === b[j]) {
out.push({ op: " ", line: a[i] });
i++;
j++;
} else if (table[(i + 1) * width + j] >= table[i * width + j + 1]) {
out.push({ op: "-", line: a[i++] });
} else {
out.push({ op: "+", line: b[j++] });
}
}
while (i < a.length) out.push({ op: "-", line: a[i++] });
while (j < b.length) out.push({ op: "+", line: b[j++] });
return out;
}
// For a copy whose from.sha256 differs from its template's current hash: which primitives differ
// between the copy as it stands and the template as it stands, and the instructions' line diff.
// The file keeps only the hash of what was imported, so a customized copy's own edits show here
// too. Nothing is applied: apply or keep is a change decision (A4).
export function diffDefinition(document, name, rolesDir) {
const definitions = document?.definitions ?? {};
if (!Object.hasOwn(definitions, name)) refuse(`definitions declares no ${name}`);
const entry = definitions[name];
const base = { definition: name, rev: entry.rev, from: entry.from ?? null };
if (!entry.from) return { ...base, current: null, changed: false, primitives: [], instructions: [] };
let template;
try {
template = readTemplate(rolesDir, entry.from.template);
} catch (error) {
if (error?.name !== "BusinessError") throw error;
return { ...base, current: null, changed: true, templateMissing: true, primitives: [], instructions: [] };
}
if (template.sha256 === entry.from.sha256) return { ...base, current: template.sha256, changed: false, primitives: [], instructions: [] };
const now = compositionOf(entryFromTemplate(template));
const copy = compositionOf(entry);
const primitives = PRIMITIVES.filter((key) => canonicalJson(copy[key] ?? null) !== canonicalJson(now[key] ?? null));
return {
...base, current: template.sha256, changed: true, primitives,
instructions: primitives.includes("instructions") ? lineDiff(copy.instructions, now.instructions) : [],
};
}
+3 -1
View File
@@ -77,7 +77,9 @@ export function checkCommands(commands, where) {
if (/[*?[]/.test(element)) refuse(`${at} must not hold *, ? or [; the match is literal`);
}
if (prefix[0].includes("/") && !prefix[0].startsWith("/")) refuse(`${at}[0] must be a bare name or an absolute path`);
if (refusedCommand(prefix[0])) refuse(`${at} starts with ${prefix[0]}, a shell, runner or interpreter, which would grant any command`);
// Name the command by its basename, which refusedCommand matched against a closed list or
// pattern, never by an absolute host path (PR #1916).
if (refusedCommand(prefix[0])) refuse(`${at} starts with ${prefix[0].split("/").pop()}, a shell, runner or interpreter, which would grant any command`);
const key = JSON.stringify(prefix);
if (seen.has(key)) refuse(`${at} repeats a prefix`);
seen.add(key);
@@ -150,7 +150,7 @@ function createdCredentials(old, instance, definition, args) {
// services) revokes the instance's credentials for a fire or a delete and throws
// credential-revoke-failed when it can't. Null in a writer with no bus (the CLI's), which revokes
// nothing. Row 239 (#1745): writer is the organization writer, injected only by tests.
export function instanceSettings({ dir, rolesDir, system, levels = () => null, revoke = null, writer = organizationWriter({ dir, rolesDir }) }) {
export function instanceSettings({ dir, rolesDir, system, levels = () => null, revoke = null, writer = organizationWriter({ dir, rolesDir, dataRoot:system?.dataRoot ?? null }) }) {
// The definitions in rolesDir and whether add and set accept each one.
function definitions() {
@@ -168,18 +168,18 @@ const ROUTE_FIELDS = Object.freeze(["harness", "model", "thinking"]);
// Without it, a write that adds a daily key quota refuses proxy-key-refused; no other write asks.
export function launchSettings({ dir, rolesDir, system, sessions = () => [], now = () => new Date().toISOString(), levels = () => null,
keyLineage = () => { throw new OrganizationError("proxy-key-refused"); } }) {
const writer = organizationWriter({ dir, rolesDir });
const writer = organizationWriter({ dir, rolesDir, dataRoot:system?.dataRoot ?? null });
// The validated business as the file stands now (row 125: the limit checks read it at each ask).
function current(id) {
return validateBusinessDocument(writer.read(id).document, businessFilePath(id, dir), { rolesDir });
return validateBusinessDocument(writer.read(id).document, businessFilePath(id, dir), { rolesDir, dataRoot:system?.dataRoot ?? null });
}
// The file before a write, validated, for checkRoutes; null when it no longer validates, so every
// route is checked.
function before(id, document) {
try {
return validateBusinessDocument(document, businessFilePath(id, dir), { rolesDir });
return validateBusinessDocument(document, businessFilePath(id, dir), { rolesDir, dataRoot:system?.dataRoot ?? null });
} catch (e) {
if (e.name === "BusinessError") return null;
throw e;
@@ -188,7 +188,7 @@ export function launchSettings({ dir, rolesDir, system, sessions = () => [], now
function view(id) {
const { document, revision } = writer.read(id);
const business = validateBusinessDocument(document, businessFilePath(id, dir), { rolesDir });
const business = validateBusinessDocument(document, businessFilePath(id, dir), { rolesDir, dataRoot:system?.dataRoot ?? null });
if (!business.launch) throw new OrganizationError("launch-not-configured");
const instances = Object.fromEntries(Object.keys(business.roles).map(instance => {
const vars = resolveInstance({ business, system, instance }).vars;
@@ -305,7 +305,7 @@ export function launchSettings({ dir, rolesDir, system, sessions = () => [], now
if (next.roles[instance]?.definition !== entry.definition) throw new OrganizationError("organization-definition-refused");
}
// Check every instance's route before publishing the complete document.
const proposed = validateBusinessDocument(next, businessFilePath(id, dir), { rolesDir });
const proposed = validateBusinessDocument(next, businessFilePath(id, dir), { rolesDir, dataRoot:system?.dataRoot ?? null });
checkRoutes({ proposed, old: before(id, old.document), levels, catalogue });
const receipt = writer.write(id, next, { revision: args.revision, actor });
return { ...view(id), transaction: receipt.transaction };
@@ -317,7 +317,7 @@ export function launchSettings({ dir, rolesDir, system, sessions = () => [], now
// doesn't set reads null; its launch refuses no-route.
function routes(id) {
const { document, revision } = writer.read(id);
const business = validateBusinessDocument(document, businessFilePath(id, dir), { rolesDir });
const business = validateBusinessDocument(document, businessFilePath(id, dir), { rolesDir, dataRoot:system?.dataRoot ?? null });
if (!business.launch) throw new OrganizationError("launch-not-configured");
const harnesses = Array.isArray(business.plugins?.harness) ? [...business.plugins.harness] : [];
const named = new Set(harnesses);
@@ -397,7 +397,7 @@ export function launchSettings({ dir, rolesDir, system, sessions = () => [], now
next.roles[instance].vars = vars;
}
if (canonicalJson(next) === canonicalJson(old.document)) throw new OrganizationError("organization-no-change");
const proposed = validateBusinessDocument(next, businessFilePath(id, dir), { rolesDir });
const proposed = validateBusinessDocument(next, businessFilePath(id, dir), { rolesDir, dataRoot:system?.dataRoot ?? null });
checkRoutes({ proposed, old: before(id, old.document), levels, catalogue });
const receipt = writer.write(id, next, { revision: args.revision, actor });
return { ...routes(id), transaction: receipt.transaction };
+4 -2
View File
@@ -24,18 +24,20 @@ export function defaultRolesDir(env = process.env) {
export function loadSeatSets({ dir = configDir(), rolesDir = defaultRolesDir() } = {}) {
// lstat first: only nothing at the path is an absent directory. Anything else
// there (a file, a dangling symlink, a directory) must list, or it throws.
// The refusal names the directory by label and the error by its code only:
// err.message quotes the host path (PR #1916).
const businesses = join(dir, "businesses");
try {
lstatSync(businesses);
} catch (err) {
if (err.code === "ENOENT") return { librarySeats: new Set(), creativeSeats: new Set() };
throw new BusinessError(`cannot list businesses in ${dir}: ${err.message}`);
throw new BusinessError(`cannot list businesses in the config directory: ${err.code ?? "unreadable"}`);
}
let names;
try {
names = readdirSync(businesses).filter((name) => name.endsWith(".json"));
} catch (err) {
throw new BusinessError(`cannot list businesses in ${dir}: ${err.message}`);
throw new BusinessError(`cannot list businesses in the config directory: ${err.code ?? "unreadable"}`);
}
const librarySeats = new Set();
const creativeSeats = new Set();
@@ -228,3 +228,6 @@ export async function orgLock(ctx, id, { clear = false } = {}) {
}
}
}
// PL1: the organization primitive set, derived from enabled, verified manifests only.
export { organizationPlugins as buildOrganizationRegistry } from "../../plugins/src/organization.mjs";
+5
View File
@@ -67,6 +67,11 @@ export const VERB_RIGHTS = record({
'decision.preview.console': 'decide',
'decision.resolve.console': 'decide',
'hire.resolve.console': 'org.hire',
// #1873 T1: the human verbs on an organization's own definitions.
'definition.import.console': 'org.hire',
'definition.edit.console': 'org.hire',
'definition.invent.console': 'org.hire',
'definition.remove.console': 'org.hire',
'request.answer.console': 'request.write',
'request.attach.console': 'request.write',
'request.cancel.console': 'request.write',
+26 -14
View File
@@ -19,15 +19,22 @@ const encode = doc => JSON.stringify(doc, null, 2) + '\n';
const projectPath = (root, project) => join(root, 'projects', project);
function authorize(ctx) { if (!ctx || typeof ctx.instance !== 'string' || !/^[a-z][a-z0-9]{1,15}$/.test(ctx.instance) || ctx.instance === 'dev' || !same(ctx.principal, owner)) fail('org-forbidden', 'authenticated local development owner required'); }
function checkId(id) { if (typeof id !== 'string' || !ID_PATTERN.test(id)) fail('org-invalid', 'invalid id'); }
async function privateRead(file) { const fd = await fs.open(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); try { const st = await fd.stat(); if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o077)) fail('org-mismatch', 'unsafe file', file); return await fd.readFile('utf8'); } finally { await fd.close(); } }
// A filesystem error names the operation, the file by label and the error code, never the
// host path Node puts in its message (PR #1916). code, syscall and the other fields stay.
const quieted = new WeakSet();
function quiet(e, what) {
if (e && typeof e === 'object' && typeof e.syscall === 'string' && typeof e.code === 'string' && !quieted.has(e)) { e.message = `cannot ${e.syscall} ${what} (${e.code})`; quieted.add(e); }
return e;
}
async function privateRead(file, what = 'organization file') { let fd; try { fd = await fs.open(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK); } catch (e) { throw quiet(e, what); } try { const st = await fd.stat(); if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o077)) fail('org-mismatch', 'unsafe file', file); return await fd.readFile('utf8'); } catch (e) { throw quiet(e, what); } finally { await fd.close(); } }
async function atomic(file, content, exclusive = false) {
const tmp = join(dirname(file), `.org-write-${randomBytes(12).toString('hex')}`);
try { await fs.writeFile(tmp, content, { mode: 0o600, flag: 'wx' }); if (exclusive) await fs.link(tmp, file); else await fs.rename(tmp, file); } finally { await fs.rm(tmp, { force: true }); }
try { await fs.writeFile(tmp, content, { mode: 0o600, flag: 'wx' }); if (exclusive) await fs.link(tmp, file); else await fs.rename(tmp, file); } catch (e) { throw quiet(e, 'organization file'); } finally { await fs.rm(tmp, { force: true }); }
}
async function ensurePrivateDir(path) { try { await fs.mkdir(path, { mode: 0o700 }); } catch (e) { if (e.code !== 'EEXIST') throw e; } const st = await fs.lstat(path); if (!st.isDirectory() || st.isSymbolicLink() || st.uid !== process.getuid() || (st.mode & 0o077)) fail('org-mismatch', 'unsafe directory', path); }
async function ensurePrivateDir(path) { try { await fs.mkdir(path, { mode: 0o700 }); } catch (e) { if (e.code !== 'EEXIST') throw quiet(e, 'organization directory'); } const st = await fs.lstat(path).catch(e => { throw quiet(e, 'organization directory'); }); if (!st.isDirectory() || st.isSymbolicLink() || st.uid !== process.getuid() || (st.mode & 0o077)) fail('org-mismatch', 'unsafe directory', path); }
async function receipt(root, op, org, principal, fields = {}) {
const result = { op, org, at: new Date().toISOString(), subject: { ...principal }, ...fields };
const file = join(root, 'receipts.jsonl'); const fd = await fs.open(file, constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW, 0o600);
const file = join(root, 'receipts.jsonl'); const fd = await fs.open(file, constants.O_WRONLY | constants.O_APPEND | constants.O_CREAT | constants.O_NOFOLLOW, 0o600).catch(e => { throw quiet(e, 'organization receipts'); });
try { const st = await fd.stat(); if (!st.isFile() || st.uid !== process.getuid() || (st.mode & 0o077)) fail('org-mismatch', 'unsafe receipts', file); await fd.writeFile(JSON.stringify(result) + '\n'); } finally { await fd.close(); } return result;
}
async function receipts(root) {
@@ -129,11 +136,11 @@ async function readCurrentUnlocked(ctx, id, { includeArchived = false } = {}) {
if (found.state === 'archived' && !includeArchived) fail('org-archived', 'organization is archived');
if (found.state !== 'active' && !(includeArchived && found.state === 'archived')) fail('org-unavailable', 'organization is not active');
const ownerFile = join(found.root, 'owner.json');
const principal = parseJsonFileText(await privateRead(ownerFile), ownerFile, 'organization owner file'); if (!same(principal, ctx.principal)) fail('org-forbidden', 'organization owner required');
const principal = parseJsonFileText(await privateRead(ownerFile, 'organization owner file'), ownerFile, 'organization owner file'); if (!same(principal, ctx.principal)) fail('org-forbidden', 'organization owner required');
// From here the caller is the verified owner. A failure carries that proof so a list may
// show this org as broken to its owner; earlier failures prove nothing and stay hidden.
try {
const entry = (await readRegistry(ctx)).orgs[id]; const file = join(ctx.configDir, 'businesses', `${id}.json`); const raw = await privateRead(file);
const entry = (await readRegistry(ctx)).orgs[id]; const file = join(ctx.configDir, 'businesses', `${id}.json`); const raw = await privateRead(file, 'organization file');
if (!entry.digest || entry.digest !== digest(raw)) fail('org-mismatch', 'organization document and registry disagree');
const doc = parseJsonFileText(raw, file, 'organization file'); await checked(doc); return { ...found, revision: entry.revision, doc, principal };
} catch (e) { if (e && typeof e === 'object') e.ownerVerified = true; throw e; }
@@ -171,13 +178,13 @@ export async function showOrg(ctx, id, opts = {}) { authorize(ctx); const { root
export async function notificationShow(ctx, id) {
authorize(ctx); const current = await readCurrent(ctx, id);
for (const path of [join(current.root, 'notify'), join(current.root, 'notify', id)]) {
const st = await fs.lstat(path);
const st = await fs.lstat(path).catch(e => { throw quiet(e, 'notifier directory'); });
if (!st.isDirectory() || st.isSymbolicLink() || st.uid !== process.getuid() || (st.mode & 0o077)) fail('org-mismatch', 'unsafe notifier directory');
}
const file = join(current.root, 'notify', id, 'notify.json');
const st = await fs.lstat(file);
const st = await fs.lstat(file).catch(e => { throw quiet(e, 'notifier configuration file'); });
if (!st.isFile() || st.isSymbolicLink() || st.uid !== process.getuid() || (st.mode & 0o777) !== 0o600) fail('org-mismatch', 'unsafe notifier configuration');
const binding = parseJsonFileText(await privateRead(file), file, 'notifier configuration file');
const binding = parseJsonFileText(await privateRead(file, 'notifier configuration file'), file, 'notifier configuration file');
if (!binding || Object.keys(binding).sort().join(',') !== 'binding,notifyVersion' || binding.notifyVersion !== 1 || !(binding.binding === null || typeof binding.binding === 'string' && BINDING_NAME.test(binding.binding))) fail('org-mismatch', 'invalid notifier configuration');
const record = (await receipts(current.root)).find(r => r.op === 'org.create' && r.org === id && same(r.subject, current.principal));
if (!record || record.notifications !== 'none') fail('org-mismatch', 'notification choice receipt unavailable');
@@ -198,7 +205,7 @@ function options({ mode, remote, acceptLocalLossRisk }) {
}
async function git(root, args) {
const env = { PATH: process.env.PATH ?? '/usr/bin:/bin', HOME: '/nonexistent', GIT_CONFIG_NOSYSTEM: '1', GIT_CONFIG_GLOBAL: '/dev/null', GIT_TERMINAL_PROMPT: '0', LC_ALL: 'C' };
return new Promise((resolve, reject) => { const p = spawn('git', ['-c', 'user.name=Mosaic Local', '-c', '[email protected]', '-c', 'core.hooksPath=/dev/null', '-C', root, ...args], { env, stdio: ['ignore', 'pipe', 'pipe'] }); let out = '', err = ''; p.stdout.on('data', d => out += d); p.stderr.on('data', d => err += d); p.on('error', reject); p.on('close', code => code === 0 ? resolve(out.trim()) : reject(new OrgError('org-git', `git failed: ${err.trim()}`))); });
return new Promise((resolve, reject) => { const p = spawn('git', ['-c', 'user.name=Mosaic Local', '-c', '[email protected]', '-c', 'core.hooksPath=/dev/null', '-C', root, ...args], { env, stdio: ['ignore', 'pipe', 'pipe'] }); let out = '', err = ''; p.stdout.on('data', d => out += d); p.stderr.on('data', d => err += d); p.on('error', reject); p.on('close', code => code === 0 ? resolve(out.trim()) : reject(new OrgError('org-git', `git ${args[0]} failed (exit ${code})`))); });
}
async function initGit(root) { await fs.mkdir(root, { mode: 0o700 }); await git(root, ['init', '--quiet', '--template=']); await git(root, ['commit', '--quiet', '--allow-empty', '-m', 'Genesis']); return git(root, ['rev-parse', 'HEAD']); }
async function setup(ctx, id, project, opts) {
@@ -273,6 +280,8 @@ export async function restoreOrg(ctx, id, opts = {}) { return lifecycleChange(ct
// Deletion keeps a durable marker in the registry and a receipt beside the
// moved data. It never opens the contents of organization secret trees.
// The deletion folder deleteOrg generates: `<id>-<ISO time with : and . as ->-<8 hex>`.
const deletionFolderPattern = (id) => new RegExp(`^${id.replace(/[.]/g, '\\.')}-\\d{4}-\\d{2}-\\d{2}T\\d{2}-\\d{2}-\\d{2}-\\d{3}Z-[0-9a-f]{8}$`);
async function deleteInspect(path, { secret = false } = {}) {
let count = 0;
async function visit(p) {
@@ -328,7 +337,7 @@ export async function deleteOrg(ctx, id, { confirm, ifRevision, busStop } = {})
try {
await deleteSafeRoot(ctx.dataRoot);
await ensurePrivateDir(deleted); await ensurePrivateDir(destination);
try { result = parseJsonFileText(await privateRead(receiptFile), receiptFile, 'deletion receipt file'); }
try { result = parseJsonFileText(await privateRead(receiptFile, 'deletion receipt file'), receiptFile, 'deletion receipt file'); }
catch (e) { if (e.code !== 'ENOENT') throw e; result = { op: 'org.delete', org: id, actor: deletion.actor, time: deletion.time, revision: deletion.revision, destination, bus: null, moves: [], secrets: [], note: 'Purging deleted data and remote-side revocation are out of scope.' }; await atomic(receiptFile, encode(result), true); }
if (result.org !== id || result.destination !== destination || result.revision !== deletion.revision || !Array.isArray(result.moves) || !Array.isArray(result.secrets)) fail('org-mismatch', 'invalid deletion receipt');
if (!result.bus) {
@@ -380,9 +389,12 @@ export async function deleteOrg(ctx, id, { confirm, ifRevision, busStop } = {})
});
registryRemoved = true; result.registry = 'removed'; await atomic(receiptFile, encode(result)); return result;
} catch (e) {
e.deleteStep = phase; if (result) e.deleteReceipt = result;
if (registryRemoved) { e.message += `; registry removal completed but the receipt could not be finalized at ${receiptFile}`; throw e; }
e.message += `; deletion remains in progress. Inspect ${destination}/receipt.json and rerun mosaic org delete ${id} --confirm ${id}`;
e.deleteStep = phase; if (result) e.deleteReceipt = result; quiet(e, `a file in the deletion ${phase} step`);
// Name the receipt under the data root, never by host path (PR #1916). The folder comes
// from the registry, so it is named only when it has the shape this function generates.
const at = deletionFolderPattern(id).test(deletion.folder) ? `deleted/${deletion.folder}/receipt.json under the data root` : 'the deletion receipt under the data root';
if (registryRemoved) { e.message += `; registry removal completed but the receipt could not be finalized at ${at}`; throw e; }
e.message += `; deletion remains in progress. Inspect ${at} and rerun mosaic org delete ${id} --confirm ${id}`;
throw e;
}
});
@@ -10,9 +10,11 @@ import { checkModels, checkLimits, checkAccount } from "./launch.mjs";
import { checkVars } from "./vars.mjs";
import { parseCredentialRef } from "./credentials.mjs";
import { loadRole } from "./role.mjs";
import { checkDefinitions, checkDefinitionName, orgDefinitionName, ORG_PREFIX } from "./definitions.mjs";
import { END_PROTECTED } from "./vocabulary.mjs";
import { checkPlugins, checkUsageSettings } from "../../plugins/src/kinds.mjs";
import { hasModule } from "../../plugins/src/loader.mjs";
import { readPluginLock } from "../../plugins/src/pinned.mjs";
import { checkMapping } from "../../library/src/mapping.mjs";
import { forkProblem, giteaName, parseGiteaUrl, HOST_SERVICE, CREATIVE_DEFINITIONS } from "./vocabulary.mjs";
import { checkAppearance } from "./appearance.mjs";
@@ -25,7 +27,7 @@ const BOT_NAME = /^bot-[a-z0-9][a-z0-9._-]{0,60}$/;
// How long a thread's session stays up with no turn before the launcher stops
// it, when launch.threadIdleSeconds is absent (REQ-THREAD-2, row 121).
export const THREAD_IDLE_SECONDS = 900;
const TOP_KEYS = ["businessVersion", "id", "human", "arbiters", "projects", "vars", "tracker", "roles", "launch", "plugins", "hosts", "retired", "replyLimits", "usage", "appearance"];
const TOP_KEYS = ["businessVersion", "id", "human", "arbiters", "projects", "vars", "tracker", "roles", "launch", "plugins", "hosts", "retired", "replyLimits", "usage", "appearance", "definitions"];
// The config directory: next to the system config, $MOSAIC_CONFIG or
// ~/.config/mosaic-dev/config.json, the same rule mosaic-config.mjs uses.
@@ -121,7 +123,7 @@ function checkPersonaVars(vars, definition, at) {
// so the business file can't name them. The key stays known so the refusal says where they went.
const HOSTS_MOVED = "lives in the bus store now: enroll a host with a host-enroll grant (mosaic hosts status)";
function checkRoles(roles, file, rolesDir, business, hasLibrary) {
function checkRoles(roles, file, rolesDir, business, hasLibrary, orgDefinitions) {
const where = `${file} roles`;
requireObject(roles, where);
if (Object.keys(roles).length === 0) refuse(`${where} must declare at least one role instance`);
@@ -134,8 +136,14 @@ function checkRoles(roles, file, rolesDir, business, hasLibrary) {
const at = `${where}.${instance}`;
requireObject(entry, at);
rejectUnknownKeys(entry, ["definition", "holder", "vars", "tracker", "credentials", "identity", "publish", "hosts"], at);
requireId(entry.definition, `${at}.definition`);
const definition = Object.hasOwn(definitions, entry.definition) ? definitions[entry.definition] : loadRole(rolesDir, entry.definition);
// #1873 T1: "org:<name>" names one of this file's definitions; anything else is a template.
const orgName = orgDefinitionName(entry.definition);
if (orgName === null) requireId(entry.definition, `${at}.definition`);
else if (!Object.hasOwn(orgDefinitions, ORG_PREFIX + checkDefinitionName(orgName, `${at}.definition after org:`))) {
refuse(`definition-missing: ${at}.definition names ${ORG_PREFIX}${orgName}, which definitions doesn't declare`);
}
const definition = Object.hasOwn(definitions, entry.definition) ? definitions[entry.definition]
: orgName === null ? loadRole(rolesDir, entry.definition) : orgDefinitions[entry.definition];
definitions[entry.definition] = definition;
if (definition.roleVersion !== 2) refuse(`${at}: role definition ${entry.definition} is version 1 and can't back a business role instance`);
const services = definition.credentials.map((c) => c.service);
@@ -238,7 +246,9 @@ function checkRetired(retired, roles, file) {
// Validate a parsed business document read from `file`. `rolesDir` is where
// role definitions live. Returns a frozen business with its definitions.
export function validateOrganizationDocument(document, file, { rolesDir }) {
// options.assertClean is the credential store's secret check for the definitions' text, when the
// caller holds one (#1873 T1); without it the check is the empty store's.
export function validateOrganizationDocument(document, file, { rolesDir, dataRoot = null, assertClean } = {}) {
requireObject(document, "business file");
rejectUnknownKeys(document, TOP_KEYS, "business file");
if (document.businessVersion !== 1) refuse(`business file "businessVersion" must be 1 (${file})`);
@@ -252,7 +262,8 @@ export function validateOrganizationDocument(document, file, { rolesDir }) {
const vars = checkVars(document.vars ?? {}, "business", `${file} vars`);
if (document.hosts !== undefined) refuse(`${file} hosts ${HOSTS_MOVED}`);
const hasLibrary = Object.values(document.projects ?? {}).some((project) => project?.kind === "library");
const { roles, definitions } = checkRoles(document.roles, file, rolesDir, document.id, hasLibrary);
const orgDefinitions = checkDefinitions(document.definitions, file, assertClean ? { assertClean } : {});
const { roles, definitions } = checkRoles(document.roles, file, rolesDir, document.id, hasLibrary, orgDefinitions);
requireObject(document.arbiters, `${file} arbiters`);
rejectUnknownKeys(document.arbiters, ["delivery", "technical"], `${file} arbiters`);
@@ -333,8 +344,13 @@ export function validateOrganizationDocument(document, file, { rolesDir }) {
// here; whether each module loads is the loader's check, at load time.
const plugins = document.plugins === undefined ? {}
: structuredClone(checkPlugins(document.plugins, `${file} plugins`, { fail: (_code, message) => refuse(message) }));
let installedNames = new Set();
if (dataRoot !== null && plugins["system-type"]?.some(id=>!hasModule("system-type",id))) {
try { installedNames = new Set(Object.keys(readPluginLock(dataRoot).plugins)); }
catch { installedNames = null; } // the loader reports an unreadable lock; core still loads
}
for (const id of plugins["system-type"] ?? []) {
if (!hasModule("system-type", id)) refuse(`${file} plugins.system-type names unknown module ${id}`);
if (!hasModule("system-type", id) && installedNames !== null && !installedNames.has(id)) refuse(`${file} plugins.system-type names unknown module ${id}`);
}
const usage = document.usage === undefined ? undefined : checkUsageSettings(document.usage);
const appearance = checkAppearance(document.appearance, `${file} appearance`);
@@ -349,14 +365,14 @@ export function validateOrganizationDocument(document, file, { rolesDir }) {
// Load businesses/<id>.json from the config directory. The file holds
// authority, so it must belong to this user and not be writable by group
// or other.
export function loadOrganization(id, { dir = configDir(), rolesDir }) {
export function loadOrganization(id, { dir = configDir(), rolesDir, dataRoot = null }) {
if (!rolesDir) throw new Error("loadBusiness needs rolesDir");
const file = organizationFilePath(id, dir);
const document = readJsonFile(file, "business file", (stat) => {
const document = readJsonFile(file, `business file ${id}.json`, (stat) => {
if (stat.uid !== process.getuid()) refuse(`business file must belong to uid ${process.getuid()}: ${file}`);
if ((stat.mode & 0o022) !== 0) refuse(`business file must not be writable by group or other (mode ${(stat.mode & 0o777).toString(8)}): ${file}`);
});
return validateOrganizationDocument(document, file, { rolesDir });
return validateOrganizationDocument(document, file, { rolesDir, dataRoot });
}
// The old names (rename design r2, module map): the same functions, not copies.
+61 -6
View File
@@ -14,6 +14,7 @@ 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";
import { checkDefinition, checkDefinitionName, instancesNaming } from "./definitions.mjs";
export class OrganizationError extends Error {
constructor(code, message = code) {
@@ -140,7 +141,7 @@ export function setReplyLimits(writer, id, args, actor) {
return writer.write(id, { ...document, replyLimits }, { revision: args.revision, actor });
}
export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
export function organizationWriter({ dir, rolesDir, dataRoot = null, barrier = () => {} }) {
const evidence = join(dir, "businesses", ".history");
const logFile = id => join(evidence, id, "changes.jsonl");
// Keep the writer's established busy refusal when the shared registry lock
@@ -155,7 +156,7 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
const file = businessFilePath(id, dir);
const bytes = regular(file);
const document = parseJsonFileText(bytes, file, "organization file");
validateBusinessDocument(document, file, { rolesDir });
validateBusinessDocument(document, file, { rolesDir, dataRoot });
return { document, revision: sha256(bytes) };
}
@@ -297,7 +298,7 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
modelQuotaFence(old, document, actor);
usageFence(old, document, actor);
systemTypesFence(old, document, actor);
validateBusinessDocument(document, file, { rolesDir });
validateBusinessDocument(document, file, { rolesDir, dataRoot });
const bytes = JSON.stringify(document, null, 2) + "\n";
// Check and log exactly what lands on disk, for every setter: a value JSON
// can't carry (a Date, a Map) serializes as something else (row 131, sage-0314).
@@ -307,7 +308,7 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
modelQuotaFence(old, written, actor);
usageFence(old, written, actor);
systemTypesFence(old, written, actor);
validateBusinessDocument(written, file, { rolesDir });
validateBusinessDocument(written, file, { rolesDir, dataRoot });
if (beforeWrite) {
beforeWrite();
revoked = true;
@@ -352,8 +353,62 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
const projectsProtected = (old, document) => {
if (canonicalJson(document?.projects) !== canonicalJson(old.projects)) fail("organization-projects-protected");
};
// #1873 T1: definitions change only through setDefinition, which bumps rev and checks the entry.
const definitionsProtected = (old, document) => {
if (canonicalJson(document?.definitions ?? {}) !== canonicalJson(old.definitions ?? {})) fail("organization-definitions-protected");
};
function write(id, document, options) {
return commit(id, document, { ...options, guard: projectsProtected });
return commit(id, document, { ...options, guard: (old, next) => { projectsProtected(old, next); definitionsProtected(old, next); } });
}
// #1873 T1: the only writer of `definitions`. `next(current)` returns the new entry without its
// rev, or null to remove it; current is the entry or null. Nothing else in the document may change.
// An agent or the tester never writes one. A write bumps rev (1 for a new name); `rev`, when given,
// must be the current one (definition-rev-conflict). Editing or removing a definition an instance
// names refuses definition-in-use: changing a hired agent's role is a change decision (A4).
// assertClean is the credential store's held-secret check (the bus's credentials.assertClean). A
// write that adds or changes an entry needs it: with no check to run, the write refuses (R1).
function setDefinition(id, name, next, { revision, rev, actor, detail, assertClean }) {
checkDefinitionName(name, "definition name");
const logged = { ...detail, definition: name, rev: null };
const guard = (old, document) => {
const agents = Object.entries(old.roles ?? {}).flatMap(([n, role]) => [n, `${role?.definition}.${n}`]);
if (actor === "tester" || agents.includes(actor) || (old.retired ?? []).includes(actor)) fail("human-required");
const { definitions: a = {}, ...restOld } = old;
const { definitions: b = {}, ...restNew } = document ?? {};
if (canonicalJson(restNew) !== canonicalJson(restOld)) fail("organization-definitions-only");
const others = (d) => canonicalJson(Object.fromEntries(Object.entries(d).filter(([k]) => k !== name)));
if (others(a) !== others(b)) fail("organization-definitions-only");
const current = Object.hasOwn(a, name) ? a[name] : null;
const sansRev = (e) => canonicalJson({ ...e, rev: 0 });
if (current && Object.hasOwn(b, name) && sansRev(current) === sansRev(b[name])) fail("definition-unchanged", `definition ${name} is unchanged`);
if (current) {
const users = instancesNaming(old, name);
if (users.length) fail("definition-in-use", `definition ${name} is named by ${users.join(", ")}; a change to a hired agent's role is a change decision`);
}
if (Object.hasOwn(b, name)) {
const want = current ? current.rev + 1 : 1;
if (b[name]?.rev !== want) fail("definition-rev-conflict", `definition ${name} must be written at rev ${want}`);
if (typeof assertClean !== "function") fail("definition-secret-check-required", "a definition write needs the credential store's secret check; write it through the bus");
checkDefinition(name, b[name], { assertClean });
}
};
const out = commit(id, (old) => {
const definitions = { ...(old.definitions ?? {}) };
const current = Object.hasOwn(definitions, name) ? definitions[name] : null;
if (rev !== undefined && (current?.rev ?? null) !== rev) fail("definition-rev-conflict", `definition ${name} is not at rev ${rev}; read again`);
const entry = next(current);
if (entry === null) {
if (!current) fail("definition-missing", `definitions declares no ${name}`);
delete definitions[name];
} else {
definitions[name] = { ...entry, rev: current ? current.rev + 1 : 1 };
logged.rev = definitions[name].rev;
}
const { definitions: _, ...rest } = old;
return Object.keys(definitions).length ? { ...rest, definitions } : rest;
}, { revision, actor, guard: (old, document) => { projectsProtected(old, document); guard(old, document); }, detail: logged });
return { ...out, rev: logged.rev };
}
// Lifecycle Task 2: the only writer that adds a project mapping, and only the exact bootstrap
@@ -483,5 +538,5 @@ export function organizationWriter({ dir, rolesDir, barrier = () => {} }) {
});
}
return { logFile, read, readRaw, write, addProject, setProjectRemote, setProjectAgent, hold, holdAsync, findAddition, record, recover: id => withLock(id, () => recover(id)) };
return { logFile, read, readRaw, write, addProject, setProjectRemote, setProjectAgent, setDefinition, hold, holdAsync, findAddition, record, recover: id => withLock(id, () => recover(id)) };
}
@@ -4,13 +4,15 @@
// Writes go through the organization writer, which logs old and new names
// like a limit change. A write is a patch per kind (see set()): kinds it doesn't name are kept. A refused write throws a code and logs nothing.
import { organizationWriter, OrganizationError } from "./organization.mjs";
import { verifiedPlugin } from "../../plugins/src/pinned.mjs";
import { hasModule } from "../../plugins/src/loader.mjs";
import { isPlainObject } from "./util.mjs";
import { KINDS, PluginError, checkPlugins, checkUsageSettings } from "../../plugins/src/kinds.mjs";
// `exists(kind, name)`, when given, refuses a name no module answers to (the host passes the
// loader's hasModule). Without it the setter checks only the shape, as before.
export function pluginSettings({ dir, rolesDir, exists = null }) {
const writer = organizationWriter({ dir, rolesDir });
export function pluginSettings({ dir, rolesDir, exists = null, dataRoot = null }) {
const writer = organizationWriter({ dir, rolesDir, dataRoot });
function read(id) {
const { document, revision } = writer.read(id);
@@ -51,6 +53,10 @@ export function pluginSettings({ dir, rolesDir, exists = null }) {
if (exists) {
for (const [kind, setting] of Object.entries(sets)) {
for (const name of Array.isArray(setting) ? setting : [setting]) {
if (dataRoot !== null && !hasModule(kind,name)) {
try { verifiedPlugin(dataRoot,name,kind); }
catch(e) { throw new OrganizationError(e.code === "missing-lock" ? "unknown-plugin" : "invalid-plugins", "installed plugin verification refused"); }
}
if (!exists(kind, name)) throw new OrganizationError("unknown-plugin", `no ${kind} module named ${name} in this tree`);
}
}
Loaded 100 of 186 files, more files were not shown because too many files have changed in this diff. Show more